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.
@@ -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())