ominfra 0.0.0.dev88__py3-none-any.whl → 0.0.0.dev90__py3-none-any.whl

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.
@@ -41,8 +41,8 @@ class AwsSigner:
41
41
 
42
42
  @dc.dataclass(frozen=True)
43
43
  class Credentials:
44
- access_key: str
45
- secret_key: str = dc.field(repr=False)
44
+ access_key_id: str
45
+ secret_access_key: str = dc.field(repr=False)
46
46
 
47
47
  @dc.dataclass(frozen=True)
48
48
  class Request:
@@ -198,7 +198,7 @@ class V4AwsSigner(AwsSigner):
198
198
 
199
199
  #
200
200
 
201
- key = self._creds.secret_key
201
+ key = self._creds.secret_access_key
202
202
  key_date = self._sha256_sign(f'AWS4{key}'.encode('utf-8'), req_dt[:8]) # noqa
203
203
  key_region = self._sha256_sign(key_date, self._region_name)
204
204
  key_service = self._sha256_sign(key_region, self._service_name)
@@ -208,7 +208,7 @@ class V4AwsSigner(AwsSigner):
208
208
  #
209
209
 
210
210
  cred_scope = '/'.join([
211
- self._creds.access_key,
211
+ self._creds.access_key_id,
212
212
  *scope_parts,
213
213
  ])
214
214
  auth = f'{algorithm} ' + ', '.join([
@@ -0,0 +1 @@
1
+ # @omlish-lite
@@ -0,0 +1 @@
1
+ # @omlish-lite
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ # @omlish-script
3
+ import argparse
4
+ import datetime
5
+ import json
6
+ import re
7
+ import sys
8
+ import time
9
+ import uuid
10
+
11
+
12
+ def _main() -> None:
13
+ parser = argparse.ArgumentParser()
14
+
15
+ parser.add_argument('n', type=int, default=10, nargs='?')
16
+ parser.add_argument('--message', default='message {i}', nargs='?')
17
+ parser.add_argument('--sleep-s', type=float, nargs='?')
18
+ parser.add_argument('--sleep-n', type=int, nargs='?')
19
+
20
+ parser.add_argument('--after-cursor', nargs='?')
21
+
22
+ # Ignored
23
+ parser.add_argument('--output', nargs='?')
24
+ parser.add_argument('--follow', action='store_true')
25
+ parser.add_argument('--show-cursor', action='store_true')
26
+ parser.add_argument('--since', nargs='?')
27
+
28
+ args = parser.parse_args()
29
+
30
+ if (ac := args.after_cursor) is not None:
31
+ if not (m := re.fullmatch(r'cursor:(?P<n>\d+)', ac)):
32
+ raise ValueError(ac)
33
+ start = int(m.groupdict()['n'])
34
+ else:
35
+ start = 0
36
+
37
+ for i in range(start, args.n):
38
+ if args.sleep_s:
39
+ if not args.sleep_n or (i and i % args.sleep_n == 0):
40
+ time.sleep(args.sleep_s)
41
+
42
+ ts_us = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() * 1_000_000 # noqa
43
+ dct = {
44
+ 'MESSAGE': args.message.format(i=i),
45
+ 'MESSAGE_ID': uuid.uuid4().hex,
46
+ '__CURSOR': f'cursor:{i}',
47
+ '_SOURCE_REALTIME_TIMESTAMP': str(int(ts_us)),
48
+ }
49
+ print(json.dumps(dct, indent=None, separators=(',', ':')))
50
+ sys.stdout.flush()
51
+
52
+
53
+ if __name__ == '__main__':
54
+ _main()
@@ -1,5 +1,4 @@
1
1
  # ruff: noqa: UP006 UP007
2
- # @omlish-lite
3
2
  import dataclasses as dc
4
3
  import json
5
4
  import typing as ta
@@ -0,0 +1,108 @@
1
+ # ruff: noqa: UP007
2
+ import fcntl
3
+ import os.path
4
+ import queue # noqa
5
+ import subprocess
6
+ import time
7
+ import typing as ta
8
+
9
+ from omlish.lite.cached import cached_nullary
10
+ from omlish.lite.check import check_not_none
11
+ from omlish.lite.logs import log
12
+ from omlish.lite.subprocesses import subprocess_shell_wrap_exec
13
+
14
+ from ..threadworker import ThreadWorker
15
+ from .messages import JournalctlMessage # noqa
16
+ from .messages import JournalctlMessageBuilder
17
+
18
+
19
+ class JournalctlTailerWorker(ThreadWorker):
20
+ DEFAULT_CMD: ta.ClassVar[ta.Sequence[str]] = ['journalctl']
21
+
22
+ def __init__(
23
+ self,
24
+ output, # type: queue.Queue[ta.Sequence[JournalctlMessage]]
25
+ *,
26
+ since: ta.Optional[str] = None,
27
+ after_cursor: ta.Optional[str] = None,
28
+
29
+ cmd: ta.Optional[ta.Sequence[str]] = None,
30
+ shell_wrap: bool = False,
31
+
32
+ read_size: int = 0x4000,
33
+ sleep_s: float = 1.,
34
+
35
+ **kwargs: ta.Any,
36
+ ) -> None:
37
+ super().__init__(**kwargs)
38
+
39
+ self._output = output
40
+
41
+ self._since = since
42
+ self._after_cursor = after_cursor
43
+
44
+ self._cmd = cmd or self.DEFAULT_CMD
45
+ self._shell_wrap = shell_wrap
46
+
47
+ self._read_size = read_size
48
+ self._sleep_s = sleep_s
49
+
50
+ self._mb = JournalctlMessageBuilder()
51
+
52
+ self._proc: ta.Optional[subprocess.Popen] = None
53
+
54
+ @cached_nullary
55
+ def _full_cmd(self) -> ta.Sequence[str]:
56
+ cmd = [
57
+ *self._cmd,
58
+ '--output', 'json',
59
+ '--show-cursor',
60
+ '--follow',
61
+ ]
62
+
63
+ if self._since is not None:
64
+ cmd.extend(['--since', self._since])
65
+
66
+ if self._after_cursor is not None:
67
+ cmd.extend(['--after-cursor', self._after_cursor])
68
+
69
+ if self._shell_wrap:
70
+ cmd = list(subprocess_shell_wrap_exec(*cmd))
71
+
72
+ return cmd
73
+
74
+ def _run(self) -> None:
75
+ with subprocess.Popen(
76
+ self._full_cmd(),
77
+ stdout=subprocess.PIPE,
78
+ ) as self._proc:
79
+ stdout = check_not_none(self._proc.stdout)
80
+
81
+ fd = stdout.fileno()
82
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
83
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
84
+
85
+ while True:
86
+ if not self._heartbeat():
87
+ break
88
+
89
+ while stdout.readable():
90
+ if not self._heartbeat():
91
+ break
92
+
93
+ buf = stdout.read(self._read_size)
94
+ if not buf:
95
+ log.debug('Journalctl empty read')
96
+ break
97
+
98
+ log.debug('Journalctl read buffer: %r', buf)
99
+ msgs = self._mb.feed(buf)
100
+ if msgs:
101
+ self._output.put(msgs)
102
+
103
+ if self._proc.poll() is not None:
104
+ log.critical('Journalctl process terminated')
105
+ break
106
+
107
+ log.debug('Journalctl readable')
108
+ time.sleep(self._sleep_s)
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ # ruff: noqa: UP007
3
+ # @omlish-amalg ../../../scripts/journald2aws.py
4
+ """
5
+ TODO:
6
+ - create log group
7
+ - log stats - chunk sizes etc
8
+
9
+ ==
10
+
11
+ https://www.freedesktop.org/software/systemd/man/latest/journalctl.html
12
+
13
+ journalctl:
14
+ -o json
15
+ --show-cursor
16
+
17
+ --since "2012-10-30 18:17:16"
18
+ --until "2012-10-30 18:17:16"
19
+
20
+ --after-cursor <cursor>
21
+
22
+ ==
23
+
24
+ https://www.freedesktop.org/software/systemd/man/latest/systemd.journal-fields.html
25
+
26
+ ==
27
+
28
+ @dc.dataclass(frozen=True)
29
+ class Journald2AwsConfig:
30
+ log_group_name: str
31
+ log_stream_name: str
32
+
33
+ aws_batch_size: int = 1_000
34
+ aws_flush_interval_s: float = 1.
35
+ """
36
+ import argparse
37
+ import contextlib
38
+ import dataclasses as dc
39
+ import json
40
+ import os.path
41
+ import queue
42
+ import sys
43
+ import time
44
+ import typing as ta
45
+ import urllib.request
46
+
47
+ from omlish.lite.cached import cached_nullary
48
+ from omlish.lite.check import check_non_empty_str
49
+ from omlish.lite.check import check_not_none
50
+ from omlish.lite.logs import configure_standard_logging
51
+ from omlish.lite.logs import log
52
+ from omlish.lite.marshal import unmarshal_obj
53
+ from omlish.lite.pidfile import Pidfile
54
+ from omlish.lite.runtime import is_debugger_attached
55
+
56
+ from ..auth import AwsSigner
57
+ from ..logs import AwsLogMessagePoster
58
+ from ..logs import AwsPutLogEventsResponse
59
+ from .journald.messages import JournalctlMessage # noqa
60
+ from .journald.tailer import JournalctlTailerWorker
61
+
62
+
63
+ @dc.dataclass(frozen=True)
64
+ class JournalctlOpts:
65
+ after_cursor: ta.Optional[str] = None
66
+
67
+ since: ta.Optional[str] = None
68
+ until: ta.Optional[str] = None
69
+
70
+
71
+ class JournalctlToAws:
72
+ @dc.dataclass(frozen=True)
73
+ class Config:
74
+ pid_file: ta.Optional[str] = None
75
+
76
+ cursor_file: ta.Optional[str] = None
77
+
78
+ #
79
+
80
+ aws_log_group_name: str = 'omlish'
81
+ aws_log_stream_name: ta.Optional[str] = None
82
+
83
+ aws_access_key_id: ta.Optional[str] = None
84
+ aws_secret_access_key: ta.Optional[str] = dc.field(default=None, repr=False)
85
+
86
+ aws_region_name: str = 'us-west-1'
87
+
88
+ #
89
+
90
+ journalctl_cmd: ta.Optional[ta.Sequence[str]] = None
91
+
92
+ journalctl_after_cursor: ta.Optional[str] = None
93
+ journalctl_since: ta.Optional[str] = None
94
+
95
+ #
96
+
97
+ dry_run: bool = False
98
+
99
+ def __init__(self, config: Config) -> None:
100
+ super().__init__()
101
+ self._config = config
102
+
103
+ #
104
+
105
+ _es: contextlib.ExitStack
106
+
107
+ def __enter__(self) -> 'JournalctlToAws':
108
+ self._es = contextlib.ExitStack().__enter__()
109
+ return self
110
+
111
+ def __exit__(self, exc_type, exc_val, exc_tb):
112
+ return self._es.__exit__(exc_type, exc_val, exc_tb)
113
+
114
+ #
115
+
116
+ @cached_nullary
117
+ def _pidfile(self) -> ta.Optional[Pidfile]:
118
+ if self._config.pid_file is None:
119
+ return None
120
+
121
+ pfp = os.path.expanduser(self._config.pid_file)
122
+
123
+ log.info('Opening pidfile %s', pfp)
124
+
125
+ pf = self._es.enter_context(Pidfile(pfp))
126
+ pf.write()
127
+ return pf
128
+
129
+ def _ensure_locked(self) -> None:
130
+ if (pf := self._pidfile()) is not None:
131
+ pf.ensure_locked()
132
+
133
+ #
134
+
135
+ def _read_cursor_file(self) -> ta.Optional[str]:
136
+ self._ensure_locked()
137
+
138
+ if not (cf := self._config.cursor_file):
139
+ return None
140
+ cf = os.path.expanduser(cf)
141
+
142
+ try:
143
+ with open(cf) as f:
144
+ return f.read().strip()
145
+ except FileNotFoundError:
146
+ return None
147
+
148
+ def _write_cursor_file(self, cursor: str) -> None:
149
+ self._ensure_locked()
150
+
151
+ if not (cf := self._config.cursor_file):
152
+ return
153
+ cf = os.path.expanduser(cf)
154
+
155
+ log.info('Writing cursor file %s : %s', cf, cursor)
156
+ with open(ncf := cf + '.next', 'w') as f:
157
+ f.write(cursor)
158
+
159
+ os.rename(ncf, cf)
160
+
161
+ #
162
+
163
+ @cached_nullary
164
+ def _aws_credentials(self) -> AwsSigner.Credentials:
165
+ return AwsSigner.Credentials(
166
+ access_key_id=check_non_empty_str(self._config.aws_access_key_id),
167
+ secret_access_key=check_non_empty_str(self._config.aws_secret_access_key),
168
+ )
169
+
170
+ @cached_nullary
171
+ def _aws_log_message_poster(self) -> AwsLogMessagePoster:
172
+ return AwsLogMessagePoster(
173
+ log_group_name=self._config.aws_log_group_name,
174
+ log_stream_name=check_non_empty_str(self._config.aws_log_stream_name),
175
+ region_name=self._config.aws_region_name,
176
+ credentials=check_not_none(self._aws_credentials()),
177
+ )
178
+
179
+ #
180
+
181
+ @cached_nullary
182
+ def _journalctl_message_queue(self): # type: () -> queue.Queue[ta.Sequence[JournalctlMessage]]
183
+ return queue.Queue()
184
+
185
+ @cached_nullary
186
+ def _journalctl_tailer_worker(self) -> JournalctlTailerWorker:
187
+ ac: ta.Optional[str] = self._config.journalctl_after_cursor
188
+ if ac is None:
189
+ ac = self._read_cursor_file()
190
+ if ac is not None:
191
+ log.info('Starting from cursor %s', ac)
192
+
193
+ if (since := self._config.journalctl_since):
194
+ log.info('Starting since %s', since)
195
+
196
+ return JournalctlTailerWorker(
197
+ self._journalctl_message_queue(),
198
+
199
+ since=since,
200
+ after_cursor=ac,
201
+
202
+ cmd=self._config.journalctl_cmd,
203
+ shell_wrap=is_debugger_attached(),
204
+ )
205
+
206
+ #
207
+
208
+ def run(self) -> None:
209
+ self._ensure_locked()
210
+
211
+ q = self._journalctl_message_queue()
212
+ jtw = self._journalctl_tailer_worker()
213
+ mp = self._aws_log_message_poster()
214
+
215
+ jtw.start()
216
+
217
+ last_cursor: ta.Optional[str] = None # noqa
218
+ while True:
219
+ if not jtw.is_alive():
220
+ log.critical('Journalctl tailer worker died')
221
+ break
222
+
223
+ msgs: ta.Sequence[JournalctlMessage] = q.get()
224
+ log.debug('%r', msgs)
225
+
226
+ cur_cursor: ta.Optional[str] = None
227
+ for m in reversed(msgs):
228
+ if m.cursor is not None:
229
+ cur_cursor = m.cursor
230
+ break
231
+
232
+ if not msgs:
233
+ log.warning('Empty queue chunk')
234
+ continue
235
+
236
+ [post] = mp.feed([mp.Message(
237
+ message=json.dumps(m.dct),
238
+ ts_ms=int(time.time() * 1000.),
239
+ ) for m in msgs])
240
+ log.debug('%r', post)
241
+
242
+ if not self._config.dry_run:
243
+ with urllib.request.urlopen(urllib.request.Request( # noqa
244
+ post.url,
245
+ method='POST',
246
+ headers=dict(post.headers),
247
+ data=post.data,
248
+ )) as resp:
249
+ response = AwsPutLogEventsResponse.from_aws(json.loads(resp.read().decode('utf-8')))
250
+ log.debug('%r', response)
251
+
252
+ if cur_cursor is not None:
253
+ self._write_cursor_file(cur_cursor)
254
+ last_cursor = cur_cursor # noqa
255
+
256
+
257
+ def _main() -> None:
258
+ parser = argparse.ArgumentParser()
259
+
260
+ parser.add_argument('--config-file')
261
+ parser.add_argument('-v', '--verbose', action='store_true')
262
+
263
+ parser.add_argument('--after-cursor', nargs='?')
264
+ parser.add_argument('--since', nargs='?')
265
+ parser.add_argument('--dry-run', action='store_true')
266
+
267
+ parser.add_argument('--message', nargs='?')
268
+ parser.add_argument('--real', action='store_true')
269
+
270
+ args = parser.parse_args()
271
+
272
+ #
273
+
274
+ configure_standard_logging('DEBUG' if args.verbose else 'INFO')
275
+
276
+ #
277
+
278
+ config: JournalctlToAws.Config
279
+ if args.config_file:
280
+ with open(os.path.expanduser(args.config_file)) as cf:
281
+ config_dct = json.load(cf)
282
+ config = unmarshal_obj(config_dct, JournalctlToAws.Config)
283
+ else:
284
+ config = JournalctlToAws.Config()
285
+
286
+ #
287
+
288
+ for k in ['aws_access_key_id', 'aws_secret_access_key']:
289
+ if not getattr(config, k) and k.upper() in os.environ:
290
+ config = dc.replace(config, **{k: os.environ.get(k.upper())}) # type: ignore
291
+
292
+ #
293
+
294
+ if not args.real:
295
+ config = dc.replace(config, journalctl_cmd=[
296
+ sys.executable,
297
+ os.path.join(os.path.dirname(__file__), 'journald', 'genmessages.py'),
298
+ '--sleep-n', '2',
299
+ '--sleep-s', '.5',
300
+ *(['--message', args.message] if args.message else []),
301
+ '100000',
302
+ ])
303
+
304
+ #
305
+
306
+ for a in ['after_cursor', 'since', 'dry_run']:
307
+ if (pa := getattr(args, a)):
308
+ config = dc.replace(config, **{a: pa})
309
+
310
+ #
311
+
312
+ with JournalctlToAws(config) as jta:
313
+ jta.run()
314
+
315
+
316
+ if __name__ == '__main__':
317
+ _main()
@@ -0,0 +1,64 @@
1
+ # ruff: noqa: UP007
2
+ import abc
3
+ import threading
4
+ import time
5
+ import typing as ta
6
+
7
+ from omlish.lite.logs import log
8
+
9
+
10
+ class ThreadWorker(abc.ABC):
11
+ def __init__(
12
+ self,
13
+ *,
14
+ stop_event: ta.Optional[threading.Event] = None,
15
+ ) -> None:
16
+ super().__init__()
17
+
18
+ if stop_event is None:
19
+ stop_event = threading.Event()
20
+ self._stop_event = stop_event
21
+
22
+ self._thread: ta.Optional[threading.Thread] = None
23
+
24
+ self._last_heartbeat: ta.Optional[float] = None
25
+
26
+ #
27
+
28
+ def should_stop(self) -> bool:
29
+ return self._stop_event.is_set()
30
+
31
+ #
32
+
33
+ @property
34
+ def last_heartbeat(self) -> ta.Optional[float]:
35
+ return self._last_heartbeat
36
+
37
+ def _heartbeat(self) -> bool:
38
+ self._last_heartbeat = time.time()
39
+
40
+ if self.should_stop():
41
+ log.info('Stopping: %s', self)
42
+ return False
43
+
44
+ return True
45
+
46
+ #
47
+
48
+ def is_alive(self) -> bool:
49
+ return (thr := self._thread) is not None and thr.is_alive()
50
+
51
+ def start(self) -> None:
52
+ thr = threading.Thread(target=self._run)
53
+ self._thread = thr
54
+ thr.start()
55
+
56
+ @abc.abstractmethod
57
+ def _run(self) -> None:
58
+ raise NotImplementedError
59
+
60
+ def stop(self) -> None:
61
+ raise NotImplementedError
62
+
63
+ def cleanup(self) -> None: # noqa
64
+ pass