Metadata-Version: 2.5
Name: zabbix-crontroller
Version: 0.0.2
Summary: Monitor cron jobs in Zabbix with crontab as the single source of truth.
Project-URL: Homepage, https://github.com/theriverman/zabbix-crontroller
Project-URL: Repository, https://github.com/theriverman/zabbix-crontroller
Author-email: Kristof Daja <22156894+theriverman@users.noreply.github.com>
License: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: No Input/Output (Daemon)
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Text Processing
Classifier: Topic :: Utilities
Requires-Python: >=3.14
Description-Content-Type: text/markdown

# Zabbix-cRontroller
Monitor cron jobs in Zabbix with crontab as the single source of truth

# Introduction
Keep job schedules and metadata in the user's crontab. The `zc` execution wrapper
records each run's exit status, duration, stdout and stderr in private per-user
storage. It forwards output to cron or the entry's existing redirections.

This implementation provides local run evidence. The Zabbix integration
uses Agent2 for discovery and collector health, and an asynchronous sender for
individual run results. See the [Zabbix setup guide](docs/ZABBIX.md) and
[integration architecture and logging policy](docs/RUNTIME.md#zabbix-integration).
Detailed evidence stays in files. Any application-generated system-log messages
are limited to brief lifecycle notices and actionable errors.

# Installation & Integration

Use Python 3.14 and [uv](https://docs.astral.sh/uv/getting-started/installation/)
on Linux with cron installed. Install the published
[PyPI package](https://pypi.org/project/zabbix-crontroller/) directly; no source
checkout is required. uv downloads Python 3.14 automatically if needed.

For the current user, install into `~/.local/bin` and create the short-name symlink:

```sh
UV_TOOL_BIN_DIR="$HOME/.local/bin" uv tool install --python 3.14 zabbix-crontroller
ln -s zabbix-crontroller "$HOME/.local/bin/zc"
export PATH="$HOME/.local/bin:$PATH"
```

For a system installation into `/usr/local/bin` with sudo:

```sh
sudo env UV_TOOL_DIR=/opt/zabbix-crontroller/tools \
  UV_PYTHON_INSTALL_DIR=/opt/zabbix-crontroller/python \
  UV_TOOL_BIN_DIR=/usr/local/bin "$(command -v uv)" tool install \
  --python 3.14 zabbix-crontroller
sudo ln -s zabbix-crontroller /usr/local/bin/zc
```

The executable is `zabbix-crontroller`; `zc` must be a symlink to it.
Use its absolute path in cron, or explicitly set cron's
`PATH` to include its directory. A non-root example is
`PATH=/home/batch/.local/bin:/usr/bin:/bin`; cron does not expand `$HOME` in PATH.
For system installations, the uv tool environment and interpreter must be readable
and executable by the job owners; the shared `/opt` locations avoid placing them
inside root's private home. Validate from each job owner's account.

Private run evidence defaults to `~/.local/state/zabbix-crontroller`; no
`XDG_STATE_HOME` setting is required. Optional storage overrides and the agreed
shared log permissions are described in
[runtime storage](docs/RUNTIME.md#storage-and-retention) and
[shared log defaults](docs/RUNTIME.md#shared-log-defaults). Administrator setup
provisions the shared logs, Agent2 configuration and collector/relay cron entries;
follow the [Zabbix setup guide](docs/ZABBIX.md).

After adding the job declarations described below, validate the installed crontab
as its owner, or validate a candidate file before installing it:

```sh
zc validate
zc validate /path/to/candidate.crontab
```

Validation checks the complete syntax and then wrapper and shell availability as
the invoking user. It never executes a job. Success returns `0`; invalid input,
acquisition failures and unavailable executables return `1`. Missing crontabs are
failures, not successful empty configurations.

## Upgrading to the latest version

For installations made with uv as described above, use the commands below to
upgrade to the latest stable release on PyPI compatible with Python 3.14.
Check the [release notes](https://github.com/theriverman/zabbix-crontroller/releases)
for changes, particularly before a major-version upgrade. Upgrade between job
runs, as uv may replace the tool environment during the operation.

### User-level installation

Run these commands as the user who installed the package, without sudo:

```sh
UV_TOOL_BIN_DIR="$HOME/.local/bin" uv tool upgrade \
  --python 3.14 --prerelease disallow zabbix-crontroller
uv tool list
"$HOME/.local/bin/zc" validate
```

Check the `zabbix-crontroller` entry in `uv tool list` for the installed version.
Validation checks that user's installed crontab and executable availability.

### System-wide installation

Use the same shared tool and Python directories as the original installation:

```sh
sudo env UV_TOOL_DIR=/opt/zabbix-crontroller/tools \
  UV_PYTHON_INSTALL_DIR=/opt/zabbix-crontroller/python \
  UV_TOOL_BIN_DIR=/usr/local/bin "$(command -v uv)" tool upgrade \
  --python 3.14 --prerelease disallow zabbix-crontroller
sudo env UV_TOOL_DIR=/opt/zabbix-crontroller/tools \
  "$(command -v uv)" tool list
```

Check the `zabbix-crontroller` entry for the installed version. Then, from each
cron job owner's account, run:

```sh
/usr/local/bin/zc validate
```

Running validation only as root checks root's crontab and permissions. If the
installation uses custom directories, substitute those same directories when
upgrading and listing tools.

Both procedures preserve the crontab and stored run evidence. The existing `zc`
symlink continues to point to `zabbix-crontroller`; if it is missing, recreate it
using the corresponding installation command above.

[uv respects version constraints](https://docs.astral.sh/uv/guides/tools/#upgrading-tools)
recorded at installation. If an earlier installation was pinned to a version
or installed from Git or a local checkout, replace `tool upgrade` with
`tool install --upgrade` in the appropriate upgrade command above. Keep the
unqualified package name `zabbix-crontroller` and the same environment variables
and sudo usage to install the latest compatible stable release from PyPI.

# Syntax

Keep schedules and commands in your normal user crontab. The
[Syntax Specification](docs/SYNTAX_SPECIFICATION.md) defines the frozen `1.0`
notation supported by the parser.

1. Declare `# zc:version = "1.0"` exactly once, before `# zc:start`.
2. Enclose all monitored jobs in one section, from `# zc:start` to `# zc:end`.
3. Start each job declaration with `job`: a stable, unique identifier using
   lowercase ASCII letters, digits, underscores or hyphens, beginning with a letter.
4. Add a required `name` and, if useful, an optional `description`. These can
   contain Unicode; the description may appear before or after the name.
5. Put exactly one cron entry immediately after its metadata. It completes the
   job declaration. `# zc:end` closes the entire section, not an individual job.
6. Begin the command with `zc run JOB -- 'COMMAND'`, repeating the exact `job`
   identifier. Use one literally quoted shell command. Both executable names and
   absolute paths ending in `zc` or `zabbix-crontroller` are accepted.

For example, a description is not required:

```cron
# zc:version = "1.0"
PATH=/usr/local/bin:/usr/bin:/bin
# zc:start
# zc:job = "backup-postgres"
# zc:name = "Orders database backup"
30 1 * * * zc run backup-postgres -- '/opt/company/maintenance/bin/backup-postgres --database orders'
# zc:end
```

Write one property per line using a quoted TOML string. Keep each metadata header
and its cron entry together. Blank lines, ordinary comments and environment
assignments may appear between completed jobs. Delimiters may have trailing
`#` comments, but take no value. Changing a name or command does not require
changing the job identifier.

The wrapper executes the quoted command using cron's `SHELL`, or `/bin/sh` when
unset. Put pipelines, command chains and substitutions inside single quotes so
they execute within the monitored shell. Keep existing output redirections
outside the quotes if they should receive forwarded output while capture stays
separate. Use `'"'"'` to include an apostrophe in a single-quoted command.
Cron still requires escaping literal percentages as `\%`, even inside quotes.
See [runtime behaviour and evidence](docs/RUNTIME.md) for limits and failure
handling.

Every cron entry inside the section needs its own metadata. An additional entry
without metadata is an error, including after the final job. Unannotated entries
are allowed before and after the section. Job metadata outside the section is
an error. An empty section is valid, but both delimiters remain required.

Commenting out only a job's cron entry is an error: its metadata will not attach
to a later entry. To stop discovering a job while keeping it running, remove its
metadata header **and move its cron entry outside the monitored section**.
Preserve the relevant environment settings when moving it; section boundaries
do not reset the cron environment. Removing only the header inside the section
is an error. Remove the entire declaration to remove both monitoring and execution.
Malformed configurations prevent publication of the discovery snapshot.

## Syntax example

The [example crontab](example.crontab) contains three monitored Docker maintenance
jobs. The following example shows a broader batch workload and an unmonitored entry:

```cron
# Production batch jobs — svc_batch@lon-batch-01
# Owner: Platform Operations
# Host timezone: Europe/London. Change requests must reference an approved ticket.

# zc:version = "1.0"

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=platform-operations@example.com
HOME=/var/lib/batch

# zc:start

# zc:job = "supplier-sync"
# zc:name = "Supplier stock synchronisation"
# zc:description = "Collect supplier stock feeds during business hours, Monday–Friday."
12 7-19 * * 1-5 zc run supplier-sync -- '/usr/bin/flock -n /var/lib/batch/locks/supplier-sync.lock /opt/company/integrations/bin/supplier-sync --config /etc/company/supplier-sync.toml' >> /var/log/batch/supplier-sync.log 2>&1

# zc:job = "backup-postgres"
# zc:name = "Orders database backup"
# zc:description = "Nightly database backup; credentials supplied via .pgpass."
30 1 * * * zc run backup-postgres -- '/opt/company/maintenance/bin/backup-postgres --database orders --retention-days 14' >> /var/log/batch/backup-postgres.log 2>&1

# zc:job = "reconcile-payments"
# zc:name = "Reconcile payments"
# zc:description = "Generate the previous business day's reconciliation report before Finance arrives."
15 6 * * 1-5 zc run reconcile-payments -- '/opt/company/finance/bin/reconcile-payments --period previous-business-day' >> /var/log/batch/reconcile-payments.log 2>&1

# zc:job = "staging-cleanup"
# zc:name = "Staging cleanup"
# zc:description = "Remove expired integration staging files during the Sunday maintenance window."
45 3 * * 0 zc run staging-cleanup -- '/usr/bin/find /var/lib/batch/staging -xdev -type f -mtime +30 -delete' >> /var/log/batch/staging-cleanup.log 2>&1

# zc:job = "sap-export"
# zc:name = "SAP export"
# zc:description = "Forward pending warehouse transactions to SAP; prevent overlapping runs."
*/5 * * * * zc run sap-export -- '/usr/bin/flock -n /var/lib/batch/locks/sap-export.lock /opt/company/integrations/bin/sap-export --env production' >> /var/log/batch/sap-export.log 2>&1

# zc:end

# This separate cron entry runs outside monitoring.
45 3 * * 0 /usr/bin/find /var/lib/batch/staging -xdev -type f -mtime +30 -delete >> /var/log/batch/staging-cleanup.log 2>&1
```

# Library usage

Import the parser independently of the application. The caller supplies the
crontab text; the parser does not run `crontab -l`, execute jobs, read files or
contact Zabbix.

```python
from pathlib import Path

from zabbix_crontroller.parser import parse_crontab

text = Path("example.crontab").read_text(encoding="utf-8")
crontab = parse_crontab(text)

for job in crontab.jobs:
    print(job.job_id, job.name, job.schedule, job.command)
```

`parse_crontab(text: str) -> Crontab` returns the declared `version` and a tuple of
`CronJob` records in source order. Jobs contain the identifier, name, optional
description, schedule, complete command, `wrapper_executable`, environment
snapshot and source line numbers. The wrapper field contains its decoded name
or absolute path; parsing does not check whether that executable is installed.
Records and their environment mappings are read-only. Missing or blank
descriptions become `None`. An explicitly empty monitored section returns an
empty `jobs` tuple.

The parser extracts five-field schedules and `@` nicknames. Schedule field
values, nickname availability, calendar ranges, time zones and daemon-specific
extensions are **not validated**. This is a metadata parser, not a replacement
for the daemon's crontab validator. Input is interpreted as a user crontab, with
no seconds or username column. Commands retain their internal and trailing
whitespace, quoting and cron percent sequences without evaluation. Only the
literal wrapper invocation is validated; the inner shell programme remains
opaque. Unmonitored commands retain their previous parsing behaviour.

Each environment snapshot contains only preceding explicit assignments, in file
order, with outer quotes removed. It does not add process environment variables
or cron defaults, perform substitutions, or model daemon-specific overrides.

Invalid input raises `ParseError`, with `ConfigurationError` for version errors
and `CrontabSyntaxError` for other parsing errors. Exceptions expose `message`,
`line_number`, `job_id`, `declaration_line_number` and `previous_line_number`;
inapplicable locations are `None`. Source lines are one-based. The exception text
includes the relevant locations. Parsing returns a complete result or raises an
error; it never returns partial discovery data. Non-string input raises
`TypeError`.

# Contributing

See [CONTRIBUTE.md](CONTRIBUTE.md) for development setup, tests and contribution
guidelines.

# Troubleshooting

Run `zc validate /path/to/candidate.crontab` before installing a candidate, or
`zc validate` to check the current user's installed crontab. Run validation as
the job owner. It returns `1` on failure and never executes the jobs. Syntax
errors include source locations; each distinct environment or installation
problem is reported once, without attributing it to a valid job line.

For example, jobs beginning with `zc run` need an explicit cron `PATH`. If it is
missing, validation reports one configuration problem even when several jobs
are affected:

```text
zabbix-crontroller: Missing cron PATH. Add PATH=/usr/local/bin:/usr/bin:/bin before jobs using bare executable names, or use absolute executable paths.
```

Add `PATH=/usr/local/bin:/usr/bin:/bin` before the monitored section. For a
per-user installation, use an absolute home path instead, for example
`PATH=/home/s24291/.local/bin:/usr/bin:/bin`. Alternatively, begin each job with
`/usr/local/bin/zabbix-crontroller run` or its actual absolute installation path.
An interactive shell's `PATH` is not used by validation. A declaration such as
`ZC='/usr/local/bin/zabbix-crontroller run'` only sets an environment variable;
it does not define an alias, and `$ZC` is not a supported wrapper invocation.

## Validation and installation errors

Environment assignments apply in file order. A `PATH` or `SHELL` declaration
after a job does not configure that earlier job, and section delimiters do not
reset the environment. Only bare wrapper or shell names require `PATH`.

| Error or diagnostic fragment | Typical recommendation |
| --- | --- |
| `Missing cron PATH` | Declare an absolute `PATH` before the affected jobs, as above, or use absolute wrapper and shell paths. |
| `Invalid cron PATH '...'` | Use colon-separated, non-empty absolute directories. Remove relative paths, empty components and trailing colons. Write literal paths: `$HOME`, `$PATH` and `~` are not expanded. |
| `Executable '...' is missing or not executable by this user` | Check the installation and execute permissions as the job owner. Check that `zc` is a symlink to `zabbix-crontroller` and that cron's declared `PATH` includes its directory. For an absolute executable, correct its path. |
| `SHELL must not be empty` | Set `SHELL=/bin/sh`, choose another installed shell by absolute path, or remove the assignment to use `/bin/sh`. |
| `Use an absolute executable path instead of '...'` | Replace a relative `SHELL` path such as `./bash` with its installed absolute path. |
| `crontab -l failed (...)` | Read the accompanying cron diagnostic. Check that this account has a crontab and permission to read it, or validate a candidate file instead. |
| `No such file or directory` / `Permission denied` | With a filename, check that the candidate exists and is readable. Without a filename, check that cron is installed and the `crontab` command is available to the validator. |
| UTF-8 decoding failure | Save the candidate as UTF-8. Validation does not guess another encoding. |

## Syntax errors

The following table groups related diagnostics. For exact notation and supported
quoting, see the [Syntax Specification](docs/SYNTAX_SPECIFICATION.md).

| Error or diagnostic fragment | Typical recommendation |
| --- | --- |
| Missing, misplaced or duplicate version declaration | Declare `# zc:version = "1.0"` exactly once, before `# zc:start`. |
| `Unsupported syntax version` | Use the supported version `1.0` with its documented syntax. Do not relabel a future syntax without adapting its contents. |
| Missing, unmatched or repeated section delimiter; end of file before `# zc:end` | Keep exactly one `# zc:start` and one following `# zc:end`, including for an empty section. Do not nest sections. |
| `Unknown metadata property` / malformed metadata assignment | Use only `version`, `job`, `name` and `description` assignments, one per line. Use `start` and `end` as delimiters without values. |
| `requires a single-line quoted string` / `Invalid metadata assignment` | Quote each metadata value with TOML single or double quotes. Correct invalid escapes and avoid multiline strings, arrays or other value types. |
| `Job metadata must be inside the monitored section` / `Orphaned metadata property` | Put job metadata inside the section and begin each header with `# zc:job`. |
| `Invalid job identifier` | Begin the identifier with a lowercase ASCII letter; use only lowercase ASCII letters, digits, underscores and hyphens afterwards. |
| `Duplicate job identifier` / `Duplicate metadata property` | Give each job a unique identifier and declare each property once per header. The diagnostic identifies the first occurrence. |
| `Missing required 'name'` / `The job name must not be blank` | Add a non-blank `# zc:name` before the cron entry. A description is optional. |
| Blank lines, ordinary comments or environment assignments inside a declaration | Keep the metadata header and its active cron entry together. Move separators and environment assignments before or after the complete declaration. |
| Another job, `# zc:end` or end of file before the required active cron entry | Complete the header with one active cron entry, or remove the entire unused declaration. Commenting out only its command leaves an unfinished declaration. |
| `Every cron entry inside the monitored section requires its own job and name metadata` | Add a complete metadata header to that entry, or move the unmonitored entry outside the section. |
| `Expected five schedule fields or an @nickname, followed by a command` | Supply a user-crontab schedule and command. Do not add a seconds field or a system-crontab username column. The cron daemon must validate schedule values. |
| `Invalid quoted environment value` | Match the opening and closing quotes around the complete environment value. Put comments on separate lines. |
| `Bare carriage returns and NUL bytes are invalid` / `Invalid UTF-8` | Save clean UTF-8 text with LF or CRLF line endings and no NUL bytes. |
| `Expected wrapper invocation` / `The entry must begin with zc, zabbix-crontroller or an absolute path to either` | Use `zc run JOB -- 'COMMAND'`, or an absolute path ending in `zc` or `zabbix-crontroller`. Remove outer prefixes such as `env`, `exec` or variable assignments. |
| `Wrapper job identifier ... does not match declared job ...` | Repeat the exact `# zc:job` identifier after `run`. |
| `Supply exactly one non-empty, literally quoted command argument` | Put the whole shell programme in one non-blank quoted argument after `--`. Do not supply several command arguments. |
| `Unterminated quote` / `Trailing backslash` | Balance the outer quotes and remove incomplete escapes. Cron entries must occupy one physical line. Escape literal percentages as `\%`, even inside quotes, because cron processes them before the shell. |
| Shell expansions, pipelines, command chains or background work outside the quoted command | Put shell operations inside the single-quoted command argument. Use a literal wrapper name or absolute path; `$ZC` and `~/bin/zc` are unsupported. |
| Incomplete or unsupported redirections | After the quoted command, use only complete `<`, `>`, `>>`, `<&` or `>&` redirections with literal, non-empty targets. Use only descriptors `0`, `1` and `2`; descriptor targets must be `0`, `1`, `2` or `-`. Put other shell constructs inside the command argument. |

## Execution and evidence errors

`validate` checks metadata, the wrapper and the shell. It does not inspect the
quoted programme, check its files or permissions, or confirm that it will
succeed. Inspect its forwarded output and [run evidence](docs/RUNTIME.md) when
a validated job fails at execution time.

| Error or symptom | Typical recommendation |
| --- | --- |
| `run requires JOB -- followed by exactly one quoted COMMAND`, invalid `JOB`, or empty command | Use `zc run JOB -- 'COMMAND'` with a valid identifier and one non-blank, NUL-free command argument. Invalid CLI usage returns `2`. |
| `Cannot execute shell '...'` | Check the runtime `SHELL` and its execute permissions as the job owner. A missing shell returns `127`; other shell launch failures return `126`. |
| Inner command reports `not found`, permission errors or another non-zero result | Check the programme's paths, environment, permissions and arguments under the cron user's account. The wrapper returns the foreground shell's exit status. |
| No run record after an outer log redirection fails | Create the log's parent directory and give the cron user appropriate write access. The shell opens outer redirections before starting the wrapper. |
| `ZC_STATE_DIR must be an absolute path` | Set a literal absolute state directory, or remove the override to use the default per-user location. |
| `Cannot initialise evidence storage` | Check that the state directory is a real directory owned by the job user with mode `0700`, and that its parent is writable when creation is needed. Check free disk space. Do not use a final-component symlink. |
| `Cannot save run record`, open/capture/finalise/close errors, or `Cannot prune completed runs` | Check disk space, filesystem health, ownership and permissions in the state directory. Keep evidence files private (`0600`). The job may still have run successfully despite a monitoring failure. |
| `Cannot configure`, `Cannot forward` or `Cannot restore` output; incomplete forwarded output | Check the output destination, file descriptors and downstream readers for closure or blocked writes. Compare the capture files with the forwarded log. |
| `Cannot forward signal` | Check process permissions and the accompanying operating-system error. Inspect the child process and run record before deciding whether another run is needed. |
| Truncated or incomplete capture; a record remains `started` | Capture retains the first 10 MiB per stream. Detached descendants, blocked output, abrupt termination or storage failures can leave incomplete evidence. Inspect the record and logs; `started` alone does not prove a job is still running. |
