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/__init__.py +6 -0
- dron/__main__.py +5 -0
- dron/api.py +106 -0
- dron/cli.py +382 -0
- dron/common.py +174 -0
- dron/conftest.py +18 -0
- dron/dron.py +510 -0
- dron/launchd.py +415 -0
- dron/launchd_wrapper.py +90 -0
- dron/monitor.py +157 -0
- dron/notify/common.py +53 -0
- dron/notify/email.py +43 -0
- dron/notify/ntfy_common.py +24 -0
- dron/notify/ntfy_desktop.py +15 -0
- dron/notify/ntfy_telegram.py +12 -0
- dron/notify/telegram.py +42 -0
- dron/py.typed +0 -0
- dron/systemd.py +542 -0
- dron/tests/test_dron.py +119 -0
- dron-0.1.20241008.dist-info/LICENSE.txt +21 -0
- dron-0.1.20241008.dist-info/METADATA +47 -0
- dron-0.1.20241008.dist-info/RECORD +25 -0
- dron-0.1.20241008.dist-info/WHEEL +5 -0
- dron-0.1.20241008.dist-info/entry_points.txt +2 -0
- dron-0.1.20241008.dist-info/top_level.txt +1 -0
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
|