Files
felhom.eu/documentation/audits/SPIKE-agent-selfupdate-2026-07-05.md
admin 582917d12d docs(audit): SPIKE — agent self-update rollback mechanics (systemd 257 semantics on felhom-pve)
GO with two design-reshaping surprises: OnFailure= fires on EVERY crash on
systemd 257 (rollback triggers at first post-apply crash; marker-guard +
idempotency mandatory), and deliberate restarts consume start-limit budget.
S1 trap confirmed (default shape never reaches failed — 35 starts/180s);
tuned [Unit] StartLimitIntervalSec=120 + Burst=4 → failed in ~20s;
[Service] placement is an asymmetric half-applied trap; reset-failed required.
S2: all restart variants reliable, detached systemd-run wins. S3: A/B flip
atomic under kill -9, all verbs + guards pass. S4: StateDir + sudoers shapes OK.
Dummy artifacts only; real agent uptime unchanged (verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 14:45:58 +02:00

14 KiB
Raw Permalink Blame History

SPIKE — agent self-update rollback mechanics (systemd semantics on felhom-pve)

  • Date: 2026-07-05
  • Class: Spike / empirical. Dummy artifacts only (felhom-spike-selfupd.service, felhom-spike-rollback.service, /usr/local/bin/felhom-spike-agent, /usr/local/sbin/felhom-spike-guarded, state in /var/tmp/felhom-spike-selfupd/) — the real felhom-agent unit/binary/sudoers/StateDir were never modified, stopped, or restarted (uptime proof in §Cleanup). No production code. Full teardown verified.
  • Host: felhom-pve (demo-felhom, 192.168.0.162), root over SSH.
  • Context baseline: felhom-agent main @ 72d7f05 (v0.69.0, running); unit shape mirrored: Type=simple, Restart=on-failure, RestartSec=5s, no StartLimit overrides.
  • Verdict: GO — every mechanism works, but two systemd-257 behaviours reshape the design: (1) OnFailure= fires on every crash, not only on terminal failed (S1 surprise — makes rollback faster but makes the marker-guard + idempotency non-negotiable); (2) deliberate restarts count against the start limit (S2 surprise — the update flow itself can trip the limit it installs). Findings-for-the-TASK list at the end.

S5 — environment facts (everything below is pinned to this stack)

Fact Value
systemd 257 (257.13-1~deb13u1)
OS / PVE Debian 13.5, pve-manager/9.2.2 (kernel 7.0.2-6-pve)
Compiled start-limit defaults DefaultStartLimitIntervalSec=10s, DefaultStartLimitBurst=5 (commented in system.conf → compiled defaults apply; confirmed live on the spike unit: StartLimitIntervalUSec=10s / StartLimitBurst=5)
/usr/local/bin, /var/lib/felhom-agent, /var/tmp all on /dev/mapper/pve-root ext4 — same fs ⇒ atomic rename(2) between staging and live is valid
Tools sha256sum /usr/bin, systemd-run present, visudo /usr/sbin
Real agent at spike start v0.69.0, MainPID 1705667, ActiveEnterTimestamp Sat 2026-07-04 21:07:38 CEST

S1 — start-limit → failed → OnFailure chain

Dummy unit mirroring the real shape; dummy binary exits 1 while a CRASH marker exists, else sleeps; OnFailure=felhom-spike-rollback.service (oneshot appending to onfailure.log).

S1a — current (default) shape: hypothesis CONFIRMED + SURPRISE

CRASH set, unit started, watched 180s:

StartLimitIntervalUSec=10s  StartLimitBurst=5  Restart=on-failure  RestartUSec=5s
t=+15s  NRestarts=2  ActiveState=activating SubState=auto-restart   onfailure.log: 3
t=+90s  NRestarts=17 activating/auto-restart                        onfailure.log: 18
t=+180s NRestarts=34 activating/auto-restart                        onfailure.log: 35
35 start attempts in 180s; `systemctl is-failed` → "activating" the whole time
  • CONFIRMED (the suspected trap): with RestartSec=5s and the default 10s/5 limit, restarts are spaced too far apart to ever trip the limit → the unit never enters failed and crash-loops forever. With the real agent's current unit shape, a start-limit-based terminal state does not exist.
  • SURPRISE (design-reshaping): on systemd 257 OnFailure= fires on EVERY crash — 35 fires in 180s, one per exit — even while the unit sits in activating (auto-restart), never reaching failed. The classic mental model ("OnFailure fires once, when restarts are exhausted") is wrong on this systemd. Journal wording per crash: Triggering OnFailure= dependencies.

Verdict: NO-GO for the current unit shape as a rollback trigger — but not for the reason hypothesized: the trigger DOES fire (repeatedly); what's missing is a terminal state, and what's mandatory is a rollback unit that tolerates being fired dozens of times.

S1b — tuned limits: bounded-time failure + the placement trap

Placement trap (version-specific, ASYMMETRIC): in a drop-in under [Service]:

/etc/systemd/system/felhom-spike-selfupd.service.d/limits.conf:2:
  Unknown key 'StartLimitIntervalSec' in section [Service], ignoring.
→ shows StartLimitIntervalUSec=10s (ignored!)  StartLimitBurst=4 (ACCEPTED!)

StartLimitBurst is still honoured in [Service] (compat) while StartLimitIntervalSec is silently-warned and ignored — a half-applied config. Both keys MUST live in [Unit]. With [Unit] placement both apply: StartLimitIntervalUSec=2min, StartLimitBurst=4.

Crash burst with [Unit] StartLimitIntervalSec=120 + StartLimitBurst=4:

starts at t=0,5,10,15 (4 = burst) → 5th scheduled restart at t=+20s:
  "Start request repeated too quickly." → "Failed with result 'exit-code'."
entered FAILED at t=+20s   (final: ActiveState=failed NRestarts=4 Result=exit-code)
onfailure fired count: 5   (one per crash ×4 + one on the terminal failed entry)
  • Terminal failed in ~20s — well inside the ≤90s GO bound.
  • Note Result=exit-code (not start-limit-hit) — match on is-failed/state, not on Result.
  • The "exactly once" expectation is refuted: 5 fires. Harmless iff the rollback unit is idempotent + marker-guarded (see findings).

S1c — recovery path

  • systemctl start while failed (within the limit window) is REFUSED (exit 1, unit stays failed) — the start limit still gates manual/rollback starts. systemctl reset-failed <unit> is REQUIRED before the post-rollback start (or you wait out the interval).
  • Full chain demo (rollback oneshot armed to: remove CRASH → reset-failedstart): crash → OnFailure fired at the first crash (t≈0s, because of the per-crash firing) → rollback ran → unit running again at t=+5s and stayed up. The production rollback will typically fire and recover on the FIRST crash of a bad binary — tens of seconds faster than the start-limit path, which remains the backstop.

S1d — false-trigger check

OnFailure fired throughout S1a/S1b with no pending marker in existence, and (S2 surprise, below) also fires when a healthy binary trips the start limit through deliberate restarts. → The production rollback unit's no-op-without-pending-marker guard is MANDATORY, and the rollback action itself must be idempotent (it will be invoked many times per incident).

S1 GO — tuned drop-in values for the real unit (in [Unit], via drop-in): StartLimitIntervalSec=120 + StartLimitBurst=4 → terminal failed ≈20s after the first crash; rollback in practice triggers on the first OnFailure fire (~05s).

S2 — restarting your own unit from inside its cgroup

Trigger files make the dummy binary invoke the restart on itself from within the unit's cgroup.

First run surprise: rounds began failing after a few restarts — journal: Start request repeated too quickly ×3. Deliberate systemctl restarts count against StartLimitBurst: with 120s/4 armed, the update flow's own restarts (apply-restart + health-checks + rollback-restart) can trip the limit on a perfectly healthy binary, blocking further starts AND firing OnFailure. (With reset-failed between rounds → 100% reliable.)

Variant Result Caller survives the call?
S2a direct systemctl restart from inside the cgroup 5/5 — restart completes, new MainPID, clean journal (Stopping→Stopped→Started, no canceled-job noise) No — killed with the cgroup mid-call (its post-call echo never ran); systemd completes the job anyway
S2b detached: systemd-run --on-active=2s --timer-property=AccuracySec=100ms systemctl restart … 5/5 Yessystemd-run returns 0, agent can log "restart scheduled"; transient timer+service self-clean (no leftovers)
S2c systemctl restart --no-block 3/3 No (killed once the restart lands)

S2 GO — winner: S2b (detached transient timer). All three variants are mechanically reliable; S2b is preferred because the agent survives to log/report the handoff and the restart is decoupled from sudo's process lifetime. S2a is an acceptable fallback (systemd finishes a restart job even when the requester dies). Caveat for the TASK: every deliberate restart consumes start-limit budget — the wrapper's restart paths should systemctl reset-failed first (as the rollback already must), and the flow should be designed to stay under Burst within the interval.

S3 — A/B flip + pending-marker wrapper mechanics

Dummy wrapper verbs apply <staged> <sha256> / commit / rollback over /usr/local/bin/felhom-spike-agent (+.prev) + pending.json; staging + live on the same fs.

Probe Result Evidence
S3a companion — wrong sha PASS (refused, nothing changed) refusing apply: sha mismatch, exit 1; live sha unchanged; no pending; no .prev created (the sha check runs before the .prev copy — keep this ordering)
S3a — happy path PASS apply → live=v2 sha exact, .prev=v1 sha exact, pending written; detached restart (S2b) → v2 started logged, unit running
S3b — kill -9 mid-swap PASS killed between .prev copy and rename: live binary still the complete old version (sha equal, parses); no pending; re-run apply converged to v3. Nuance: the kill landed before the staging-copy existed, so the orphaned-temp case wasn't exercised — a kill between staging-copy and mv would leave a harmless <bin>.new.<pid>; the production wrapper should sweep stale temps on entry
S3c — rollback verb PASS prev restored byte-identical (sha equal), pending cleared, reset-failed + restart → unit running the previous version
S3d — commit PASS pending cleared, .prev retained (manual safety net); second commit = clean no-op (exit 0)
S3e — rollback with NO pending PASS no pending — refusing rollback (no-op), exit 0, binary byte-identical, unit not restarted — exactly the guard shape the OnFailure-triggered unit needs

S3 GO — verb semantics as probed survive; atomic same-fs rename guarantee held under kill -9.

S4 — permissions + sudoers syntax

  • S4a PASS: sudo -u felhom-agent mkdir/write/read/remove under /var/lib/felhom-agent/selfupdate/ all OK (StateDir 0750 agent-owned; the subdir was removed after the probe — the only touch near real agent state, agent-owned data only).
  • S4b PASS (both shapes parse): visudo -cf accepts both …felhom-selfupdate-guarded apply /var/lib/felhom-agent/selfupdate/* [a-f0-9]* and the coarse … /var/lib/felhom-agent/selfupdate/* *. Note on semantics (not just syntax): sudoers args match by fnmatch — [a-f0-9]* only pins the FIRST character to the hex class; it is effectively coarse anyway. Since the wrapper re-verifies the sha itself (S3a companion), the coarse * form is acceptable and simpler; keep the [a-f0-9]* form only if the mild first-char narrowing is wanted.

Cleanup (verified)

Both spike units stopped/removed + daemon-reload + reset-failed; dummy binary/wrapper/state dir/StateDir-subdir all removed; systemctl list-units 'felhom-*' = exactly the pre-spike set (felhom-agent.service running + felhom-shared-parent.service); no transient run-p* units left. Real agent uptime unchanged: MainPID 1705667, ActiveEnterTimestamp Sat 2026-07-04 21:07:38 CEST — identical before and after.

Findings that MUST shape the implementation TASK

  1. OnFailure fires per crash on systemd 257 (S1a) — the rollback unit will be invoked many times per incident, including while the main unit is merely activating (auto-restart). It MUST be: (a) no-op without the pending marker (S3e shape proven), (b) idempotent, (c) tolerant of concurrent scheduled auto-restarts. Corollary: rollback happens at the FIRST post-apply crash (~05s) — the start-limit is the backstop, not the trigger.
  2. Start-limit tuning is still required for a terminal state: drop-in in [Unit] with StartLimitIntervalSec=120 + StartLimitBurst=4failed ≈20s after first crash. Without it the unit crash-loops forever (S1a, 35 starts/180s).
  3. [Service] placement is a half-applied trap on 257: StartLimitBurst is accepted, StartLimitIntervalSec is ignored with only a journal warning. Never split; always [Unit].
  4. reset-failed is mandatory before any start of a failed unit inside the limit window (S1c) — the rollback path AND any deliberate-restart path must run it first.
  5. Deliberate restarts consume start-limit budget (S2 surprise): the apply→restart→ health→commit flow must budget its own restarts (reset-failed before deliberate restarts; don't chain more than Burst restarts within the interval).
  6. Detached restart (S2b) is the winner: systemd-run --on-active=2s --timer-property=AccuracySec=100ms systemctl restart <unit> — 5/5, caller survives to log the handoff, transient units self-clean. Direct restart also completes (systemd finishes the job after the requester dies) — acceptable fallback.
  7. Wrapper ordering proven by S3: sha-verify BEFORE .prev copy; staging-copy + atomic same-fs mv; pending written after the flip; .prev retained on commit; sweep stale <bin>.new.* temps on entry (the one unexercised crash window).
  8. Match failure on state, not Result: terminal start-limit failure reports Result=exit-code on this systemd (journal: "Start request repeated too quickly"), not start-limit-hit.
  9. Sudoers: coarse trailing * for the sha arg is fine (fnmatch makes [a-f0-9]* first-char-only anyway; the wrapper is the real gate). Staging under /var/lib/felhom-agent/selfupdate/ is writable by the agent user as-is.