dron 0.1.20241008__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.
dron/dron.py ADDED
@@ -0,0 +1,510 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import os
5
+ import shlex
6
+ import sys
7
+ from collections import OrderedDict
8
+ from difflib import unified_diff
9
+ from itertools import tee
10
+ from pathlib import Path
11
+ from subprocess import check_call, run
12
+ from tempfile import TemporaryDirectory
13
+ from typing import Iterable, Iterator, NamedTuple, Union
14
+
15
+ import click
16
+
17
+ from . import launchd, systemd
18
+ from .api import Job, UnitName
19
+ from .common import (
20
+ ALWAYS,
21
+ IS_SYSTEMD,
22
+ Body,
23
+ State,
24
+ Unit,
25
+ UnitFile,
26
+ UnitState,
27
+ logger,
28
+ unwrap,
29
+ )
30
+ from .systemd import _systemctl
31
+
32
+ # todo appdirs?
33
+ DRON_DIR = Path('~/.config/dron').expanduser()
34
+ DRON_UNITS_DIR = DRON_DIR / 'units'
35
+ DRON_UNITS_DIR.mkdir(parents=True, exist_ok=True)
36
+
37
+
38
+ DRONTAB = DRON_DIR / 'drontab.py'
39
+
40
+
41
+ def verify_units(pre_units: list[tuple[UnitName, Body]]) -> None:
42
+ # need an inline import here in case we modify this variable from cli/tests
43
+ from .common import VERIFY_UNITS
44
+
45
+ if not VERIFY_UNITS:
46
+ return
47
+
48
+ if len(pre_units) == 0:
49
+ # otherwise systemd analayser would complain if we pass zero units
50
+ return
51
+
52
+ if not IS_SYSTEMD:
53
+ for unit_name, body in pre_units:
54
+ launchd.verify_unit(unit_name=unit_name, body=body)
55
+ else:
56
+ systemd.verify_units(pre_units=pre_units)
57
+
58
+
59
+ def verify_unit(*, unit_name: UnitName, body: Body) -> None:
60
+ return verify_units([(unit_name, body)])
61
+
62
+
63
+ def write_unit(*, unit: Unit, body: Body, prefix: Path=DRON_UNITS_DIR) -> None:
64
+ unit_file = prefix / unit
65
+
66
+ logger.info(f'writing unit file: {unit_file}')
67
+ verify_unit(unit_name=unit_file.name, body=body)
68
+ unit_file.write_text(body)
69
+
70
+
71
+ def _daemon_reload() -> None:
72
+ if IS_SYSTEMD:
73
+ check_call(_systemctl('daemon-reload'))
74
+ else:
75
+ # no-op under launchd
76
+ pass
77
+
78
+
79
+ def managed_units(*, with_body: bool) -> State:
80
+ if IS_SYSTEMD:
81
+ yield from systemd.systemd_state(with_body=with_body)
82
+ else:
83
+ yield from launchd.launchd_state(with_body=with_body)
84
+
85
+
86
+ def make_state(jobs: Iterable[Job]) -> State:
87
+ pre_units = []
88
+ names: set[Unit] = set()
89
+ for j in jobs:
90
+ uname = j.unit_name
91
+
92
+ assert uname not in names, j
93
+ names.add(uname)
94
+
95
+ if IS_SYSTEMD:
96
+ s = systemd.service(unit_name=uname, command=j.command, on_failure=j.on_failure, **j.kwargs)
97
+ pre_units.append((uname + '.service', s))
98
+
99
+ when = j.when
100
+ if when is None:
101
+ # manual job?
102
+ continue
103
+ if when == ALWAYS:
104
+ continue
105
+ t = systemd.timer(unit_name=uname, when=when)
106
+ pre_units.append((uname + '.timer', t))
107
+ else:
108
+ p = launchd.plist(unit_name=uname, command=j.command, on_failure=j.on_failure, when=j.when)
109
+ pre_units.append((uname + '.plist', p))
110
+
111
+ verify_units(pre_units)
112
+
113
+ for unit_file, body in pre_units:
114
+ yield UnitState(
115
+ unit_file=DRON_UNITS_DIR / unit_file,
116
+ body=body,
117
+ cmdline=None, # ugh, a bit crap, but from this code path cmdline doesn't matter
118
+ )
119
+
120
+
121
+ # TODO bleh. too verbose..
122
+ class Update(NamedTuple):
123
+ unit_file: UnitFile
124
+ old_body: Body
125
+ new_body: Body
126
+
127
+ @property
128
+ def unit(self) -> str:
129
+ return self.unit_file.name
130
+
131
+
132
+ class Delete(NamedTuple):
133
+ unit_file: UnitFile
134
+
135
+ @property
136
+ def unit(self) -> str:
137
+ return self.unit_file.name
138
+
139
+
140
+ class Add(NamedTuple):
141
+ unit_file: UnitFile
142
+ body: Body
143
+
144
+ @property
145
+ def unit(self) -> str:
146
+ return self.unit_file.name
147
+
148
+
149
+ Action = Union[Update, Delete, Add]
150
+ Plan = Iterable[Action]
151
+
152
+ # TODO ugh. not sure how to verify them?
153
+
154
+ def compute_plan(*, current: State, pending: State) -> Plan:
155
+ # eh, I feel like i'm reinventing something already existing here...
156
+ currentd = OrderedDict((x.unit_file, unwrap(x.body)) for x in current)
157
+ pendingd = OrderedDict((x.unit_file, unwrap(x.body)) for x in pending)
158
+
159
+ units = [c for c in currentd if c not in pendingd] + list(pendingd.keys())
160
+ for u in units:
161
+ in_cur = u in currentd
162
+ in_pen = u in pendingd
163
+ if in_cur:
164
+ if in_pen:
165
+ # TODO not even sure I should emit it if bodies match??
166
+ yield Update(unit_file=u, old_body=currentd[u], new_body=pendingd[u])
167
+ else:
168
+ yield Delete(unit_file=u)
169
+ else:
170
+ if in_pen:
171
+ yield Add(unit_file=u, body=pendingd[u])
172
+ else:
173
+ raise AssertionError("Can't happen")
174
+
175
+
176
+ # TODO it's not apply, more like 'compute' and also plan is more like a diff between states?
177
+ def apply_state(pending: State) -> None:
178
+ current = list(managed_units(with_body=True))
179
+
180
+ pending_units = {s.unit_file.name for s in pending}
181
+ def is_always_running(unit_path: Path) -> bool:
182
+ name = unit_path.stem
183
+ has_timer = f'{name}.timer' in pending_units
184
+ # TODO meh. not ideal
185
+ return not has_timer
186
+
187
+ plan = list(compute_plan(current=current, pending=pending))
188
+
189
+ deletes: list[Delete] = []
190
+ adds: list[Add] = []
191
+ _updates: list[Update] = []
192
+
193
+ for a in plan:
194
+ if isinstance(a, Delete):
195
+ deletes.append(a)
196
+ elif isinstance(a, Add):
197
+ adds.append(a)
198
+ elif isinstance(a, Update):
199
+ _updates.append(a)
200
+ else:
201
+ raise AssertionError("Can't happen", a)
202
+
203
+ if len(deletes) == len(current) and len(deletes) > 0:
204
+ msg = "Trying to delete all managed jobs"
205
+ if click.confirm(f'{msg}. Are you sure?', default=False):
206
+ pass
207
+ else:
208
+ raise RuntimeError(msg)
209
+
210
+ Diff = list[str]
211
+ nochange: list[Update] = []
212
+ updates: list[tuple[Update, Diff]] = []
213
+
214
+ for u in _updates:
215
+ unit = a.unit
216
+ diff: Diff = list(unified_diff(
217
+ u.old_body.splitlines(keepends=True),
218
+ u.new_body.splitlines(keepends=True),
219
+ ))
220
+ if len(diff) == 0:
221
+ nochange.append(u)
222
+ else:
223
+ updates.append((u, diff))
224
+
225
+ # TODO list unit names here?
226
+ logger.info(f'no change: {len(nochange)}')
227
+ logger.info(f'disabling: {len(deletes)}')
228
+ logger.info(f'updating : {len(updates)}')
229
+ logger.info(f'adding : {len(adds)}')
230
+
231
+ for a in deletes:
232
+ if IS_SYSTEMD:
233
+ # TODO stop timer first?
234
+ check_call(_systemctl('stop' , a.unit))
235
+ check_call(_systemctl('disable', a.unit))
236
+ else:
237
+ launchd.launchctl_unload(unit=Path(a.unit).stem)
238
+ for a in deletes:
239
+ (DRON_UNITS_DIR / a.unit).unlink()
240
+
241
+
242
+ for (u, diff) in updates:
243
+ unit = u.unit
244
+ unit_file = u.unit_file
245
+ logger.info(f'updating {unit}')
246
+ for d in diff:
247
+ sys.stderr.write(d)
248
+ write_unit(unit=u.unit, body=u.new_body)
249
+ if IS_SYSTEMD:
250
+ if unit.endswith('.service') and is_always_running(unit_file):
251
+ # persistent unit needs a restart to pick up change
252
+ _daemon_reload()
253
+ check_call(_systemctl('restart', unit))
254
+ else:
255
+ launchd.launchctl_reload(unit=Path(unit).stem, unit_file=unit_file)
256
+
257
+ if unit.endswith('.timer'):
258
+ _daemon_reload()
259
+ # NOTE: need to be careful -- seems that job might trigger straightaway if it's on interval schedule
260
+ # so if we change something unrelated (e.g. whitespace), it will start all jobs at the same time??
261
+ check_call(_systemctl('restart', u.unit))
262
+
263
+ for a in adds:
264
+ logger.info(f'adding {a.unit_file}')
265
+ # TODO when we add, assert that previous unit wasn't managed? otherwise we overwrite something
266
+ write_unit(unit=a.unit, body=a.body)
267
+
268
+ # need to load units before starting the timers..
269
+ _daemon_reload()
270
+
271
+ for a in adds:
272
+ unit_file = a.unit_file
273
+ unit = unit_file.name
274
+ logger.info(f'enabling {unit}')
275
+ if unit.endswith('.service'):
276
+ # quiet here because it warns that "The unit files have no installation config"
277
+ # TODO maybe add [Install] section? dunno
278
+ maybe_now = []
279
+ if is_always_running(unit_file):
280
+ maybe_now = ['--now']
281
+ check_call(_systemctl('enable', unit_file, '--quiet', *maybe_now))
282
+ elif unit.endswith('.timer'):
283
+ check_call(_systemctl('enable', unit_file, '--now'))
284
+ elif unit.endswith('.plist'):
285
+ launchd.launchctl_load(unit_file=unit_file)
286
+ else:
287
+ raise AssertionError(a)
288
+
289
+ # TODO not sure if this reload is even necessary??
290
+ _daemon_reload()
291
+
292
+
293
+ def manage(state: State) -> None:
294
+ apply_state(pending=state)
295
+
296
+
297
+ Error = str
298
+ # TODO perhaps, return Plan or error instead?
299
+
300
+ # eh, implicit convention that only one state will be emitted. oh well
301
+ def lint(tabfile: Path) -> Iterator[Exception | State]:
302
+ linters = [
303
+ [sys.executable, '-m', 'mypy', '--no-incremental', '--check-untyped', str(tabfile)],
304
+ ]
305
+
306
+ ldir = tabfile.parent
307
+ # TODO not sure if should always lint in temporary dir to prevent turds?
308
+
309
+ dron_dir = str(Path(__file__).resolve().absolute().parent)
310
+ dtab_dir = drontab_dir()
311
+
312
+ # meh.
313
+ def extra_path(variable: str, path: str, env) -> dict[str, str]:
314
+ vv = env.get(variable)
315
+ pp = path + ('' if vv is None else ':' + vv)
316
+ return {**env, variable: pp}
317
+
318
+ errors = []
319
+ for l in linters:
320
+ scmd = ' '.join(map(shlex.quote, l))
321
+ logger.info(f'Running: {scmd}')
322
+ with TemporaryDirectory() as td:
323
+ env = {**os.environ}
324
+ env = extra_path('MYPYPATH' , dtab_dir, env)
325
+
326
+ r = run(l, cwd=str(ldir), env=env, check=False)
327
+ if r.returncode == 0:
328
+ logger.info('OK')
329
+ continue
330
+ else:
331
+ logger.error(f'FAIL: code: {r.returncode}')
332
+ errors.append('error')
333
+ if len(errors) > 0:
334
+ yield RuntimeError('Python linting failed!')
335
+ return
336
+
337
+ # TODO just add options to skip python lint? so it always goes through same code paths
338
+
339
+ try:
340
+ jobs = load_jobs(tabfile=tabfile, ppath=Path(dtab_dir))
341
+ except Exception as e:
342
+ # TODO could add better logging here? 'i.e. error while loading jobs'
343
+ logger.exception(e)
344
+ yield e
345
+ return
346
+
347
+ try:
348
+ state = list(make_state(jobs))
349
+ except Exception as e:
350
+ logger.exception(e)
351
+ yield e
352
+ return
353
+
354
+ yield state
355
+
356
+
357
+ def test_do_lint(tmp_path: Path) -> None:
358
+ import pytest
359
+
360
+
361
+ def ok(body: str) -> None:
362
+ tpath = Path(tmp_path) / 'drontab.py'
363
+ tpath.write_text(body)
364
+ do_lint(tabfile=tpath)
365
+
366
+ def fails(body: str) -> None:
367
+ with pytest.raises(Exception):
368
+ ok(body)
369
+
370
+ fails(body='''
371
+ None.whatever
372
+ ''')
373
+
374
+ # no jobs
375
+ fails(body='''
376
+ ''')
377
+
378
+ ok(body='''
379
+ def jobs():
380
+ yield from []
381
+ ''')
382
+
383
+ ok(body='''
384
+ from dron.api import job
385
+ def jobs():
386
+ yield job(
387
+ 'hourly',
388
+ ['/bin/echo', '123'],
389
+ unit_name='unit_test',
390
+ )
391
+ ''')
392
+
393
+ from .systemd import _is_missing_systemd
394
+
395
+ if not _is_missing_systemd():
396
+ from .cli import _drontab_example
397
+
398
+ # this test doesn't work without systemd yet, because launchd adapter doesn't support unquoted commands, at least yet..
399
+ example = _drontab_example()
400
+ # ugh. some hackery to make it find the executable..
401
+ echo = " '/bin/echo"
402
+ example = example.replace(" 'linkchecker", echo).replace(" '/home/user/scripts/run-borg", echo).replace(" 'ping", " '/bin/ping")
403
+ ok(body=example)
404
+
405
+
406
+ def do_lint(tabfile: Path) -> State:
407
+ eit, vit = tee(lint(tabfile))
408
+ errors = [r for r in eit if isinstance(r, Exception)]
409
+ values = [r for r in vit if not isinstance(r, Exception)]
410
+ assert len(errors) == 0, errors
411
+ [state] = values
412
+ return state
413
+
414
+
415
+ def drontab_dir() -> str:
416
+ # meeh
417
+ return str(DRONTAB.resolve().absolute().parent)
418
+
419
+
420
+ def load_jobs(tabfile: Path, ppath: Path) -> Iterator[Job]:
421
+ pp = str(ppath)
422
+ sys.path.insert(0, pp)
423
+ try:
424
+ spec = importlib.util.spec_from_file_location(tabfile.name, tabfile)
425
+ assert spec is not None, tabfile
426
+ loader = spec.loader
427
+ assert loader is not None, (tabfile, spec)
428
+ module = importlib.util.module_from_spec(spec)
429
+ loader.exec_module(module)
430
+ finally:
431
+ sys.path.remove(pp) # extremely meh..
432
+
433
+ jobs = module.jobs
434
+ emitted: dict[str, Job] = {}
435
+ for job in jobs():
436
+ assert isinstance(job, Job), job # just in case for dumb typos
437
+ assert job.unit_name not in emitted, (job, emitted[job.unit_name])
438
+ yield job
439
+ emitted[job.unit_name] = job
440
+
441
+
442
+ def apply(tabfile: Path) -> None:
443
+ state = do_lint(tabfile)
444
+ manage(state=state)
445
+
446
+
447
+ get_entries_for_monitor = (
448
+ systemd.get_entries_for_monitor
449
+ if IS_SYSTEMD else
450
+ launchd.get_entries_for_monitor
451
+ )
452
+
453
+
454
+ def main() -> None:
455
+ from . import cli
456
+
457
+ cli.main()
458
+
459
+
460
+ if __name__ == '__main__':
461
+ main()
462
+
463
+
464
+ # TODO stuff I learnt:
465
+ # TODO systemd-analyze --user unit-paths
466
+ # TODO blame!
467
+ # systemd-analyze verify -- check syntax
468
+
469
+ # TODO would be nice to revert... via contextmanager?
470
+ # TODO assert that managed by dron
471
+ # TODO not sure what rollback should do w.r.t to
472
+ # TODO perhaps, only reenable changed ones? ugh. makes it trickier...
473
+
474
+ # TODO wonder if I remove timers, do they drop counts?
475
+ # TODO FIXME ok, for now, it's fine, but with more sophisticated timers might be a bit annoying
476
+
477
+ # TODO use python's literate types?
478
+
479
+
480
+ # TODO wow, that's quite annoying. so timer has to be separate file. oh well.
481
+
482
+ # TODO tui for confirming changes, show short diff?
483
+
484
+ # TODO actually for me, stuff like 'hourly' makes little sense; I usually space out in time..
485
+
486
+ # https://bugs.python.org/issue31528 eh, probably can't use configparser.. plaintext is good enough though.
487
+
488
+
489
+ # TODO later, implement logic for cleaning up old jobs
490
+
491
+
492
+ # TODO not sure if should do one by one or all at once?
493
+ # yeah, makes sense to do all at once...
494
+ # TODO warn about dirty state?
495
+
496
+
497
+ # TODO test with 'fake' systemd dir?
498
+
499
+ # TODO the assumption is that managed jobs are not changed manually, or changed in a way that doesn't break anything
500
+ # in general it's impossible to prevent anyway
501
+
502
+ # def update_unit(unit_file: Unit, old_body: Body, new_body: Body) -> Action:
503
+ # if old_body == new_body:
504
+ # pass # TODO no-op?
505
+ # else:
506
+ # raise RuntimeError(unit_file, old_body, new_body)
507
+ # # TODO hmm FIXME!! yield is a nice way to make function lazy??
508
+
509
+
510
+ # TODO that perhaps? https://askubuntu.com/a/897317/427470