Merge pull request #163 from claude-bot-andresmgsl/build/162-tenant-cron
fix: arm cron on agent tenant boxes
This commit is contained in:
commit
93608e8d78
3 changed files with 134 additions and 1 deletions
3
changelog.d/162.md
Normal file
3
changelog.d/162.md
Normal file
|
|
@ -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)
|
||||||
|
|
@ -233,6 +233,27 @@ append_line_once() {
|
||||||
chown "$TENANT_USER:$TENANT_GROUP" "$file"
|
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 ----------------------------------------------------------------
|
# --- packages ----------------------------------------------------------------
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
log "installing base packages (tenant ${ROLE})"
|
log "installing base packages (tenant ${ROLE})"
|
||||||
|
|
@ -246,8 +267,11 @@ else
|
||||||
# The shared agent toolbelt the templates carried, plus the definition's
|
# 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
|
# 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.
|
# 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
|
# 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
|
fi
|
||||||
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
# 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
|
# 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
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
|
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 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
|
fi
|
||||||
|
|
||||||
# --- docker ------------------------------------------------------------------
|
# --- docker ------------------------------------------------------------------
|
||||||
|
|
|
||||||
103
test/cli.sh
103
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'
|
"$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box'
|
||||||
check "tenant: dockerd effective-state assert is present" 0 "" \
|
check "tenant: dockerd effective-state assert is present" 0 "" \
|
||||||
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
|
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 <noop|converge|masked|deadstart> — 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" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
PATH=/usr/bin:/bin
|
||||||
|
echo "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 <captured> <substr>
|
||||||
|
|
||||||
|
# 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
|
# 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.
|
# 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" \
|
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue