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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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