zabbix-crontroller 0.0.1__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.
- zabbix_crontroller/__init__.py +8 -0
- zabbix_crontroller/app.py +596 -0
- zabbix_crontroller/invocation.py +150 -0
- zabbix_crontroller/parser.py +466 -0
- zabbix_crontroller-0.0.1.dist-info/METADATA +235 -0
- zabbix_crontroller-0.0.1.dist-info/RECORD +9 -0
- zabbix_crontroller-0.0.1.dist-info/WHEEL +4 -0
- zabbix_crontroller-0.0.1.dist-info/entry_points.txt +2 -0
- zabbix_crontroller-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Monitor cron jobs while keeping the parser independently importable."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main(argv: list[str] | None = None) -> int:
|
|
5
|
+
"""Delegate to the application without importing it during library use."""
|
|
6
|
+
from .app import main as app_main
|
|
7
|
+
|
|
8
|
+
return app_main(argv)
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Run cron commands with bounded evidence capture and validate installations.
|
|
2
|
+
|
|
3
|
+
The application targets Linux with cron. It uses only the standard library and
|
|
4
|
+
never submits data to Zabbix or reads system logs. Monitoring failures do not
|
|
5
|
+
prevent a valid command from running or replace its exit status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import select
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from typing import BinaryIO
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
from .invocation import JOB_IDENTIFIER
|
|
26
|
+
from .parser import CronJob, ParseError, parse_crontab
|
|
27
|
+
|
|
28
|
+
CAPTURE_LIMIT = 10 * 1024 * 1024
|
|
29
|
+
RETAIN_RUNS = 20
|
|
30
|
+
DRAIN_SECONDS = 2.0
|
|
31
|
+
_CHUNK_SIZE = 64 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now() -> str:
|
|
35
|
+
"""Return a UTC timestamp suitable for a JSON run record."""
|
|
36
|
+
return datetime.now(timezone.utc).isoformat()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _diagnostic(message: str) -> None:
|
|
40
|
+
"""Attempt a stderr diagnostic without turning a broken sink into failure."""
|
|
41
|
+
flags = None
|
|
42
|
+
try:
|
|
43
|
+
flags = fcntl.fcntl(2, fcntl.F_GETFL)
|
|
44
|
+
fcntl.fcntl(2, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
45
|
+
os.write(
|
|
46
|
+
2, f"zabbix-crontroller: {message}\n".encode("utf-8", "backslashreplace")
|
|
47
|
+
)
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
finally:
|
|
51
|
+
if flags is not None:
|
|
52
|
+
try:
|
|
53
|
+
fcntl.fcntl(2, fcntl.F_SETFL, flags)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _problem(record: dict, message: str) -> None:
|
|
59
|
+
"""Retain a monitoring error and attempt to report it to the operator."""
|
|
60
|
+
record["errors"].append(message)
|
|
61
|
+
_diagnostic(message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _state_directory() -> Path:
|
|
65
|
+
"""Resolve per-user storage without expanding variables in explicit values."""
|
|
66
|
+
if "ZC_STATE_DIR" in os.environ:
|
|
67
|
+
root = Path(os.environ["ZC_STATE_DIR"])
|
|
68
|
+
if not root.is_absolute():
|
|
69
|
+
raise ValueError("ZC_STATE_DIR must be an absolute path")
|
|
70
|
+
return root
|
|
71
|
+
state_home = os.environ.get("XDG_STATE_HOME", "")
|
|
72
|
+
if state_home and Path(state_home).is_absolute():
|
|
73
|
+
return Path(state_home) / "zabbix-crontroller"
|
|
74
|
+
return Path.home() / ".local" / "state" / "zabbix-crontroller"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _private_directory(path: Path) -> None:
|
|
78
|
+
"""Create missing directories privately and reject an unsafe evidence root."""
|
|
79
|
+
missing = []
|
|
80
|
+
ancestor = path
|
|
81
|
+
while not ancestor.exists() and not ancestor.is_symlink():
|
|
82
|
+
missing.append(ancestor)
|
|
83
|
+
ancestor = ancestor.parent
|
|
84
|
+
for directory in reversed(missing):
|
|
85
|
+
try:
|
|
86
|
+
directory.mkdir(mode=0o700)
|
|
87
|
+
directory.chmod(0o700)
|
|
88
|
+
except FileExistsError:
|
|
89
|
+
pass
|
|
90
|
+
info = path.lstat()
|
|
91
|
+
if (
|
|
92
|
+
not stat.S_ISDIR(info.st_mode)
|
|
93
|
+
or info.st_uid != os.getuid()
|
|
94
|
+
or info.st_mode & 0o077
|
|
95
|
+
):
|
|
96
|
+
raise OSError(
|
|
97
|
+
f"Evidence directory must be owned by this user with mode 0700: {path}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _private_file(path: Path, *, exclusive: bool = True) -> BinaryIO:
|
|
102
|
+
"""Open a private regular file without following a final-component symlink."""
|
|
103
|
+
flags = os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK
|
|
104
|
+
if exclusive:
|
|
105
|
+
flags |= os.O_EXCL
|
|
106
|
+
descriptor = os.open(path, flags, 0o600)
|
|
107
|
+
try:
|
|
108
|
+
info = os.fstat(descriptor)
|
|
109
|
+
if (
|
|
110
|
+
not stat.S_ISREG(info.st_mode)
|
|
111
|
+
or info.st_uid != os.getuid()
|
|
112
|
+
or info.st_mode & 0o077
|
|
113
|
+
):
|
|
114
|
+
raise OSError(
|
|
115
|
+
f"Evidence file must be private and owned by this user: {path}"
|
|
116
|
+
)
|
|
117
|
+
os.fchmod(descriptor, 0o600)
|
|
118
|
+
return os.fdopen(descriptor, "r+b", buffering=0)
|
|
119
|
+
except BaseException:
|
|
120
|
+
os.close(descriptor)
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _sync_directory(path: Path) -> None:
|
|
125
|
+
"""Persist directory entries after creation or atomic record replacement."""
|
|
126
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
|
127
|
+
try:
|
|
128
|
+
os.fsync(descriptor)
|
|
129
|
+
finally:
|
|
130
|
+
os.close(descriptor)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_all(file: BinaryIO, data: bytes) -> int:
|
|
134
|
+
"""Write all bytes to a local evidence file, including partial writes."""
|
|
135
|
+
view = memoryview(data)
|
|
136
|
+
written = 0
|
|
137
|
+
while written < len(view):
|
|
138
|
+
count = file.write(view[written:])
|
|
139
|
+
if not count:
|
|
140
|
+
raise OSError("Evidence write made no progress")
|
|
141
|
+
written += count
|
|
142
|
+
return written
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Store:
|
|
146
|
+
"""Own one invocation's private directory and atomic status publication."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, job_id: str, run_id: str) -> None:
|
|
149
|
+
"""Create storage without changing the job's inherited umask."""
|
|
150
|
+
root = _state_directory()
|
|
151
|
+
_private_directory(root)
|
|
152
|
+
self.job_directory = root / job_id
|
|
153
|
+
_private_directory(self.job_directory)
|
|
154
|
+
self.directory = self.job_directory / run_id
|
|
155
|
+
self.directory.mkdir(mode=0o700)
|
|
156
|
+
self.directory.chmod(0o700)
|
|
157
|
+
_sync_directory(root)
|
|
158
|
+
_sync_directory(self.job_directory)
|
|
159
|
+
|
|
160
|
+
def save(self, record: dict) -> None:
|
|
161
|
+
"""Publish a complete, durable JSON snapshot by atomic replacement."""
|
|
162
|
+
temporary = self.directory / f".run-{uuid.uuid4().hex}.tmp"
|
|
163
|
+
try:
|
|
164
|
+
with _private_file(temporary) as file:
|
|
165
|
+
_write_all(file, (json.dumps(record, indent=2) + "\n").encode("utf-8"))
|
|
166
|
+
os.fsync(file.fileno())
|
|
167
|
+
os.replace(temporary, self.directory / "run.json")
|
|
168
|
+
_sync_directory(self.directory)
|
|
169
|
+
finally:
|
|
170
|
+
temporary.unlink(missing_ok=True)
|
|
171
|
+
|
|
172
|
+
def prune(self) -> None:
|
|
173
|
+
"""Serialise retention and remove only recognised, completed run folders."""
|
|
174
|
+
with _private_file(
|
|
175
|
+
self.job_directory / ".cleanup.lock", exclusive=False
|
|
176
|
+
) as lock:
|
|
177
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
178
|
+
completed = []
|
|
179
|
+
for directory in self.job_directory.iterdir():
|
|
180
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
181
|
+
continue
|
|
182
|
+
try:
|
|
183
|
+
if uuid.UUID(directory.name).hex != directory.name:
|
|
184
|
+
continue
|
|
185
|
+
contents = list(directory.iterdir())
|
|
186
|
+
if any(
|
|
187
|
+
item.is_symlink() or not item.is_file() for item in contents
|
|
188
|
+
):
|
|
189
|
+
continue
|
|
190
|
+
if {item.name for item in contents} != {
|
|
191
|
+
"run.json",
|
|
192
|
+
"stdout.log",
|
|
193
|
+
"stderr.log",
|
|
194
|
+
}:
|
|
195
|
+
continue
|
|
196
|
+
record = json.loads(
|
|
197
|
+
(directory / "run.json").read_text(encoding="utf-8")
|
|
198
|
+
)
|
|
199
|
+
if (
|
|
200
|
+
not isinstance(record, dict)
|
|
201
|
+
or record.get("schema_version") != 1
|
|
202
|
+
or record.get("status") not in ("finished", "spawn_failed")
|
|
203
|
+
or record.get("run_id") != directory.name
|
|
204
|
+
or record.get("job_id") != self.job_directory.name
|
|
205
|
+
or record.get("uid") != os.getuid()
|
|
206
|
+
or not isinstance(record.get("finished_at"), str)
|
|
207
|
+
):
|
|
208
|
+
continue
|
|
209
|
+
finished = datetime.fromisoformat(record["finished_at"])
|
|
210
|
+
if finished.tzinfo is None:
|
|
211
|
+
continue
|
|
212
|
+
completed.append((finished, directory.name, directory))
|
|
213
|
+
except OSError, ValueError, TypeError:
|
|
214
|
+
continue
|
|
215
|
+
for _, _, directory in sorted(completed, reverse=True)[RETAIN_RUNS:]:
|
|
216
|
+
shutil.rmtree(directory)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _save(store: _Store | None, record: dict) -> bool:
|
|
220
|
+
"""Attempt publication without affecting command execution."""
|
|
221
|
+
if store is None:
|
|
222
|
+
return False
|
|
223
|
+
try:
|
|
224
|
+
store.save(record)
|
|
225
|
+
return True
|
|
226
|
+
except OSError as error:
|
|
227
|
+
_problem(record, f"Cannot save run record: {error}")
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class _Stream:
|
|
233
|
+
"""Track one child's byte stream, bounded forwarding buffer and capture."""
|
|
234
|
+
|
|
235
|
+
name: str
|
|
236
|
+
destination: int | None
|
|
237
|
+
capture: BinaryIO | None = None
|
|
238
|
+
source: BinaryIO | None = None
|
|
239
|
+
pending: bytearray = field(default_factory=bytearray)
|
|
240
|
+
seen: int = 0
|
|
241
|
+
saved: int = 0
|
|
242
|
+
forwarded: int = 0
|
|
243
|
+
eof: bool = False
|
|
244
|
+
capture_failed: bool = False
|
|
245
|
+
forwarding_failed: bool = False
|
|
246
|
+
|
|
247
|
+
def receive(self, data: bytes, record: dict) -> None:
|
|
248
|
+
"""Save a bounded prefix, then queue bytes for their original destination."""
|
|
249
|
+
self.seen += len(data)
|
|
250
|
+
if self.capture is not None and self.saved < CAPTURE_LIMIT:
|
|
251
|
+
try:
|
|
252
|
+
self.saved += _write_all(
|
|
253
|
+
self.capture, data[: CAPTURE_LIMIT - self.saved]
|
|
254
|
+
)
|
|
255
|
+
except OSError as error:
|
|
256
|
+
self.capture_failed = True
|
|
257
|
+
_problem(record, f"Cannot capture {self.name}: {error}")
|
|
258
|
+
self.close_capture(record)
|
|
259
|
+
if self.destination is not None:
|
|
260
|
+
self.pending.extend(data)
|
|
261
|
+
|
|
262
|
+
def close_capture(self, record: dict) -> None:
|
|
263
|
+
"""Flush evidence and report delayed storage failures."""
|
|
264
|
+
if self.capture is None:
|
|
265
|
+
return
|
|
266
|
+
try:
|
|
267
|
+
self.saved = os.fstat(self.capture.fileno()).st_size
|
|
268
|
+
os.fsync(self.capture.fileno())
|
|
269
|
+
except OSError as error:
|
|
270
|
+
self.capture_failed = True
|
|
271
|
+
_problem(record, f"Cannot finalise {self.name}: {error}")
|
|
272
|
+
finally:
|
|
273
|
+
try:
|
|
274
|
+
self.capture.close()
|
|
275
|
+
except OSError as error:
|
|
276
|
+
self.capture_failed = True
|
|
277
|
+
_problem(record, f"Cannot close {self.name}: {error}")
|
|
278
|
+
self.capture = None
|
|
279
|
+
|
|
280
|
+
def summary(self) -> dict:
|
|
281
|
+
"""Describe captured and forwarded byte counts without decoding output."""
|
|
282
|
+
return {
|
|
283
|
+
"bytes_seen": self.seen,
|
|
284
|
+
"bytes_saved": self.saved,
|
|
285
|
+
"bytes_forwarded": self.forwarded,
|
|
286
|
+
"truncated": self.seen > CAPTURE_LIMIT,
|
|
287
|
+
"eof": self.eof,
|
|
288
|
+
"capture_complete": self.eof
|
|
289
|
+
and not self.capture_failed
|
|
290
|
+
and self.seen <= CAPTURE_LIMIT,
|
|
291
|
+
"forwarding_complete": self.eof
|
|
292
|
+
and not self.forwarding_failed
|
|
293
|
+
and not self.pending,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _capture(child: subprocess.Popen, streams: list[_Stream], record: dict) -> int:
|
|
298
|
+
"""Drain both pipes fairly with bounded buffers and a post-exit deadline.
|
|
299
|
+
|
|
300
|
+
Non-blocking forwarding prevents one stalled destination from blocking reads
|
|
301
|
+
of the other stream. Original descriptor flags are restored before returning.
|
|
302
|
+
Regular-file I/O remains subject to the underlying filesystem's latency.
|
|
303
|
+
"""
|
|
304
|
+
original_flags = {}
|
|
305
|
+
for stream in streams:
|
|
306
|
+
try:
|
|
307
|
+
original_flags[stream.destination] = fcntl.fcntl(
|
|
308
|
+
stream.destination, fcntl.F_GETFL
|
|
309
|
+
)
|
|
310
|
+
except OSError as error:
|
|
311
|
+
_problem(record, f"Cannot forward {stream.name}: {error}")
|
|
312
|
+
stream.destination = None
|
|
313
|
+
stream.forwarding_failed = True
|
|
314
|
+
try:
|
|
315
|
+
for stream in streams:
|
|
316
|
+
if stream.destination is not None:
|
|
317
|
+
try:
|
|
318
|
+
fcntl.fcntl(
|
|
319
|
+
stream.destination,
|
|
320
|
+
fcntl.F_SETFL,
|
|
321
|
+
original_flags[stream.destination] | os.O_NONBLOCK,
|
|
322
|
+
)
|
|
323
|
+
except OSError as error:
|
|
324
|
+
_problem(
|
|
325
|
+
record, f"Cannot configure {stream.name} forwarding: {error}"
|
|
326
|
+
)
|
|
327
|
+
stream.destination = None
|
|
328
|
+
stream.forwarding_failed = True
|
|
329
|
+
deadline = None
|
|
330
|
+
while True:
|
|
331
|
+
returncode = child.poll()
|
|
332
|
+
if returncode is not None:
|
|
333
|
+
if deadline is None:
|
|
334
|
+
deadline = time.monotonic() + DRAIN_SECONDS
|
|
335
|
+
if all(stream.eof and not stream.pending for stream in streams):
|
|
336
|
+
return returncode
|
|
337
|
+
if time.monotonic() >= deadline:
|
|
338
|
+
for stream in streams:
|
|
339
|
+
if not stream.eof or stream.pending:
|
|
340
|
+
_problem(
|
|
341
|
+
record,
|
|
342
|
+
f"{stream.name} drain deadline exceeded; output is incomplete",
|
|
343
|
+
)
|
|
344
|
+
return returncode
|
|
345
|
+
readable = [
|
|
346
|
+
stream.source
|
|
347
|
+
for stream in streams
|
|
348
|
+
if not stream.eof and not stream.pending
|
|
349
|
+
]
|
|
350
|
+
writable = [
|
|
351
|
+
stream.destination
|
|
352
|
+
for stream in streams
|
|
353
|
+
if stream.pending and stream.destination is not None
|
|
354
|
+
]
|
|
355
|
+
ready_read, ready_write, _ = select.select(readable, writable, [], 0.05)
|
|
356
|
+
for stream in streams:
|
|
357
|
+
if stream.source in ready_read:
|
|
358
|
+
data = os.read(stream.source.fileno(), _CHUNK_SIZE)
|
|
359
|
+
if data:
|
|
360
|
+
stream.receive(data, record)
|
|
361
|
+
else:
|
|
362
|
+
stream.eof = True
|
|
363
|
+
if stream.destination in ready_write and stream.pending:
|
|
364
|
+
try:
|
|
365
|
+
count = os.write(stream.destination, stream.pending)
|
|
366
|
+
if count == 0:
|
|
367
|
+
raise OSError("Forwarding write made no progress")
|
|
368
|
+
stream.forwarded += count
|
|
369
|
+
del stream.pending[:count]
|
|
370
|
+
except BlockingIOError:
|
|
371
|
+
pass
|
|
372
|
+
except OSError as error:
|
|
373
|
+
stream.forwarding_failed = True
|
|
374
|
+
stream.destination = None
|
|
375
|
+
stream.pending.clear()
|
|
376
|
+
_problem(record, f"Cannot forward {stream.name}: {error}")
|
|
377
|
+
finally:
|
|
378
|
+
for descriptor, flags in original_flags.items():
|
|
379
|
+
try:
|
|
380
|
+
fcntl.fcntl(descriptor, fcntl.F_SETFL, flags)
|
|
381
|
+
except OSError as error:
|
|
382
|
+
_problem(
|
|
383
|
+
record, f"Cannot restore output descriptor {descriptor}: {error}"
|
|
384
|
+
)
|
|
385
|
+
for stream in streams:
|
|
386
|
+
stream.source.close()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def run_job(job_id: str, command: str) -> int:
|
|
390
|
+
"""Execute one foreground shell command and preserve its termination result.
|
|
391
|
+
|
|
392
|
+
The caller supplies an already decoded command argument. This function does
|
|
393
|
+
not read the crontab or repeat cron's percent preprocessing. Call it from the
|
|
394
|
+
main thread, as it temporarily owns the termination signal handlers.
|
|
395
|
+
"""
|
|
396
|
+
if (
|
|
397
|
+
JOB_IDENTIFIER.fullmatch(job_id) is None
|
|
398
|
+
or not command.strip()
|
|
399
|
+
or "\x00" in command
|
|
400
|
+
):
|
|
401
|
+
raise ValueError(
|
|
402
|
+
"A valid job identifier and non-empty, NUL-free command are required"
|
|
403
|
+
)
|
|
404
|
+
started = time.monotonic()
|
|
405
|
+
run_id = uuid.uuid4().hex
|
|
406
|
+
shell = os.environ.get("SHELL", "/bin/sh")
|
|
407
|
+
record = {
|
|
408
|
+
"schema_version": 1,
|
|
409
|
+
"job_id": job_id,
|
|
410
|
+
"run_id": run_id,
|
|
411
|
+
"uid": os.getuid(),
|
|
412
|
+
"command": command,
|
|
413
|
+
"shell": shell,
|
|
414
|
+
"wrapper_pid": os.getpid(),
|
|
415
|
+
"child_pid": None,
|
|
416
|
+
"started_at": _now(),
|
|
417
|
+
"finished_at": None,
|
|
418
|
+
"duration_seconds": None,
|
|
419
|
+
"status": "started",
|
|
420
|
+
"returncode": None,
|
|
421
|
+
"exit_code": None,
|
|
422
|
+
"signal": None,
|
|
423
|
+
"errors": [],
|
|
424
|
+
"streams": {},
|
|
425
|
+
}
|
|
426
|
+
store = None
|
|
427
|
+
try:
|
|
428
|
+
store = _Store(job_id, run_id)
|
|
429
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
430
|
+
_problem(record, f"Cannot initialise evidence storage: {error}")
|
|
431
|
+
_save(store, record)
|
|
432
|
+
streams = [_Stream("stdout", 1), _Stream("stderr", 2)]
|
|
433
|
+
for stream in streams:
|
|
434
|
+
if store is not None:
|
|
435
|
+
try:
|
|
436
|
+
stream.capture = _private_file(store.directory / f"{stream.name}.log")
|
|
437
|
+
except OSError as error:
|
|
438
|
+
_problem(record, f"Cannot open {stream.name} capture: {error}")
|
|
439
|
+
stream.capture_failed = stream.capture is None
|
|
440
|
+
|
|
441
|
+
child = None
|
|
442
|
+
pending_signals = []
|
|
443
|
+
|
|
444
|
+
def forward(signum: int, frame: object) -> None:
|
|
445
|
+
"""Forward cancellation to the process group, including during startup."""
|
|
446
|
+
if child is None:
|
|
447
|
+
pending_signals.append(signum)
|
|
448
|
+
return
|
|
449
|
+
try:
|
|
450
|
+
os.killpg(child.pid, signum)
|
|
451
|
+
except ProcessLookupError:
|
|
452
|
+
pass
|
|
453
|
+
except OSError as error:
|
|
454
|
+
_problem(record, f"Cannot forward signal {signum}: {error}")
|
|
455
|
+
|
|
456
|
+
previous_handlers = {}
|
|
457
|
+
try:
|
|
458
|
+
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
459
|
+
previous_handlers[signum] = signal.signal(signum, forward)
|
|
460
|
+
environment = os.environ.copy()
|
|
461
|
+
environment.update(ZC_JOB_ID=job_id, ZC_RUN_ID=run_id)
|
|
462
|
+
try:
|
|
463
|
+
child = subprocess.Popen(
|
|
464
|
+
[shell, "-c", command],
|
|
465
|
+
stdin=None,
|
|
466
|
+
stdout=subprocess.PIPE,
|
|
467
|
+
stderr=subprocess.PIPE,
|
|
468
|
+
env=environment,
|
|
469
|
+
start_new_session=True,
|
|
470
|
+
bufsize=0,
|
|
471
|
+
)
|
|
472
|
+
except OSError as error:
|
|
473
|
+
exit_code = 127 if isinstance(error, FileNotFoundError) else 126
|
|
474
|
+
record["status"] = "spawn_failed"
|
|
475
|
+
record["spawn_error"] = str(error)
|
|
476
|
+
_diagnostic(f"Cannot execute shell {shell!r}: {error}")
|
|
477
|
+
else:
|
|
478
|
+
record["child_pid"] = child.pid
|
|
479
|
+
for signum in pending_signals:
|
|
480
|
+
forward(signum, None)
|
|
481
|
+
_save(store, record)
|
|
482
|
+
streams[0].source = child.stdout
|
|
483
|
+
streams[1].source = child.stderr
|
|
484
|
+
returncode = _capture(child, streams, record)
|
|
485
|
+
record["returncode"] = returncode
|
|
486
|
+
record["signal"] = -returncode if returncode < 0 else None
|
|
487
|
+
record["status"] = "finished"
|
|
488
|
+
exit_code = 128 - returncode if returncode < 0 else returncode
|
|
489
|
+
finally:
|
|
490
|
+
for signum, handler in previous_handlers.items():
|
|
491
|
+
signal.signal(signum, handler)
|
|
492
|
+
for stream in streams:
|
|
493
|
+
stream.close_capture(record)
|
|
494
|
+
record["exit_code"] = exit_code
|
|
495
|
+
record["finished_at"] = _now()
|
|
496
|
+
record["duration_seconds"] = time.monotonic() - started
|
|
497
|
+
record["streams"] = {stream.name: stream.summary() for stream in streams}
|
|
498
|
+
if _save(store, record):
|
|
499
|
+
try:
|
|
500
|
+
store.prune()
|
|
501
|
+
except OSError as error:
|
|
502
|
+
_problem(record, f"Cannot prune completed runs: {error}")
|
|
503
|
+
_save(store, record)
|
|
504
|
+
return exit_code
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _check_executable(executable: str, job: CronJob) -> None:
|
|
508
|
+
"""Check a literal executable using only an explicit, absolute cron PATH."""
|
|
509
|
+
if not executable:
|
|
510
|
+
raise ValueError("SHELL must not be empty")
|
|
511
|
+
if os.path.isabs(executable):
|
|
512
|
+
found = shutil.which(executable)
|
|
513
|
+
else:
|
|
514
|
+
if "/" in executable:
|
|
515
|
+
raise ValueError(
|
|
516
|
+
f"Use an absolute executable path instead of {executable!r}"
|
|
517
|
+
)
|
|
518
|
+
path = job.environment.get("PATH")
|
|
519
|
+
if path is None or any(
|
|
520
|
+
not os.path.isabs(part) for part in path.split(os.pathsep)
|
|
521
|
+
):
|
|
522
|
+
raise ValueError(
|
|
523
|
+
"Bare executable names require an explicit cron PATH containing only absolute directories"
|
|
524
|
+
)
|
|
525
|
+
found = shutil.which(executable, path=path)
|
|
526
|
+
if found is None:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
f"Executable {executable!r} is missing or not executable by this user"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def validate(filename: str | None = None) -> int:
|
|
533
|
+
"""Validate a complete user crontab and executable availability without running jobs."""
|
|
534
|
+
try:
|
|
535
|
+
if filename is None:
|
|
536
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, check=False)
|
|
537
|
+
if result.returncode:
|
|
538
|
+
raise ValueError(
|
|
539
|
+
f"crontab -l failed ({result.returncode}): {result.stderr.decode('utf-8', 'replace').strip()}"
|
|
540
|
+
)
|
|
541
|
+
text = result.stdout.decode("utf-8")
|
|
542
|
+
else:
|
|
543
|
+
text = Path(filename).read_bytes().decode("utf-8")
|
|
544
|
+
crontab = parse_crontab(text)
|
|
545
|
+
except (OSError, ValueError, ParseError) as error:
|
|
546
|
+
_diagnostic(str(error))
|
|
547
|
+
return 1
|
|
548
|
+
failed = False
|
|
549
|
+
for job in crontab.jobs:
|
|
550
|
+
for executable in (
|
|
551
|
+
job.wrapper_executable,
|
|
552
|
+
job.environment.get("SHELL", "/bin/sh"),
|
|
553
|
+
):
|
|
554
|
+
try:
|
|
555
|
+
_check_executable(executable, job)
|
|
556
|
+
except (OSError, ValueError) as error:
|
|
557
|
+
_diagnostic(
|
|
558
|
+
f"line {job.line_number}; job {job.job_id!r}, opened at line {job.declaration_line_number}: {error}"
|
|
559
|
+
)
|
|
560
|
+
failed = True
|
|
561
|
+
return 1 if failed else 0
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def main(argv: list[str] | None = None) -> int:
|
|
565
|
+
"""Dispatch the standard-library CLI and return an operating-system exit code."""
|
|
566
|
+
parser = argparse.ArgumentParser(prog="zabbix-crontroller")
|
|
567
|
+
commands = parser.add_subparsers(dest="action", required=True)
|
|
568
|
+
run = commands.add_parser(
|
|
569
|
+
"run", help="Run a quoted command and capture its evidence"
|
|
570
|
+
)
|
|
571
|
+
run.add_argument("job")
|
|
572
|
+
run.add_argument("command", nargs=argparse.REMAINDER, metavar="-- COMMAND")
|
|
573
|
+
check = commands.add_parser(
|
|
574
|
+
"validate", help="Validate a file or the current user's crontab"
|
|
575
|
+
)
|
|
576
|
+
check.add_argument("file", nargs="?")
|
|
577
|
+
raw_arguments = list(sys.argv[1:] if argv is None else argv)
|
|
578
|
+
arguments = parser.parse_args(raw_arguments)
|
|
579
|
+
if arguments.action == "validate":
|
|
580
|
+
return validate(arguments.file)
|
|
581
|
+
if (
|
|
582
|
+
len(raw_arguments) != 4
|
|
583
|
+
or raw_arguments[2] != "--"
|
|
584
|
+
or len(arguments.command) != 1
|
|
585
|
+
):
|
|
586
|
+
parser.error("run requires JOB -- followed by exactly one quoted COMMAND")
|
|
587
|
+
if JOB_IDENTIFIER.fullmatch(arguments.job) is None:
|
|
588
|
+
parser.error("JOB must match [a-z][a-z0-9_-]*")
|
|
589
|
+
command = arguments.command[0]
|
|
590
|
+
if not command.strip() or "\x00" in command:
|
|
591
|
+
parser.error("COMMAND must be non-empty and contain no NUL bytes")
|
|
592
|
+
return run_job(arguments.job, command)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
if __name__ == "__main__":
|
|
596
|
+
sys.exit(main())
|