ominfra 0.0.0.dev77__tar.gz → 0.0.0.dev79__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {ominfra-0.0.0.dev77/ominfra.egg-info → ominfra-0.0.0.dev79}/PKG-INFO +3 -3
  2. ominfra-0.0.0.dev79/ominfra/clouds/aws/auth.py +228 -0
  3. ominfra-0.0.0.dev79/ominfra/clouds/aws/dataclasses.py +149 -0
  4. ominfra-0.0.0.dev79/ominfra/clouds/aws/journald2aws/journald.py +67 -0
  5. ominfra-0.0.0.dev79/ominfra/clouds/aws/logs.py +173 -0
  6. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/_executor.py +17 -0
  7. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/pyremote/_runcommands.py +17 -0
  8. ominfra-0.0.0.dev77/ominfra/supervisor/_supervisor.py → ominfra-0.0.0.dev79/ominfra/scripts/supervisor.py +52 -34
  9. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/dispatchers.py +36 -33
  10. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/supervisor.py +1 -1
  11. ominfra-0.0.0.dev79/ominfra/tailscale/__init__.py +0 -0
  12. ominfra-0.0.0.dev79/ominfra/tools/__init__.py +0 -0
  13. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79/ominfra.egg-info}/PKG-INFO +3 -3
  14. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra.egg-info/SOURCES.txt +6 -1
  15. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra.egg-info/requires.txt +2 -2
  16. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/pyproject.toml +3 -3
  17. ominfra-0.0.0.dev77/ominfra/clouds/aws/auth.py +0 -223
  18. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/LICENSE +0 -0
  19. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/MANIFEST.in +0 -0
  20. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/README.rst +0 -0
  21. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/.manifests.json +0 -0
  22. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/__about__.py +0 -0
  23. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/__init__.py +0 -0
  24. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/clouds/__init__.py +0 -0
  25. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/clouds/aws/__init__.py +0 -0
  26. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/clouds/aws/__main__.py +0 -0
  27. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/clouds/aws/cli.py +0 -0
  28. {ominfra-0.0.0.dev77/ominfra/deploy → ominfra-0.0.0.dev79/ominfra/clouds/aws/journald2aws}/__init__.py +0 -0
  29. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/clouds/aws/metadata.py +0 -0
  30. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/cmds.py +0 -0
  31. {ominfra-0.0.0.dev77/ominfra/deploy/executor/concerns → ominfra-0.0.0.dev79/ominfra/deploy}/__init__.py +0 -0
  32. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/configs.py +0 -0
  33. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/__init__.py +0 -0
  34. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/base.py +0 -0
  35. {ominfra-0.0.0.dev77/ominfra/manage → ominfra-0.0.0.dev79/ominfra/deploy/executor/concerns}/__init__.py +0 -0
  36. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/dirs.py +0 -0
  37. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/nginx.py +0 -0
  38. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/repo.py +0 -0
  39. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/supervisor.py +0 -0
  40. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/systemd.py +0 -0
  41. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/user.py +0 -0
  42. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/concerns/venv.py +0 -0
  43. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/executor/main.py +0 -0
  44. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/__init__.py +0 -0
  45. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/_main.py +0 -0
  46. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/base.py +0 -0
  47. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/configs.py +0 -0
  48. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/deploy.py +0 -0
  49. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/main.py +0 -0
  50. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/nginx.py +0 -0
  51. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/repo.py +0 -0
  52. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/runtime.py +0 -0
  53. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/site.py +0 -0
  54. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/supervisor.py +0 -0
  55. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/poly/venv.py +0 -0
  56. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/deploy/remote.py +0 -0
  57. {ominfra-0.0.0.dev77/ominfra/pyremote → ominfra-0.0.0.dev79/ominfra/manage}/__init__.py +0 -0
  58. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/manage/manage.py +0 -0
  59. {ominfra-0.0.0.dev77/ominfra/tailscale → ominfra-0.0.0.dev79/ominfra/pyremote}/__init__.py +0 -0
  60. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/pyremote/bootstrap.py +0 -0
  61. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/pyremote/runcommands.py +0 -0
  62. {ominfra-0.0.0.dev77/ominfra/tools → ominfra-0.0.0.dev79/ominfra/scripts}/__init__.py +0 -0
  63. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/ssh.py +0 -0
  64. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/__init__.py +0 -0
  65. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/__main__.py +0 -0
  66. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/compat.py +0 -0
  67. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/configs.py +0 -0
  68. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/context.py +0 -0
  69. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/datatypes.py +0 -0
  70. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/events.py +0 -0
  71. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/exceptions.py +0 -0
  72. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/poller.py +0 -0
  73. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/process.py +0 -0
  74. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/states.py +0 -0
  75. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/supervisor/types.py +0 -0
  76. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/tailscale/cli.py +0 -0
  77. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra/tools/listresources.py +0 -0
  78. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra.egg-info/dependency_links.txt +0 -0
  79. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra.egg-info/entry_points.txt +0 -0
  80. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/ominfra.egg-info/top_level.txt +0 -0
  81. {ominfra-0.0.0.dev77 → ominfra-0.0.0.dev79}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ominfra
3
- Version: 0.0.0.dev77
3
+ Version: 0.0.0.dev79
4
4
  Summary: ominfra
5
5
  Author: wrmsr
6
6
  License: BSD-3-Clause
@@ -12,8 +12,8 @@ Classifier: Operating System :: OS Independent
12
12
  Classifier: Operating System :: POSIX
13
13
  Requires-Python: ~=3.12
14
14
  License-File: LICENSE
15
- Requires-Dist: omdev==0.0.0.dev77
16
- Requires-Dist: omlish==0.0.0.dev77
15
+ Requires-Dist: omdev==0.0.0.dev79
16
+ Requires-Dist: omlish==0.0.0.dev79
17
17
  Provides-Extra: all
18
18
  Requires-Dist: paramiko~=3.5; extra == "all"
19
19
  Requires-Dist: asyncssh~=2.17; python_version < "3.13" and extra == "all"
@@ -0,0 +1,228 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ """
4
+ https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
5
+
6
+ TODO:
7
+ - https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
8
+ - boto / s3transfer upload_fileobj doesn't stream either lol - eagerly calcs Content-MD5
9
+ - sts tokens
10
+ - !! fix canonical_qs - sort params
11
+ - secrets
12
+ """
13
+ import dataclasses as dc
14
+ import datetime
15
+ import hashlib
16
+ import hmac
17
+ import typing as ta
18
+ import urllib.parse
19
+
20
+ from omlish.lite.check import check_equal
21
+ from omlish.lite.check import check_non_empty_str
22
+ from omlish.lite.check import check_not_isinstance
23
+
24
+
25
+ ##
26
+
27
+
28
+ class AwsSigner:
29
+ def __init__(
30
+ self,
31
+ creds: 'AwsSigner.Credentials',
32
+ region_name: str,
33
+ service_name: str,
34
+ ) -> None:
35
+ super().__init__()
36
+ self._creds = creds
37
+ self._region_name = region_name
38
+ self._service_name = service_name
39
+
40
+ #
41
+
42
+ @dc.dataclass(frozen=True)
43
+ class Credentials:
44
+ access_key: str
45
+ secret_key: str = dc.field(repr=False)
46
+
47
+ @dc.dataclass(frozen=True)
48
+ class Request:
49
+ method: str
50
+ url: str
51
+ headers: ta.Mapping[str, ta.Sequence[str]] = dc.field(default_factory=dict)
52
+ payload: bytes = b''
53
+
54
+ #
55
+
56
+ ISO8601 = '%Y%m%dT%H%M%SZ'
57
+
58
+ #
59
+
60
+ @staticmethod
61
+ def _host_from_url(url: str) -> str:
62
+ url_parts = urllib.parse.urlsplit(url)
63
+ host = check_non_empty_str(url_parts.hostname)
64
+ default_ports = {
65
+ 'http': 80,
66
+ 'https': 443,
67
+ }
68
+ if url_parts.port is not None:
69
+ if url_parts.port != default_ports.get(url_parts.scheme):
70
+ host = '%s:%d' % (host, url_parts.port)
71
+ return host
72
+
73
+ @staticmethod
74
+ def _lower_case_http_map(d: ta.Mapping[str, ta.Sequence[str]]) -> ta.Mapping[str, ta.Sequence[str]]:
75
+ o: ta.Dict[str, ta.List[str]] = {}
76
+ for k, vs in d.items():
77
+ o.setdefault(k.lower(), []).extend(check_not_isinstance(vs, str))
78
+ return o
79
+
80
+ #
81
+
82
+ @staticmethod
83
+ def _as_bytes(data: ta.Union[str, bytes]) -> bytes:
84
+ return data if isinstance(data, bytes) else data.encode('utf-8')
85
+
86
+ @staticmethod
87
+ def _sha256(data: ta.Union[str, bytes]) -> str:
88
+ return hashlib.sha256(AwsSigner._as_bytes(data)).hexdigest()
89
+
90
+ @staticmethod
91
+ def _sha256_sign(key: bytes, msg: ta.Union[str, bytes]) -> bytes:
92
+ return hmac.new(key, AwsSigner._as_bytes(msg), hashlib.sha256).digest()
93
+
94
+ @staticmethod
95
+ def _sha256_sign_hex(key: bytes, msg: ta.Union[str, bytes]) -> str:
96
+ return hmac.new(key, AwsSigner._as_bytes(msg), hashlib.sha256).hexdigest()
97
+
98
+ _EMPTY_SHA256: str
99
+
100
+ #
101
+
102
+ _SIGNED_HEADERS_BLACKLIST = frozenset([
103
+ 'authorization',
104
+ 'expect',
105
+ 'user-agent',
106
+ 'x-amzn-trace-id',
107
+ ])
108
+
109
+ def _validate_request(self, req: Request) -> None:
110
+ check_non_empty_str(req.method)
111
+ check_equal(req.method.upper(), req.method)
112
+ for k, vs in req.headers.items():
113
+ check_equal(k.strip(), k)
114
+ for v in vs:
115
+ check_equal(v.strip(), v)
116
+
117
+
118
+ AwsSigner._EMPTY_SHA256 = AwsSigner._sha256(b'') # noqa
119
+
120
+
121
+ ##
122
+
123
+
124
+ class V4AwsSigner(AwsSigner):
125
+ def sign(
126
+ self,
127
+ req: AwsSigner.Request,
128
+ *,
129
+ sign_payload: bool = False,
130
+ utcnow: ta.Optional[datetime.datetime] = None,
131
+ ) -> ta.Mapping[str, ta.Sequence[str]]:
132
+ self._validate_request(req)
133
+
134
+ #
135
+
136
+ if utcnow is None:
137
+ utcnow = datetime.datetime.now(tz=datetime.timezone.utc) # noqa
138
+ req_dt = utcnow.strftime(self.ISO8601)
139
+
140
+ #
141
+
142
+ parsed_url = urllib.parse.urlsplit(req.url)
143
+ canon_uri = parsed_url.path
144
+ canon_qs = parsed_url.query
145
+
146
+ #
147
+
148
+ headers_to_sign: ta.Dict[str, ta.List[str]] = {
149
+ k: list(v)
150
+ for k, v in self._lower_case_http_map(req.headers).items()
151
+ if k not in self._SIGNED_HEADERS_BLACKLIST
152
+ }
153
+
154
+ if 'host' not in headers_to_sign:
155
+ headers_to_sign['host'] = [self._host_from_url(req.url)]
156
+
157
+ headers_to_sign['x-amz-date'] = [req_dt]
158
+
159
+ hashed_payload = self._sha256(req.payload) if req.payload else self._EMPTY_SHA256
160
+ if sign_payload:
161
+ headers_to_sign['x-amz-content-sha256'] = [hashed_payload]
162
+
163
+ sorted_header_names = sorted(headers_to_sign)
164
+ canon_headers = ''.join([
165
+ ':'.join((k, ','.join(headers_to_sign[k]))) + '\n'
166
+ for k in sorted_header_names
167
+ ])
168
+ signed_headers = ';'.join(sorted_header_names)
169
+
170
+ #
171
+
172
+ canon_req = '\n'.join([
173
+ req.method,
174
+ canon_uri,
175
+ canon_qs,
176
+ canon_headers,
177
+ signed_headers,
178
+ hashed_payload,
179
+ ])
180
+
181
+ #
182
+
183
+ algorithm = 'AWS4-HMAC-SHA256'
184
+ scope_parts = [
185
+ req_dt[:8],
186
+ self._region_name,
187
+ self._service_name,
188
+ 'aws4_request',
189
+ ]
190
+ scope = '/'.join(scope_parts)
191
+ hashed_canon_req = self._sha256(canon_req)
192
+ string_to_sign = '\n'.join([
193
+ algorithm,
194
+ req_dt,
195
+ scope,
196
+ hashed_canon_req,
197
+ ])
198
+
199
+ #
200
+
201
+ key = self._creds.secret_key
202
+ key_date = self._sha256_sign(f'AWS4{key}'.encode('utf-8'), req_dt[:8]) # noqa
203
+ key_region = self._sha256_sign(key_date, self._region_name)
204
+ key_service = self._sha256_sign(key_region, self._service_name)
205
+ key_signing = self._sha256_sign(key_service, 'aws4_request')
206
+ sig = self._sha256_sign_hex(key_signing, string_to_sign)
207
+
208
+ #
209
+
210
+ cred_scope = '/'.join([
211
+ self._creds.access_key,
212
+ *scope_parts,
213
+ ])
214
+ auth = f'{algorithm} ' + ', '.join([
215
+ f'Credential={cred_scope}',
216
+ f'SignedHeaders={signed_headers}',
217
+ f'Signature={sig}',
218
+ ])
219
+
220
+ #
221
+
222
+ out = {
223
+ 'Authorization': [auth],
224
+ 'X-Amz-Date': [req_dt],
225
+ }
226
+ if sign_payload:
227
+ out['X-Amz-Content-SHA256'] = [hashed_payload]
228
+ return out
@@ -0,0 +1,149 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ import collections.abc
4
+ import dataclasses as dc
5
+ import typing as ta
6
+
7
+ from omlish.lite.cached import cached_nullary
8
+ from omlish.lite.reflect import get_optional_alias_arg
9
+ from omlish.lite.reflect import is_generic_alias
10
+ from omlish.lite.reflect import is_optional_alias
11
+ from omlish.lite.strings import camel_case
12
+
13
+
14
+ class AwsDataclass:
15
+ class Raw(dict):
16
+ pass
17
+
18
+ #
19
+
20
+ _aws_meta: ta.ClassVar[ta.Optional['AwsDataclassMeta']] = None
21
+
22
+ @classmethod
23
+ def _get_aws_meta(cls) -> 'AwsDataclassMeta':
24
+ try:
25
+ return cls.__dict__['_aws_meta']
26
+ except KeyError:
27
+ pass
28
+ ret = cls._aws_meta = AwsDataclassMeta(cls)
29
+ return ret
30
+
31
+ #
32
+
33
+ def to_aws(self) -> ta.Mapping[str, ta.Any]:
34
+ return self._get_aws_meta().converters().d2a(self)
35
+
36
+ @classmethod
37
+ def from_aws(cls, v: ta.Mapping[str, ta.Any]) -> 'AwsDataclass':
38
+ return cls._get_aws_meta().converters().a2d(v)
39
+
40
+
41
+ @dc.dataclass(frozen=True)
42
+ class AwsDataclassMeta:
43
+ cls: ta.Type['AwsDataclass']
44
+
45
+ #
46
+
47
+ class Field(ta.NamedTuple):
48
+ d_name: str
49
+ a_name: str
50
+ is_opt: bool
51
+ is_seq: bool
52
+ dc_cls: ta.Optional[ta.Type['AwsDataclass']]
53
+
54
+ @cached_nullary
55
+ def fields(self) -> ta.Sequence[Field]:
56
+ fs = []
57
+ for f in dc.fields(self.cls): # type: ignore # noqa
58
+ d_name = f.name
59
+ a_name = camel_case(d_name, lower=True)
60
+
61
+ is_opt = False
62
+ is_seq = False
63
+ dc_cls = None
64
+
65
+ c = f.type
66
+ if c is AwsDataclass.Raw:
67
+ continue
68
+
69
+ if is_optional_alias(c):
70
+ is_opt = True
71
+ c = get_optional_alias_arg(c)
72
+
73
+ if is_generic_alias(c) and ta.get_origin(c) is collections.abc.Sequence:
74
+ is_seq = True
75
+ [c] = ta.get_args(c)
76
+
77
+ if is_generic_alias(c):
78
+ raise TypeError(c)
79
+
80
+ if isinstance(c, type) and issubclass(c, AwsDataclass):
81
+ dc_cls = c
82
+
83
+ fs.append(AwsDataclassMeta.Field(
84
+ d_name=d_name,
85
+ a_name=a_name,
86
+ is_opt=is_opt,
87
+ is_seq=is_seq,
88
+ dc_cls=dc_cls,
89
+ ))
90
+
91
+ return fs
92
+
93
+ #
94
+
95
+ class Converters(ta.NamedTuple):
96
+ d2a: ta.Callable
97
+ a2d: ta.Callable
98
+
99
+ @cached_nullary
100
+ def converters(self) -> Converters:
101
+ for df in dc.fields(self.cls): # type: ignore # noqa
102
+ c = df.type
103
+
104
+ if is_optional_alias(c):
105
+ c = get_optional_alias_arg(c)
106
+
107
+ if c is AwsDataclass.Raw:
108
+ rf = df.name
109
+ break
110
+
111
+ else:
112
+ rf = None
113
+
114
+ fs = [
115
+ (f, f.dc_cls._get_aws_meta().converters() if f.dc_cls is not None else None) # noqa
116
+ for f in self.fields()
117
+ ]
118
+
119
+ def d2a(o):
120
+ dct = {}
121
+ for f, cs in fs:
122
+ x = getattr(o, f.d_name)
123
+ if x is None:
124
+ continue
125
+ if cs is not None:
126
+ if f.is_seq:
127
+ x = list(map(cs.d2a, x))
128
+ else:
129
+ x = cs.d2a(x)
130
+ dct[f.a_name] = x
131
+ return dct
132
+
133
+ def a2d(v):
134
+ dct = {}
135
+ for f, cs in fs:
136
+ x = v.get(f.a_name)
137
+ if x is None:
138
+ continue
139
+ if cs is not None:
140
+ if f.is_seq:
141
+ x = list(map(cs.a2d, x))
142
+ else:
143
+ x = cs.a2d(x)
144
+ dct[f.d_name] = x
145
+ if rf is not None:
146
+ dct[rf] = self.cls.Raw(v)
147
+ return self.cls(**dct)
148
+
149
+ return AwsDataclassMeta.Converters(d2a, a2d)
@@ -0,0 +1,67 @@
1
+ # ruff: noqa: UP006 UP007
2
+ # @omlish-lite
3
+ import dataclasses as dc
4
+ import json
5
+ import typing as ta
6
+
7
+ from omlish.lite.check import check_isinstance
8
+ from omlish.lite.io import DelimitingBuffer
9
+ from omlish.lite.logs import log
10
+
11
+
12
+ @dc.dataclass(frozen=True)
13
+ class JournalctlMessage:
14
+ raw: bytes
15
+ dct: ta.Optional[ta.Mapping[str, ta.Any]] = None
16
+ cursor: ta.Optional[str] = None
17
+ ts_us: ta.Optional[int] = None # microseconds UTC
18
+
19
+
20
+ class JournalctlMessageBuilder:
21
+ def __init__(self) -> None:
22
+ super().__init__()
23
+
24
+ self._buf = DelimitingBuffer(b'\n')
25
+
26
+ _cursor_field = '__CURSOR'
27
+ _timestamp_field = '_SOURCE_REALTIME_TIMESTAMP'
28
+
29
+ def _make_message(self, raw: bytes) -> JournalctlMessage:
30
+ dct = None
31
+ cursor = None
32
+ ts = None
33
+
34
+ try:
35
+ dct = json.loads(raw.decode('utf-8', 'replace'))
36
+ except Exception: # noqa
37
+ log.exception('Failed to parse raw message: %r', raw)
38
+
39
+ else:
40
+ cursor = dct.get(self._cursor_field)
41
+
42
+ if tsv := dct.get(self._timestamp_field):
43
+ if isinstance(tsv, str):
44
+ try:
45
+ ts = int(tsv)
46
+ except ValueError:
47
+ try:
48
+ ts = int(float(tsv))
49
+ except ValueError:
50
+ log.exception('Failed to parse timestamp: %r', tsv)
51
+ elif isinstance(tsv, (int, float)):
52
+ ts = int(tsv)
53
+ else:
54
+ log.exception('Invalid timestamp: %r', tsv)
55
+
56
+ return JournalctlMessage(
57
+ raw=raw,
58
+ dct=dct,
59
+ cursor=cursor,
60
+ ts_us=ts,
61
+ )
62
+
63
+ def feed(self, data: bytes) -> ta.Sequence[JournalctlMessage]:
64
+ ret: ta.List[JournalctlMessage] = []
65
+ for line in self._buf.feed(data):
66
+ ret.append(self._make_message(check_isinstance(line, bytes))) # type: ignore
67
+ return ret
@@ -0,0 +1,173 @@
1
+ # @omlish-lite
2
+ # ruff: noqa: UP007
3
+ """
4
+ https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html :
5
+ - The maximum batch size is 1,048,576 bytes. This size is calculated as the sum of all event messages in UTF-8, plus 26
6
+ bytes for each log event.
7
+ - None of the log events in the batch can be more than 2 hours in the future.
8
+ - None of the log events in the batch can be more than 14 days in the past. Also, none of the log events can be from
9
+ earlier than the retention period of the log group.
10
+ - The log events in the batch must be in chronological order by their timestamp. The timestamp is the time that the
11
+ event occurred, expressed as the number of milliseconds after Jan 1, 1970 00:00:00 UTC. (In AWS Tools for PowerShell
12
+ and the AWS SDK for .NET, the timestamp is specified in .NET format: yyyy-mm-ddThh:mm:ss. For example,
13
+ 2017-09-15T13:45:30.)
14
+ - A batch of log events in a single request cannot span more than 24 hours. Otherwise, the operation fails.
15
+ - Each log event can be no larger than 256 KB.
16
+ - The maximum number of log events in a batch is 10,000.
17
+ """
18
+ import dataclasses as dc
19
+ import json
20
+ import typing as ta
21
+
22
+ from omlish.lite.check import check_non_empty_str
23
+ from omlish.lite.check import check_single
24
+
25
+ from .auth import AwsSigner
26
+ from .auth import V4AwsSigner
27
+ from .dataclasses import AwsDataclass
28
+
29
+
30
+ ##
31
+
32
+
33
+ @dc.dataclass(frozen=True)
34
+ class AwsLogEvent(AwsDataclass):
35
+ message: str
36
+ timestamp: int # milliseconds UTC
37
+
38
+
39
+ @dc.dataclass(frozen=True)
40
+ class AwsPutLogEventsRequest(AwsDataclass):
41
+ log_group_name: str
42
+ log_stream_name: str
43
+ log_events: ta.Sequence[AwsLogEvent]
44
+ sequence_token: ta.Optional[str] = None
45
+
46
+
47
+ @dc.dataclass(frozen=True)
48
+ class AwsRejectedLogEventsInfo(AwsDataclass):
49
+ expired_log_event_end_index: ta.Optional[int] = None
50
+ too_new_log_event_start_index: ta.Optional[int] = None
51
+ too_old_log_event_end_index: ta.Optional[int] = None
52
+
53
+
54
+ @dc.dataclass(frozen=True)
55
+ class AwsPutLogEventsResponse(AwsDataclass):
56
+ next_sequence_token: ta.Optional[str] = None
57
+ rejected_log_events_info: ta.Optional[AwsRejectedLogEventsInfo] = None
58
+
59
+ raw: ta.Optional[AwsDataclass.Raw] = None
60
+
61
+
62
+ ##
63
+
64
+
65
+ class AwsLogMessagePoster:
66
+ """
67
+ TODO:
68
+ - max_items
69
+ - max_bytes - manually build body
70
+ - flush_interval
71
+ - !! sort by timestamp
72
+ """
73
+
74
+ DEFAULT_URL = 'https://logs.{region_name}.amazonaws.com/' # noqa
75
+
76
+ DEFAULT_SERVICE_NAME = 'logs'
77
+
78
+ DEFAULT_TARGET = 'Logs_20140328.PutLogEvents'
79
+ DEFAULT_CONTENT_TYPE = 'application/x-amz-json-1.1'
80
+
81
+ DEFAULT_HEADERS: ta.Mapping[str, str] = {
82
+ 'X-Amz-Target': DEFAULT_TARGET,
83
+ 'Content-Type': DEFAULT_CONTENT_TYPE,
84
+ }
85
+
86
+ def __init__(
87
+ self,
88
+ log_group_name: str,
89
+ log_stream_name: str,
90
+ region_name: str,
91
+ credentials: AwsSigner.Credentials,
92
+
93
+ url: ta.Optional[str] = None,
94
+ service_name: str = DEFAULT_SERVICE_NAME,
95
+ headers: ta.Optional[ta.Mapping[str, str]] = None,
96
+ extra_headers: ta.Optional[ta.Mapping[str, str]] = None,
97
+ ) -> None:
98
+ super().__init__()
99
+
100
+ self._log_group_name = check_non_empty_str(log_group_name)
101
+ self._log_stream_name = check_non_empty_str(log_stream_name)
102
+
103
+ if url is None:
104
+ url = self.DEFAULT_URL.format(region_name=region_name)
105
+ self._url = url
106
+
107
+ if headers is None:
108
+ headers = self.DEFAULT_HEADERS
109
+ if extra_headers is not None:
110
+ headers = {**headers, **extra_headers}
111
+ self._headers = {k: [v] for k, v in headers.items()}
112
+
113
+ self._signer = V4AwsSigner(
114
+ credentials,
115
+ region_name,
116
+ service_name,
117
+ )
118
+
119
+ #
120
+
121
+ @dc.dataclass(frozen=True)
122
+ class Message:
123
+ message: str
124
+ ts_ms: int # milliseconds UTC
125
+
126
+ @dc.dataclass(frozen=True)
127
+ class Post:
128
+ url: str
129
+ headers: ta.Mapping[str, str]
130
+ data: bytes
131
+
132
+ def feed(self, messages: ta.Sequence[Message]) -> ta.Sequence[Post]:
133
+ if not messages:
134
+ return []
135
+
136
+ payload = AwsPutLogEventsRequest(
137
+ log_group_name=self._log_group_name,
138
+ log_stream_name=self._log_stream_name,
139
+ log_events=[
140
+ AwsLogEvent(
141
+ message=m.message,
142
+ timestamp=m.ts_ms,
143
+ )
144
+ for m in messages
145
+ ],
146
+ )
147
+
148
+ body = json.dumps(
149
+ payload.to_aws(),
150
+ indent=None,
151
+ separators=(',', ':'),
152
+ ).encode('utf-8')
153
+
154
+ sig_req = V4AwsSigner.Request(
155
+ method='POST',
156
+ url=self._url,
157
+ headers=self._headers,
158
+ payload=body,
159
+ )
160
+
161
+ sig_headers = self._signer.sign(
162
+ sig_req,
163
+ sign_payload=False,
164
+ )
165
+ sig_req = dc.replace(sig_req, headers={**sig_req.headers, **sig_headers})
166
+
167
+ post = AwsLogMessagePoster.Post(
168
+ url=self._url,
169
+ headers={k: check_single(v) for k, v in sig_req.headers.items()},
170
+ data=sig_req.payload,
171
+ )
172
+
173
+ return [post]
@@ -168,6 +168,23 @@ def check_state(v: bool, msg: str = 'Illegal state') -> None:
168
168
  raise ValueError(msg)
169
169
 
170
170
 
171
+ def check_equal(l: T, r: T) -> T:
172
+ if l != r:
173
+ raise ValueError(l, r)
174
+ return l
175
+
176
+
177
+ def check_not_equal(l: T, r: T) -> T:
178
+ if l == r:
179
+ raise ValueError(l, r)
180
+ return l
181
+
182
+
183
+ def check_single(vs: ta.Iterable[T]) -> T:
184
+ [v] = vs
185
+ return v
186
+
187
+
171
188
  ########################################
172
189
  # ../../../../omlish/lite/json.py
173
190
 
@@ -251,6 +251,23 @@ def check_state(v: bool, msg: str = 'Illegal state') -> None:
251
251
  raise ValueError(msg)
252
252
 
253
253
 
254
+ def check_equal(l: T, r: T) -> T:
255
+ if l != r:
256
+ raise ValueError(l, r)
257
+ return l
258
+
259
+
260
+ def check_not_equal(l: T, r: T) -> T:
261
+ if l == r:
262
+ raise ValueError(l, r)
263
+ return l
264
+
265
+
266
+ def check_single(vs: ta.Iterable[T]) -> T:
267
+ [v] = vs
268
+ return v
269
+
270
+
254
271
  ########################################
255
272
  # ../../../omlish/lite/json.py
256
273