diff --git a/changelog.d/162.md b/changelog.d/162.md new file mode 100644 index 0000000..e57e3ae --- /dev/null +++ b/changelog.d/162.md @@ -0,0 +1,3 @@ +### Fixed + +- Agent tenant boxes ship cron — binary asserted, service enabled and active — so the duty engine can arm its timer (#162) diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index 1eb9d12..ee251c6 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -233,6 +233,27 @@ append_line_once() { chown "$TENANT_USER:$TENANT_GROUP" "$file" } +# The binary on PATH is not the effective state — an image can ship crontab +# with cron.service masked or stopped, and an unarmed timer is exactly the +# silent-inert box #162 is about. Converge best-effort, then assert what +# systemd actually reports; the assert is the authority. Enabling an +# already-enabled unit is a no-op and no path here touches any crontab. +# A function so test/cli.sh can lift it verbatim and drive it against a +# stubbed systemctl, the drop_incus precedent. +converge_cron() { + if ! systemctl is-enabled cron >/dev/null 2>&1; then + log "cron.service not enabled — converging" + systemctl unmask cron >/dev/null 2>&1 || true + systemctl enable cron >/dev/null 2>&1 || true + fi + if ! systemctl is-active cron >/dev/null 2>&1; then + log "cron.service not active — starting" + systemctl start cron >/dev/null 2>&1 || true + fi + systemctl is-enabled cron >/dev/null 2>&1 || die "cron.service is not enabled after converge — the duty engine's timer never fires without it (#162)" + systemctl is-active cron >/dev/null 2>&1 || die "cron.service is not active after converge — the duty engine's timer never fires without it (#162)" +} + # --- packages ---------------------------------------------------------------- export DEBIAN_FRONTEND=noninteractive log "installing base packages (tenant ${ROLE})" @@ -246,8 +267,11 @@ else # The shared agent toolbelt the templates carried, plus the definition's # APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a # word list, every word already vetted by the parser's package-name gate. + # cron is toolbelt, not a template flavour: every agent tenant exists to + # run the cron-driven duty engine, whose unprivileged installer can detect + # a missing cron but never apt-get it (#162). # shellcheck disable=SC2086 - apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS + apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS fi # Assert the effective toolbelt, not apt's exit code — tmux is the box#65 # contract ('box tmux' runs tmux new-session inside every box) and gh is how @@ -256,6 +280,9 @@ command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — ' if [ "$ROLE" != "staging-box" ]; then command -v gh >/dev/null 2>&1 || die "gh missing after package install" command -v git >/dev/null 2>&1 || die "git missing after package install" + command -v crontab >/dev/null 2>&1 || die "crontab missing after package install — the duty engine arms itself with cron (#162)" + # staging-box is exempt with the rest of this block: no agent, no engine. + converge_cron fi # --- docker ------------------------------------------------------------------ diff --git a/test/cli.sh b/test/cli.sh index d0b6af8..d5b59ab 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -655,6 +655,109 @@ check "tenant: a crafted role name dies at the charset gate" 2 "invalid tenant r "$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box' check "tenant: dockerd effective-state assert is present" 0 "" \ grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh" +# The #162 contract, both halves: cron installs with the agent toolbelt (the +# duty engine's unprivileged installer can never apt-get it), and PATH is not +# the effective state — the service must be asserted enabled AND active, or a +# masked daemon leaves every tenant crontab silently inert. +check "tenant: cron rides the agent toolbelt install" 0 "" \ + grep -qE '^ *apt-get install .* cron ' "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: crontab toolbelt assert is present" 0 "" \ + grep -qF "command -v crontab" "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: cron.service enabled assert is present" 0 "" \ + grep -qF "systemctl is-enabled cron" "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: cron.service active assert is present" 0 "" \ + grep -qF "systemctl is-active cron" "$ROOT/commands/bootstrap-tenant.sh" + +# The converge is exercised, not argued about (the drop_incus precedent): +# converge_cron is lifted out of the real file verbatim — column-0 +# 'converge_cron() {' through column-0 '}' — and driven against a stub +# systemctl whose effective state lives in files. The extraction is asserted +# first: if that shape ever changes the lift comes back empty and every case +# below fails loudly rather than passing vacuously. +CRON_FN="$(sed -n '/^converge_cron() {/,/^}/p' "$ROOT/commands/bootstrap-tenant.sh")" +# shellcheck disable=SC2016 # $1 is the inner bash -c's positional, deliberately +check "tenant: converge_cron lifts out of the real file whole" 0 "" \ + bash -c '[ -n "$1" ] && printf %s "$1" | grep -q "^}$"' _ "$CRON_FN" +# ...and the function must actually be CALLED — a lifted-and-driven function +# nobody invokes proves nothing about bootstrap. +check "tenant: converge_cron is invoked" 0 "" \ + grep -qE '^ *converge_cron$' "$ROOT/commands/bootstrap-tenant.sh" + +CRON_BASH="$(command -v bash)" +# drive_cron — the real converge_cron against +# a stub systemctl. Effective state is files: 'enabled'/'active' existing means +# the probe passes. 'noop': both preexist — the idempotent re-run. 'converge': +# neither, and enable/start take effect. 'masked': neither, and enable/start do +# NOTHING — the unrecoverably-inert daemon #162 is about. 'deadstart': enabled, +# but start never takes. The stub logs its calls to a file: the real call sites +# are '>/dev/null 2>&1', so a stub that spoke on either stream would be +# silenced and the call assertions below would pass vacuously. +drive_cron() { + local mode="$1" d + d="$(mktemp -d)" + mkdir -p "$d/bin" + case "$mode" in noop) : > "$d/enabled"; : > "$d/active" ;; deadstart) : > "$d/enabled" ;; esac + # The stub restores a real PATH for itself: the caller's PATH is REPLACED by + # the stub dir (that is what keeps a host systemctl out of reach), which + # would otherwise leave the stub unable to find 'echo' as an executable. + cat > "$d/bin/systemctl" <> "$d/calls" +case "\$1" in + is-enabled) [ -e "$d/enabled" ] ;; + is-active) [ -e "$d/active" ] ;; + enable) case "$mode" in noop|converge) : > "$d/enabled" ;; esac ;; + start) case "$mode" in noop|converge) : > "$d/active" ;; esac ;; +esac +EOF + chmod +x "$d/bin/systemctl" + # The driving shell mirrors the real script: same set flags, same log/die. + # shellcheck disable=SC2016 # $*/$1/$2 resolve inside the driving shell + PATH="$d/bin" "$CRON_BASH" -c ' + set -euo pipefail + log() { printf "rig-bootstrap: %s\n" "$*"; } + die() { printf "rig-bootstrap: ERROR: %s\n" "$1" >&2; exit "${2:-1}"; } + '"$CRON_FN"' + converge_cron' 2>&1 + echo "RC=$?" + cat "$d/calls" 2>/dev/null + rm -rf "$d" +} +CRON_NOOP="$(drive_cron noop)" +CRON_CONV="$(drive_cron converge)" +CRON_MASK="$(drive_cron masked)" +CRON_DEAD="$(drive_cron deadstart)" +cron_has() { printf '%s' "$1" | grep -qF -e "$2"; } # cron_has + +# The idempotent re-run: both probes already pass, NOTHING is converged and +# nothing dies — a second bootstrap must not touch the unit. +check "converge_cron: already enabled+active exits 0" 0 "" cron_has "$CRON_NOOP" "RC=0" +check "converge_cron: the no-op never calls unmask" 1 "" cron_has "$CRON_NOOP" "systemctl unmask" +check "converge_cron: the no-op never calls enable" 1 "" cron_has "$CRON_NOOP" "systemctl enable" +check "converge_cron: the no-op never calls start" 1 "" cron_has "$CRON_NOOP" "systemctl start" +# The converge path: a disabled, stopped unit is unmasked, enabled, started — +# and the asserts then pass on systemd's own answer, exit 0. +check "converge_cron: disabled+inactive converges, exits 0" 0 "" cron_has "$CRON_CONV" "RC=0" +check "converge_cron: the converge unmasks" 0 "" cron_has "$CRON_CONV" "systemctl unmask cron" +check "converge_cron: the converge enables" 0 "" cron_has "$CRON_CONV" "systemctl enable cron" +check "converge_cron: the converge starts" 0 "" cron_has "$CRON_CONV" "systemctl start cron" +# The log states the probe fact, never a success it did not verify. +check "converge_cron: the log states the probe fact" 0 "" \ + cron_has "$CRON_CONV" "cron.service not enabled — converging" +# THE #162 FAILURE: a converge that does not take effect DIES, nonzero, naming +# cron — never a silent success wrapping an inert timer. +check "converge_cron: an unrecoverable unit dies nonzero" 0 "" cron_has "$CRON_MASK" "RC=1" +check "converge_cron: the death names the enabled assert" 0 "" \ + cron_has "$CRON_MASK" "cron.service is not enabled after converge" +check "converge_cron: the death cites #162" 0 "" cron_has "$CRON_MASK" "#162" +check "converge_cron: the dying path tried to converge first" 0 "" \ + cron_has "$CRON_MASK" "systemctl unmask cron" +# Enabled but the start never takes: the ACTIVE assert dies — enabled alone +# is not an armed timer. +check "converge_cron: enabled-but-dead start dies nonzero" 0 "" cron_has "$CRON_DEAD" "RC=1" +check "converge_cron: that death names the active assert" 0 "" \ + cron_has "$CRON_DEAD" "cron.service is not active after converge" # The machine-role traits die with the tenant story, never "unknown flag" — an # operator coming from the machine families needs the boundary, not a shrug. check "tenant: trait flags die with the tenant story" 2 "have no traits" \