Observable jobs: exit contracts, logs, metrics and audit trails

JSON logs, metrics that reveal a job that never ran, idempotency and audit lines.

Advanced55 min · lesson 12 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (21 files, 7 KB): scr-observability.tar.gz. Unpack it with tar -xzf scr-observability.tar.gz, which creates scr-observability/. SHA-256: 22d8dfb4d17f26bd14946a0011f615180ed589e026afa2f0046e3a70d9192a92

A job that runs while you sleep is judged by what it leaves behind. This lesson gives one job, sweep, the traces an on-call engineer needs: an exit status from a contract that a scheduler acts on, JSON logs stamped in UTC, a metric that shows whether the job still runs and whether it succeeds, re-runs that are safe even after the job was killed halfway, and a hash-chained audit line for every change. It is standard library only and behaves the same on Ubuntu's Python 3.14.4 and upstream 3.14.7 apart from timestamps.

This lesson owns the exit contract and structured logging for the whole course. bash-ops "Exit status and tests" fixed 0, 1, 2 and 128+n; py-sec "Logging, retries and failure design" introduced logging to stderr and temporary versus permanent errors. Unpack the lesson files in your home directory and work in ~/scr-observability. The prompts show the lab's deploy account; commands that need your account name use $USER. A few steps use sudo (a directory under /var/lib, a second account, two systemd units), and the lesson undoes each of them.

One exit contract for every job

When a job ends, cron, a systemd timer, a CI step and a wrapper script all branch on its exit status before anyone reads a log line. A status only helps if it tells the caller what to do next, so the contract sorts outcomes by the action they need:

exit_codes.py
"""The exit contract for this course's jobs: what each status means, and what the caller does."""
from enum import IntEnum
class Exit(IntEnum):
OK = 0 # everything converged (or already was): nothing to do
FAILED = 1 # a human must look: a permanent failure, unusable input, or findings from a checker
USAGE = 2 # the command line itself is wrong: unknown flag, missing or malformed argument
SOFTWARE = 70 # crashed on an error nobody classified: a bug; traceback in the log (EX_SOFTWARE)
TEMPFAIL = 75 # only transient failures: a later run can finish the work (EX_TEMPFAIL)
CONFIG = 78 # the configuration is unusable; nothing was attempted (EX_CONFIG)
# 126, 127 and 128+n belong to the shell and the kernel: 137 is 128+9, killed by SIGKILL.
class PermanentError(Exception):
"""Retrying cannot help (refused by policy, bad data): the run ends with Exit.FAILED."""
class TransientError(Exception):
"""May work later (timeout, outage, another run busy): the run ends with Exit.TEMPFAIL."""

1 means a human has to look: a target refused for a reason a retry cannot change, or findings from a checker. 75 (EX_TEMPFAIL from sysexits.h) means every failure was transient, so a later run can finish the work. 78 (EX_CONFIG) means nothing was attempted because the configuration is unusable. 70 (EX_SOFTWARE) is a crash: an exception no rule covers, which a retry would only repeat. Everything from 126 up belongs to the shell and the kernel; a job killed by the OOM killer ends with 137 (128+9) whatever your code intended. When one run meets several kinds, the most demanding wins: 70, then 1, then 75.

Not every tool needs the whole table; simpler tools use a subset. 0 is success in every tool of this track. 2 means the command could not run as asked: in every tool that covers a command line argparse rejects (an unknown flag, a missing argument, x where a number belongs), and py-sec's tools also use it, the way grep does, for input or setup the operator must fix (verify_sums.py when its SHA256SUMS list is unusable, the IOC sweep when its config or token is). 1 is the tool's own: a finding, or "a human must look". Add 70, 75 and 78 when something acts on them, such as a scheduler that retries or a wrapper that pages. The jobs of this course, blsync ("Designing a CLI tool"), ticketer ("Failure design") and sweep here, use the full contract and split py-sec's broad 2: an unusable configuration file is 78, and an input file that the command line names correctly but that cannot be read or parsed is bad data, not bad usage. It gets 1 in blsync and ticketer, because a human has to fix the file before a rerun can help. certcheck in the next lesson is a checker whose 1 means "a certificate is expiring", so it documents 3 ("could not check") for that case.

sweep brings each host of a fleet to a wanted key generation. main() turns what happened into one status:

sweep.py
"""sweep: bring every target to the wanted key generation and leave four traces behind: a JSON
log, a metric, an audit trail and an exit status from the contract in exit_codes.py."""
import argparse
import sys
from pathlib import Path
import metrics
from config import ConfigError, load_config
from converge import Report, converge
from exit_codes import Exit, TransientError
from jsonlog import get_logger
from lock import single_instance
log = get_logger("sweep")
def outcome(report: Report) -> Exit:
"""A failure that needs a human outranks one that may clear up by itself."""
if report.permanent:
return Exit.FAILED
if report.transient:
return Exit.TEMPFAIL
return Exit.OK
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="sweep")
parser.add_argument("--config", type=Path, default=Path("sweep.toml"))
parser.add_argument("--dry-run", action="store_true", help="report what would change, change nothing")
args = parser.parse_args(argv) # a bad flag exits 2 here, before anything else runs
try:
cfg = load_config(args.config)
except ConfigError as err:
log.error("config error", extra={"fields": {"error": str(err)}})
return Exit.CONFIG
report = Report()
try:
with single_instance(Path(cfg["lock"])):
converge(cfg, args.dry_run, report)
status = outcome(report)
except TransientError as err: # the lock is held: another run is doing the work
log.warning("not started", extra={"fields": {"error": str(err)}})
return Exit.TEMPFAIL
except Exception: # no rule for this error: stop, and say it is a bug, not a retry
log.exception("crashed")
status = Exit.SOFTWARE
if not args.dry_run:
status = write_metric(cfg, report, status)
log.info("run complete", extra={"fields": {"changed": report.changed, "permanent": report.permanent,
"transient": report.transient, "exit": int(status)}})
return status
def write_metric(cfg: dict, report: Report, status: Exit) -> Exit:
"""The metric is how monitoring sees this job. If it cannot be written, the run reports
a software failure (70) in one log line, instead of a traceback and Python's exit 1."""
failed = len(report.permanent) + len(report.transient)
try:
metrics.record_run(Path(cfg["metrics"]), status, report.changed, failed)
except Exception as err:
detail = getattr(err, "strerror", None) or str(err)
log.error("metric not written", extra={"fields": {
"path": cfg["metrics"], "error": f"{type(err).__name__}: {detail}"}})
return Exit.SOFTWARE
return status
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

Expected failures arrive as PermanentError or TransientError, the split "Failure design" introduced, and outcome() ranks them. Anything else lands in except Exception: log.exception records the traceback and the run ends with 70. Writing the metric is guarded the same way in write_metric: monitoring depends on it, so a metric that cannot be written is a failed run (70) with one log line, not a traceback that Python would end with exit 1. A held lock is transient (75). config.py, in the lesson files, loads and type-checks the TOML once and raises ConfigError (78), the pattern from "Designing a CLI tool". The fleet is a directory of JSON files standing in for real hosts, and the metric goes to a directory under /var/lib (the metrics section explains why):

sweep.toml
# What sweep changes, and where it leaves its traces. Reviewed and version-controlled.
targets = ["web01", "web02", "db01"]
generation = 2
fleet = "fleet"
metrics = "/var/lib/scr-observability/textfile/sweep.prom"
audit = "audit/sweep.jsonl"
lock = "sweep.lock"
make-fleet.sh
#!/usr/bin/env bash
# A stand-in fleet for sweep: one JSON file per host, holding the key generation deployed there.
# On real hosts this state is on the host itself; sweep reads it before it changes anything.
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p fleet audit metrics
for host in web01 web02 db01; do
printf '{"generation": 1}\n' >"fleet/$host.json"
done
printf '{"generation": 1, "frozen": true}\n' >fleet/pay01.json # change freeze: a human decides
printf '{"generation": 1, "reachable": false}\n' >fleet/dr01.json # outage: may answer later
ls fleet
deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ ./make-fleet.sh sudo mkdir -p /var/lib/scr-observability/textfile sudo chown "$USER:" /var/lib/scr-observability/textfile stat -c "%a %U %n" /var/lib/scr-observability /var/lib/scr-observability/textfile
db01.json dr01.json pay01.json web01.json web02.json 755 root /var/lib/scr-observability 755 deploy /var/lib/scr-observability/textfile
$ python3 sweep.py --dry-run; echo "exit status $?"
{"ts": "2026-09-28T20:12:48.417858+00:00", "level": "info", "logger": "sweep", "msg": "would change", "target": "web01", "from": 1, "to": 2} {"ts": "2026-09-28T20:12:48.417971+00:00", "level": "info", "logger": "sweep", "msg": "would change", "target": "web02", "from": 1, "to": 2} {"ts": "2026-09-28T20:12:48.418029+00:00", "level": "info", "logger": "sweep", "msg": "would change", "target": "db01", "from": 1, "to": 2} {"ts": "2026-09-28T20:12:48.418064+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 0, "permanent": [], "transient": [], "exit": 0} exit status 0
$ python3 sweep.py; echo "exit status $?"
{"ts": "2026-09-28T20:12:48.474947+00:00", "level": "info", "logger": "sweep", "msg": "changed", "target": "web01", "result": "changed", "from": 1, "to": 2} {"ts": "2026-09-28T20:12:48.476933+00:00", "level": "info", "logger": "sweep", "msg": "changed", "target": "web02", "result": "changed", "from": 1, "to": 2} {"ts": "2026-09-28T20:12:48.478208+00:00", "level": "info", "logger": "sweep", "msg": "changed", "target": "db01", "result": "changed", "from": 1, "to": 2} {"ts": "2026-09-28T20:12:48.479078+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 3, "permanent": [], "transient": [], "exit": 0} exit status 0

The dry run read every host and changed nothing. The real run moved three hosts from generation 1 to 2 and exited 0. The lesson files hold five test configs that differ from sweep.toml in their targets (the grep shows them) and keep their metric out of the textfile directory:

deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ grep ^targets mixed.toml python3 sweep.py --config mixed.toml; echo "exit status $?"
targets = ["web01", "pay01", "dr01"] {"ts": "2026-09-28T20:12:48.533425+00:00", "level": "info", "logger": "sweep", "msg": "unchanged", "target": "web01", "result": "unchanged"} {"ts": "2026-09-28T20:12:48.533519+00:00", "level": "error", "logger": "sweep", "msg": "failed", "target": "pay01", "result": "permanent", "error": "host is under a change freeze"} {"ts": "2026-09-28T20:12:48.533577+00:00", "level": "warning", "logger": "sweep", "msg": "failed", "target": "dr01", "result": "transient", "error": "host did not answer"} {"ts": "2026-09-28T20:12:48.534615+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 0, "permanent": ["pay01"], "transient": ["dr01"], "exit": 1} exit status 1
$ grep ^targets down.toml python3 sweep.py --config down.toml 2>&1 | tail -n 1
targets = ["web01", "dr01"] {"ts": "2026-09-28T20:12:48.589488+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 0, "permanent": [], "transient": ["dr01"], "exit": 75}
$ printf "{\"generation\": " > fleet/legacy01.json python3 sweep.py --config crash.toml 2>crash.log; echo "exit status $?" jq -r ".exc // empty" crash.log | tail -n 1
exit status 70 json.decoder.JSONDecodeError: Expecting value: line 1 column 16 (char 15)
$ mkdir -m 0555 readonly grep ^metrics nometric.toml python3 sweep.py --config nometric.toml 2>&1 | tail -n 2; echo "exit status ${PIPESTATUS[0]}" rmdir readonly
metrics = "readonly/sweep.prom" {"ts": "2026-09-28T20:12:48.709329+00:00", "level": "error", "logger": "sweep", "msg": "metric not written", "path": "readonly/sweep.prom", "error": "PermissionError: Permission denied"} {"ts": "2026-09-28T20:12:48.709370+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 0, "permanent": [], "transient": [], "exit": 70} exit status 70
$ python3 sweep.py --config nokeys.toml; echo "exit status $?"
{"ts": "2026-09-28T20:12:48.764116+00:00", "level": "error", "logger": "sweep", "msg": "config error", "error": "nokeys.toml: 'fleet' is missing or not a str"} exit status 78
$ flock sweep.lock python3 sweep.py; echo "exit status $?"
{"ts": "2026-09-28T20:12:48.818640+00:00", "level": "warning", "logger": "sweep", "msg": "not started", "error": "another run holds sweep.lock"} exit status 75

In the mixed run web01 was already done, pay01 refused (a change freeze, permanent) and dr01 did not answer (transient): exit 1, because the freeze needs a human however often the job retries. With only the unreachable host the exit is 75. A truncated host file raised JSONDecodeError, which no rule covers, so the run stopped with 70 and the last line of the logged traceback names the cause. With the metric pointed at a directory the account cannot write (readonly/, mode 0555), the run checked web01, logged one metric not written line naming the PermissionError, and exited 70: a job whose metric is missing leaves monitoring blind, so the run counts as failed. A config without its settings exited 78 before any host was read. And while flock held sweep.lock, a second run did nothing and exited 75.

What the scheduler does with each code

A systemd timer starts a service, and the service's settings decide what an exit status triggers. Restart=on-failure restarts a service that exited non-zero or was killed by a signal; RestartPreventExitStatus= lists the codes that must not restart; StartLimitBurst= and StartLimitIntervalSec= cap the attempts. Put 1 2 70 78 in that list and only 75 (and a kill, such as the OOM killer's) is retried. Here are the same settings on two transient units, one per outcome; in a unit file they go under [Service], with the two start-limit settings under [Unit]:

deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ sudo systemd-run --no-block -q --unit=scr-observability-retry --uid="$USER" --same-dir \ -p Type=oneshot -p Restart=on-failure -p RestartSec=2 -p RestartPreventExitStatus="1 2 70 78" \ -p StartLimitBurst=3 -p StartLimitIntervalSec=5min python3 sweep.py --config down.toml sleep 1; systemctl show -p ActiveState -p SubState scr-observability-retry timeout 60 bash -c "until [ \"\$(systemctl show -P ActiveState scr-observability-retry)\" = failed ]; do sleep 0.5; done" systemctl show -p ActiveState -p Result -p NRestarts -p ExecMainStatus scr-observability-retry journalctl -q -u scr-observability-retry -o cat --no-pager | grep -E "restart counter|too quickly" | tail -n 2
ActiveState=activating SubState=auto-restart ActiveState=failed Result=exit-code NRestarts=3 ExecMainStatus=75 scr-observability-retry.service: Scheduled restart job, restart counter is at 3. scr-observability-retry.service: Start request repeated too quickly.
$ sudo systemd-run --no-block -q --unit=scr-observability-human --uid="$USER" --same-dir \ -p Type=oneshot -p Restart=on-failure -p RestartSec=2 -p RestartPreventExitStatus="1 2 70 78" \ -p StartLimitBurst=3 -p StartLimitIntervalSec=5min python3 sweep.py --config mixed.toml timeout 60 bash -c "until [ \"\$(systemctl show -P ActiveState scr-observability-human)\" = failed ]; do sleep 0.5; done" systemctl show -p ActiveState -p Result -p NRestarts -p ExecMainStatus scr-observability-human
ActiveState=failed Result=exit-code NRestarts=0 ExecMainStatus=1
$ journalctl -q -u scr-observability-human -o cat --no-pager | tail -n 4
{"ts": "2026-09-28T20:12:55.689184+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 0, "permanent": ["pay01"], "transient": ["dr01"], "exit": 1} scr-observability-human.service: Main process exited, code=exited, status=1/FAILURE scr-observability-human.service: Failed with result 'exit-code'. Failed to start scr-observability-human.service - [systemd-run] /usr/bin/python3 sweep.py --config mixed.toml.
$ sudo systemctl reset-failed scr-observability-retry scr-observability-human systemctl show -P LoadState scr-observability-retry scr-observability-human
not-found not-found

The 75 unit was waiting to restart one second after it started (activating, auto-restart). systemd restarted it three times (NRestarts=3), then the start limit stopped the retries and the unit ended failed, still with the job's own status, 75. The exit-1 unit was never restarted: NRestarts=0, Result=exit-code, and the journal shows the JSON log lines next to systemd's own status=1/FAILURE. OnFailure= in [Unit] starts another unit, such as a pager, when a unit enters the failed state: at once for 1, 70 and 78, and for 75 only when the retries are spent. reset-failed removed both units.

cron has no retries and no failed state. It mails whatever a job prints, when mail is set up, so a wrapper maps the contract onto "stay quiet" and "tell someone":

sweep-cron.sh
#!/usr/bin/env bash
# Run sweep from cron. cron retries nothing and mails whatever a job prints, so this wrapper
# turns the exit contract into "stay quiet" or "tell a human".
cd "$(dirname "$0")" || exit 78
python3 sweep.py --config "${1:-sweep.toml}" 2>>sweep.log
status=$?
case $status in
0) ;;
75) printf 'sweep: transient failure (75); the next run retries\n' >>sweep.log ;;
*) printf 'sweep: exit %s needs a human; the log is %s/sweep.log\n' "$status" "$PWD" ;;
esac
exit "$status"
deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ ./sweep-cron.sh down.toml; echo "exit status $?" tail -n 1 sweep.log
exit status 75 sweep: transient failure (75); the next run retries
$ ./sweep-cron.sh mixed.toml; echo "exit status $?"
sweep: exit 1 needs a human; the log is /home/deploy/scr-observability/sweep.log exit status 1

The transient run printed nothing and wrote one line to sweep.log: the next hourly run is the retry. The permanent failure printed one line, which cron would mail. A transient failure that never clears must still reach a person, and that is what the metric below is for.

Logs a pipeline can query, stamped in UTC

A line such as rotated web01 forces every reader to grep and guess. One JSON object per event lets a log store filter on exact fields. The logs go to stderr so stdout stays free for the tool's real output:

jsonlog.py
"""JSON logs on stderr with UTC-aware timestamps, so stdout stays free for the tool's real output."""
import json
import logging
import sys
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
# datetime.now(timezone.utc) is the supported UTC clock. datetime.utcnow() is deprecated
# since 3.12: it returned a naive datetime that only pretended to be UTC.
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"msg": record.getMessage(),
}
for key, value in (getattr(record, "fields", None) or {}).items():
# A field never replaces ts, level, logger or msg: a value taken from input could
# otherwise forge them inside a well-formed line. A clashing key gets a prefix.
payload[f"field_{key}" if key in payload else key] = value
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
# json.dumps escapes control characters, so a newline or an ESC in a value becomes
# \n or \u001b and cannot start a second log line.
return json.dumps(payload, ensure_ascii=True)
def get_logger(name: str) -> logging.Logger:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(JsonFormatter())
log = logging.getLogger(name)
log.handlers = [handler]
log.setLevel(logging.INFO)
log.propagate = False
return log

datetime.utcnow() has been deprecated since Python 3.12 because it returned a naive datetime that only pretended to be UTC; fromtimestamp(record.created, timezone.utc) gives an aware one whose isoformat() carries +00:00. This is the formatter from logsafe.py in the previous lesson, "Handling hostile input at scale", where json.dumps escaping stopped a newline in a value from forging a second line. One addition: a field can no longer replace ts, level, logger or msg, so a record parsed from input cannot forge those inside a valid line; a clashing key gets a field_ prefix.

A metric that shows a job stopped or keeps failing

A log proves that a run happened. It cannot show the run that never started: a disabled timer writes nothing to search for. A metric whose age you watch can. On a host the usual path is the node_exporter textfile collector, which serves every .prom file in one directory. metrics.py writes that file:

metrics.py
"""Write a node_exporter textfile metric atomically, readable by the scraper's own user."""
import os
import tempfile
import time
from pathlib import Path
def write_textfile(path: Path, body: str) -> None:
"""A scrape sees the old file or the whole new one, never half of one, and the node_exporter
user (not this job's user) can read it."""
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix="." + path.name + ".")
try:
with os.fdopen(fd, "w") as f:
f.write(body)
f.flush()
os.fsync(f.fileno())
# mkstemp creates the file 0600 for its own safety; node_exporter runs as its own user
# and could not read that. Widen to 0644 before the rename makes the name visible.
os.chmod(tmp, 0o644)
os.replace(tmp, path) # atomic within one filesystem
except BaseException:
os.unlink(tmp)
raise
SERIES = { # name: help text. Names end in _seconds where the value is a Unix time.
"sweep_last_run_seconds": "Unix time the last run finished, whatever its outcome.",
"sweep_last_success_seconds": "Unix time of the last fully successful run.",
"sweep_last_exit_code": "Exit status of the last run.",
"sweep_targets_changed": "Targets changed by the last run.",
"sweep_targets_failed": "Targets that failed in the last run.",
}
def record_run(path: Path, status: int, changed: int, failed: int) -> None:
"""Every run stamps last_run; only a clean run moves last_success. Together they tell
"stopped running" (last_run is old) from "runs and keeps failing" (only last_success is old)."""
now = int(time.time())
values = [now, now if status == 0 else read_last_success(path), status, changed, failed]
write_textfile(path, "".join(f"# HELP {name} {text}\n# TYPE {name} gauge\n{name} {value}\n"
for (name, text), value in zip(SERIES.items(), values)))
def read_last_success(path: Path) -> int:
"""The last-success time already on disk, so a failed run keeps it instead of resetting it."""
if path.exists():
for line in path.read_text().splitlines():
if line.startswith("sweep_last_success_seconds "):
return int(line.split()[1])
return 0

node_exporter scrapes on its own clock and could catch the file half-written, so write_textfile writes a temp file in the same directory and os.replaces it into place, the atomic write from "Files, paths and safe writes" in py-sec ("Temp files, locks and timeouts" showed the shell form). Every run stamps sweep_last_run_seconds and its exit code; only a clean run moves sweep_last_success_seconds.

deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ cat /var/lib/scr-observability/textfile/sweep.prom
# HELP sweep_last_run_seconds Unix time the last run finished, whatever its outcome. # TYPE sweep_last_run_seconds gauge sweep_last_run_seconds 1790626368 # HELP sweep_last_success_seconds Unix time of the last fully successful run. # TYPE sweep_last_success_seconds gauge sweep_last_success_seconds 1790626368 # HELP sweep_last_exit_code Exit status of the last run. # TYPE sweep_last_exit_code gauge sweep_last_exit_code 0 …
$ stat -c "%a %U %n" /var/lib/scr-observability/textfile/sweep.prom
644 deploy /var/lib/scr-observability/textfile/sweep.prom

The file is 0644 because of the os.chmod: tempfile.mkstemp creates files 0600, and node_exporter runs as its own account. Here scrobs, a system account with no home and no login shell, plays that account. metrics_bad.py does the atomic rename but skips the chmod:

metrics_bad.py (shown to be avoided)
"""The atomic-but-unreadable metric writer: correct rename, wrong mode. Shown to be avoided."""
import os
import sys
import tempfile
from pathlib import Path
path = Path(sys.argv[1])
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix="." + path.name + ".")
with os.fdopen(fd, "w") as f:
f.write("sweep_up 1\n")
os.replace(tmp, path) # atomic, but the file keeps mkstemp's 0600: the scraper cannot read it
deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin scrobs id scrobs
uid=999(scrobs) gid=987(scrobs) groups=987(scrobs)
$ python3 metrics_bad.py /var/lib/scr-observability/textfile/bad.prom stat -c "%a %n" /var/lib/scr-observability/textfile/bad.prom
600 /var/lib/scr-observability/textfile/bad.prom
$ sudo -u scrobs cat /var/lib/scr-observability/textfile/bad.prom; echo "exit status $?"
cat: /var/lib/scr-observability/textfile/bad.prom: Permission denied exit status 1
$ sudo -u scrobs head -n 3 /var/lib/scr-observability/textfile/sweep.prom
# HELP sweep_last_run_seconds Unix time the last run finished, whatever its outcome. # TYPE sweep_last_run_seconds gauge sweep_last_run_seconds 1790626368
$ stat -c "%a %n" ~ metrics/down.prom sudo -u scrobs cat ~/scr-observability/metrics/down.prom; echo "exit status $?"
750 /home/deploy 644 metrics/down.prom cat: /home/deploy/scr-observability/metrics/down.prom: Permission denied exit status 1
$ rm /var/lib/scr-observability/textfile/bad.prom

scrobs was refused the 0600 file and read the 0644 one. It was also refused a 0644 file under your home, because Ubuntu 26.04 creates homes 0750 and every directory on the path must let the reader through. That is why the textfile directory lives under /var/lib, owned by the job's account and mode 0755. A scrape that fails either way does not error loudly; the series just disappears. A small gate reads the two timestamps back:

freshness.py
"""A freshness gate over sweep's metric file: is the job running, and is it succeeding?
freshness.py --max-age SECONDS FILE
exit 0 the last run and the last success are both within --max-age
exit 1 a human must look: no metric file, the job stopped running, or it runs and keeps failing
"""
import argparse
import sys
import time
from pathlib import Path
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="freshness")
parser.add_argument("--max-age", type=int, required=True, metavar="SECONDS")
parser.add_argument("file", type=Path)
args = parser.parse_args(argv)
if not args.file.exists():
print(f"freshness: no {args.file}: the job never wrote it, or it writes somewhere else")
return 1
samples = dict(line.split() for line in args.file.read_text().splitlines()
if line and not line.startswith("#"))
run_age = time.time() - float(samples["sweep_last_run_seconds"])
success_age = time.time() - float(samples["sweep_last_success_seconds"])
if run_age > args.max_age:
print(f"freshness: last run {run_age:.0f}s ago: the job has stopped running")
return 1
if success_age > args.max_age:
when = "never" if samples["sweep_last_success_seconds"] == "0" else f"{success_age:.0f}s ago"
print(f"freshness: runs (last exit {samples['sweep_last_exit_code']}) but last success"
f" {when}: it keeps failing")
return 1
print(f"freshness: last run {run_age:.0f}s ago, last success {success_age:.0f}s ago")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ python3 freshness.py --max-age 86400 /var/lib/scr-observability/textfile/sweep.prom; echo "exit status $?"
freshness: last run 9s ago, last success 9s ago exit status 0
$ python3 freshness.py --max-age 86400 metrics/mixed.prom; echo "exit status $?"
freshness: runs (last exit 1) but last success never: it keeps failing exit status 1
$ old=$(( $(date +%s) - 3 * 86400 )) printf "sweep_last_run_seconds %s\nsweep_last_success_seconds %s\nsweep_last_exit_code 0\n" "$old" "$old" > metrics/stopped.prom python3 freshness.py --max-age 86400 metrics/stopped.prom; echo "exit status $?"
freshness: last run 259201s ago: the job has stopped running exit status 1
$ python3 freshness.py --max-age 86400 metrics/absent.prom; echo "exit status $?"
freshness: no metrics/absent.prom: the job never wrote it, or it writes somewhere else exit status 1

Four answers. A recent run and success pass. The mixed config runs but has never succeeded. A file whose timestamps are three days old belongs to a job that stopped running, which is where you check the timer, not the logs. A missing file means the job never wrote it or writes somewhere else. In Prometheus the same checks are time() - sweep_last_run_seconds > 86400, time() - sweep_last_success_seconds > 86400 and absent(sweep_last_run_seconds).

Re-runs that are safe after a crash

A correct job is safe to run twice, and safe to run again after it was killed halfway. Two rules make that true. First, check the target, not a memo: read_generation asks each host what it runs now, so a host already converged (by an earlier run or by hand) is a no-op. Second, make each change final as soon as it is made, never in one write at the end:

targets.py
"""The targets sweep changes. Each host is stood in for by one JSON file under fleet/."""
import json
import os
import time
from pathlib import Path
from exit_codes import PermanentError, TransientError
def read_generation(host: Path) -> int:
"""Ask the target which key generation it runs now: the target is the truth, not a memo."""
state = json.loads(host.read_text()) # a corrupt file raises JSONDecodeError: no rule for it
if not state.get("reachable", True):
raise TransientError("host did not answer")
if state.get("frozen"):
raise PermanentError("host is under a change freeze")
return state["generation"]
def rotate(host: Path, generation: int, work_seconds: float) -> None:
"""Stand-in for the real change (rotate a key, renew a certificate): slow, then one atomic write."""
time.sleep(work_seconds)
tmp = host.with_name(host.name + ".tmp")
tmp.write_text(json.dumps({"generation": generation}) + "\n")
os.replace(tmp, host)
converge.py
"""Bring every target to the wanted generation. Each target's change is final as soon as it is made."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import audit
from exit_codes import PermanentError, TransientError
from jsonlog import get_logger
from targets import read_generation, rotate
log = get_logger("sweep")
@dataclass
class Report:
ok: int = 0
changed: int = 0
permanent: list[str] = field(default_factory=list)
transient: list[str] = field(default_factory=list)
def converge(cfg: dict, dry_run: bool, report: Report) -> None:
want, fleet, trail = cfg["generation"], Path(cfg["fleet"]), Path(cfg["audit"])
for target in cfg["targets"]:
try:
have = read_generation(fleet / f"{target}.json")
except PermanentError as err:
report.permanent.append(target)
log.error("failed", extra={"fields": {"target": target, "result": "permanent", "error": str(err)}})
continue
except TransientError as err:
report.transient.append(target)
log.warning("failed", extra={"fields": {"target": target, "result": "transient", "error": str(err)}})
continue
if have == want: # converged already, by an earlier run or by hand: a no-op
report.ok += 1
log.info("unchanged", extra={"fields": {"target": target, "result": "unchanged"}})
continue
if dry_run:
log.info("would change", extra={"fields": {"target": target, "from": have, "to": want}})
continue
# Intent before the change, completion after it: a run that dies in between leaves a
# "start" with no "done", so the audit trail shows the change that may have happened.
audit.append(trail, {"ts": _now(), "target": target, "event": "start", "from": have, "to": want})
rotate(fleet / f"{target}.json", want, cfg.get("work_seconds", 0))
audit.append(trail, {"ts": _now(), "target": target, "event": "done", "to": want})
report.ok += 1
report.changed += 1
log.info("changed", extra={"fields": {"target": target, "result": "changed", "from": have, "to": want}})
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")

The audit line "start" is written before the change and "done" after it. The run itself is held by lock.py, which is single_instance from "Failure design":

lock.py
"""One run at a time: an exclusive, non-blocking flock on a file that is never deleted."""
import fcntl
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from exit_codes import TransientError
@contextmanager
def single_instance(path: Path) -> Iterator[None]:
with path.open("a") as f:
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise TransientError(f"another run holds {path}") from None
yield # the kernel drops the lock when the file is closed or the process dies, even by SIGKILL
deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ python3 sweep.py; echo "exit status $?" wc -l < audit/sweep.jsonl
{"ts": "2026-09-28T20:12:56.902573+00:00", "level": "info", "logger": "sweep", "msg": "unchanged", "target": "web01", "result": "unchanged"} {"ts": "2026-09-28T20:12:56.902697+00:00", "level": "info", "logger": "sweep", "msg": "unchanged", "target": "web02", "result": "unchanged"} {"ts": "2026-09-28T20:12:56.902766+00:00", "level": "info", "logger": "sweep", "msg": "unchanged", "target": "db01", "result": "unchanged"} {"ts": "2026-09-28T20:12:56.904865+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 0, "permanent": [], "transient": [], "exit": 0} exit status 0 6
$ python3 sweep.py --config rotate3.toml & pid=$! sleep 3; kill -KILL "$pid"; wait "$pid"; echo "exit status $?"
{"ts": "2026-09-28T20:12:58.970809+00:00", "level": "info", "logger": "sweep", "msg": "changed", "target": "web01", "result": "changed", "from": 2, "to": 3} -bash: line 2: 775087 Killed python3 sweep.py --config rotate3.toml exit status 137
# At an interactive prompt the same kill shows as [1]+ Killed, from job control.
$ grep . fleet/web01.json fleet/web02.json fleet/db01.json jq -c "{target, event, to}" audit/sweep.jsonl | tail -n 3
fleet/web01.json:{"generation": 3} fleet/web02.json:{"generation": 2} fleet/db01.json:{"generation": 2} {"target":"web01","event":"start","to":3} {"target":"web01","event":"done","to":3} {"target":"web02","event":"start","to":3}
$ python3 sweep.py --config rotate3.toml; echo "exit status $?" jq -c "{target, event, to}" audit/sweep.jsonl | tail -n 4
{"ts": "2026-09-28T20:13:00.062414+00:00", "level": "info", "logger": "sweep", "msg": "unchanged", "target": "web01", "result": "unchanged"} {"ts": "2026-09-28T20:13:02.084367+00:00", "level": "info", "logger": "sweep", "msg": "changed", "target": "web02", "result": "changed", "from": 2, "to": 3} {"ts": "2026-09-28T20:13:04.100878+00:00", "level": "info", "logger": "sweep", "msg": "changed", "target": "db01", "result": "changed", "from": 2, "to": 3} {"ts": "2026-09-28T20:13:04.104996+00:00", "level": "info", "logger": "sweep", "msg": "run complete", "changed": 2, "permanent": [], "transient": [], "exit": 0} exit status 0 {"target":"web02","event":"start","to":3} {"target":"web02","event":"done","to":3} {"target":"db01","event":"start","to":3} {"target":"db01","event":"done","to":3}

The second run found every host at generation 2, logged unchanged three times and added no audit line: still six. Then rotate3.toml (two seconds per host) was killed with SIGKILL after three seconds, exit 137. web01 was already at 3, web02 was not, and the audit trail ends with a start for web02 and no done: the record of a change that may or may not have happened. The next run found no stale lock (the kernel releases an flock when its holder dies), skipped web01 and finished the other two. Nothing was done twice. The only record that can go missing is a done line for a change the job finished just before it died, and the start line shows where to look.

An audit trail you can add to but not quietly rewrite

The audit file is the record of every change the job made. Each line carries the SHA-256 of the line before it, so an earlier record cannot be edited without breaking the chain, and every append takes a lock on the file:

audit.py
"""An append-only audit trail: one JSON object per line, each carrying the hash of the line before.
Editing or deleting a line that has a successor breaks the chain, and verify() finds it. Cutting
lines off the end does not: the shorter file is still a valid chain. Only a copy of the newest
hash kept somewhere else (the lines shipped off the host as they are written) shows that. Whoever
can write the file can also recompute every hash, so the chain raises the bar; it does not seal
the file."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
GENESIS = "0" * 64
def _hash(line: bytes) -> str:
return hashlib.sha256(line).hexdigest()
def _last_line(fd: int) -> bytes:
"""Read the last line from the end, so an append does not reread the whole file."""
end = os.lseek(fd, 0, os.SEEK_END)
tail = os.pread(fd, min(end, 65536), max(0, end - 65536)) # a record is far below 64 KiB
return tail.rstrip(b"\n").rsplit(b"\n", 1)[-1]
def append(path: Path, record: dict) -> None:
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o640)
try:
# Without the lock, two writers read the same last line and both chain to it. With it,
# read-last-hash, write and fsync are one step.
fcntl.flock(fd, fcntl.LOCK_EX)
last = _last_line(fd)
entry = {**record, "prev": _hash(last) if last else GENESIS}
os.write(fd, json.dumps(entry, sort_keys=True).encode() + b"\n")
os.fsync(fd) # on disk before the job goes on to the next step
finally:
os.close(fd) # closing the descriptor releases the lock
def verify(path: Path) -> bool:
prev = GENESIS
for raw in path.read_bytes().splitlines():
if not raw:
continue
if json.loads(raw).get("prev") != prev:
return False
prev = _hash(raw)
return True
deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ python3 check_audit.py audit/sweep.jsonl
chain over 13 records: intact
$ for i in 1 2; do python3 -c "import audit, pathlib; [audit.append(pathlib.Path(\"audit/race.jsonl\"), {\"n\": n}) for n in range(200)]" & done wait python3 check_audit.py audit/race.jsonl
chain over 400 records: intact
$ head -n 4 audit/sweep.jsonl > audit/cut.jsonl python3 check_audit.py audit/cut.jsonl
chain over 4 records: intact
$ sed -i "1s/web01/web99/" audit/sweep.jsonl python3 check_audit.py audit/sweep.jsonl; echo "exit status $?"
chain over 13 records: BROKEN exit status 1

Thirteen records chain intact. Two processes appending 200 records each at the same time also left an intact chain, because flock makes read-last-line, write and fsync one step; without it both writers chain to the same line and verify reports tampering that never happened. The first four lines alone still verify, as the docstring warns: a hash chain cannot see its tail cut off. Editing web01 in the first record changed that line's hash, so record 2 no longer matched. The chain raises the bar and does not seal the file, since whoever can edit it can recompute every hash. The copy an attacker cannot touch is the one shipped off the host as each line is written, to a collector the job's account cannot reach.

Four traces from one run
1Lock, then read each target
the target is the truth; converged is a no-op
2Change one target at a time
audit start, change, audit done
3JSON log to stderr
UTC-aware, typed fields, reserved keys kept
4Metric, written atomically
last run, last success, exit code, mode 0644
5Exit status from the contract
0, 1, 2, 70, 75, 78: the scheduler acts on it
Lose any one and a whole class of failure turns invisible.

Try this

check_audit.py prints only intact or BROKEN. When a chain of thousands of records breaks, you want to know where. Change it so it reports the first record whose prev does not match, and stops there. The lab's solution, run against the tampered file above, printed chain broke at record 2: its prev does not match the hash of record 1 and exited 1; against audit/race.jsonl it printed chain over 400 records: intact and exited 0. Then explain why the break shows up at record 2 when you edited record 1. When you are done, remove the account and the directory:

deploy@web01:~/scr-observability · Ubuntu 26.04 LTS
$ sudo userdel scrobs sudo rm -rf /var/lib/scr-observability id scrobs 2>/dev/null || echo "no user scrobs" [ -e /var/lib/scr-observability ] || echo "no /var/lib/scr-observability"
no user scrobs no /var/lib/scr-observability

Takeaway

Exit with a status the caller can act on (a human, a retry, a bug, the config) and wire the scheduler to it; make every change final and re-checked against the real target so a killed run can simply run again; and write a metric whose two timestamps tell "stopped" from "keeps failing".

Quick check
01A key-rotation job runs from a systemd timer with Restart=on-failure and RestartPreventExitStatus=1 2 70 78. One run finds a host frozen by change policy and another host that timed out. Which exit status should the run return?
Correct — A permanent failure outranks a transient one; the unit fails at once and OnFailure= can page.
Incorrect — The freeze does not clear by itself, so the unit would restart until the start limit and page late, for the wrong reason.
Incorrect — Both failures were classified; 70 is for an exception no rule covers, which a retry would repeat.
Incorrect — A zero hides both failures from systemd and from every alert that watches the unit.
02A cron job writes sweep.prom by creating a temp file with tempfile.mkstemp and renaming it into node_exporter's textfile directory. The rename is atomic, yet node_exporter reports the series as absent. What is the most likely cause?
Incorrect — Both paths are in one directory on one filesystem, so the rename is atomic; a torn read is not what is happening.
Incorrect — There is no such cache; node_exporter reads the directory again on each scrape.
Correct — Change the temp file to 0644 before the rename, and check that every directory above it lets that user through.
Incorrect — The textfile collector accepts gauges; the last-run and last-success timestamps are gauges.
03sweep is killed with SIGKILL after it rotated web01 and while it was rotating web02. What does the next run do?
Incorrect — That was the flaw of a local memo written after the loop; this job reads each host and writes each change as it goes.
Correct — The target is the truth, each change is final when made, and the audit keeps web02's start line.
Incorrect — An flock belongs to an open file, and the kernel releases it when the process dies, even by SIGKILL.
Incorrect — The unmatched line is information for a person; the job does not treat it as an error and carries on.

Related