fix(container): apps stopped cleanly must come back — Restart=always

"Bitcoin Knots disappeared again, plus other apps." Root cause is a pairing,
not a single bug: quadlet renders `podman run ... --replace --rm`, so the
container is deleted the moment it stops, and from_manifest set
Restart=on-failure, which declines to restart after a CLEAN exit. bitcoind
exits 0 on SIGTERM. So any clean stop deleted the container AND left it
deleted — the app vanished from podman and from My Apps until a later
archipelago reconcile tick noticed and recreated it. That is the
"previously-running app has no container after boot — recreating
(desired-state recovery)" line, which fired for bitcoin-knots at 18:53,
19:57 and 20:39 and for electrumx at 19:57 and 20:42 on 2026-08-07.

A crash always self-healed: on-failure restarted the unit and podman run
recreated the container. Only a clean exit stranded it, which is why this
survived so long.

The justification for on-failure was wrong on systemd's own semantics. It
read "clean exits — e.g. operator-issued systemctl stop — stay stopped", but
Restart= is never consulted for a unit stopped via systemctl stop
(systemd.service(5)), and that is exactly how archipelago stops these apps
(prod_orchestrator -> stop_service_with_timeout). Always keeps the
stopped-stays-stopped behaviour and drops the failure mode.

Always also restores the premise of the Quadlet migration — systemd owns
supervision, so an app returns without archipelago alive to notice it left.

Checked before flipping: no manifest declares a one-shot container and there
is no manifest-level restart field, so nothing gets restart-looped.
Propagation to existing nodes is via sync_quadlet_unit's drift re-render,
which rewrites the unit and daemon-reloads WITHOUT restarting the service —
running containers are undisturbed and the new policy governs the next start.

OnFailure is kept as a deliberate opt-in with a note not to wire it back to
backends. Two tests now pin the new default and assert on-failure is absent
from a rendered backend unit.

Container suite 215/215.

NOTE FOR THE OPERATOR: this changes supervision semantics for every app on
the Quadlet canary path. Wants sign-off and a lifecycle-gate run before OTA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 07:56:08 -04:00
co-authored by Claude Opus 5
parent adc3c444cd
commit 8908fb4ff9
+42 -9
View File
@@ -73,15 +73,39 @@ pub enum NetworkMode {
Pasta,
}
/// systemd Restart= policy for the generated `.service` unit. Companions
/// use Always (any exit triggers a restart). Backends use OnFailure
/// (clean exits — e.g. operator-issued `systemctl stop` — stay stopped,
/// only crashes get restarted automatically).
/// systemd Restart= policy for the generated `.service` unit.
///
/// Everything archipelago generates uses Always. Backends used to use
/// OnFailure, justified as "clean exits — e.g. operator-issued `systemctl
/// stop` — stay stopped". That justification was wrong on systemd's own
/// semantics: `Restart=` is never consulted for a unit stopped via
/// `systemctl stop` (systemd.service(5): "the service is not restarted if it
/// is stopped with systemctl stop or an equivalent operation"), and that is
/// exactly how archipelago stops these apps
/// (`prod_orchestrator` → `stop_service_with_timeout`). So OnFailure bought
/// none of the behaviour it claimed to.
///
/// What it cost was apps vanishing. Quadlet renders `podman run … --rm`, so
/// the container is deleted the moment it stops; OnFailure then declines to
/// restart after a CLEAN exit, and bitcoind exits 0 on SIGTERM. Any clean
/// stop therefore deleted the container AND left it deleted, so the app
/// disappeared from podman and from My Apps until a later archipelago
/// reconcile tick recreated it — logged as "previously-running app has no
/// container after boot — recreating" for bitcoin-knots and electrumx
/// repeatedly on 2026-08-07, and reported by the operator as "Bitcoin Knots
/// disappeared again". A crash always self-healed; only a clean exit
/// stranded it, which is why this hid for so long.
///
/// Always also restores the premise of the Quadlet migration: systemd owns
/// supervision, so an app comes back without needing archipelago alive to
/// notice. Safe against restart-looping because no manifest declares a
/// one-shot container and there is no manifest-level restart field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartPolicy {
Always,
/// Used by `from_manifest` for backend manifests. Wired through
/// `install_via_quadlet` (gated by `Config::use_quadlet_backends`).
/// Retained as a deliberate opt-in for a unit that genuinely must stay
/// down after a clean exit. Nothing selects it today — do not wire it to
/// backends again without re-reading the note above.
OnFailure,
}
@@ -498,7 +522,9 @@ impl QuadletUnit {
read_only_root: app.security.readonly_root,
no_new_privileges: app.security.no_new_privileges,
cpu_quota: app.resources.cpu_limit,
restart_policy: RestartPolicy::OnFailure,
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
restart_policy: RestartPolicy::Always,
}
}
}
@@ -1302,7 +1328,10 @@ app:
.add_hosts
.iter()
.any(|(n, ip)| n == "host.archipelago" && ip == "10.89.0.1"));
assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
// Always, not OnFailure. Quadlet renders `--rm`, so OnFailure left a
// cleanly-exited app both deleted and unrestarted — the vanishing
// bitcoin-knots/electrumx bug. Do not relax this back.
assert_eq!(u.restart_policy, RestartPolicy::Always);
}
#[test]
@@ -1760,6 +1789,10 @@ app:
assert!(body.contains("AddHost=host.archipelago:10.89.0.1"));
assert!(body.contains("DropCapability=ALL"));
assert!(body.contains("NoNewPrivileges=true"));
assert!(body.contains("Restart=on-failure"));
// A rendered backend unit must never carry on-failure again: paired
// with quadlet's `--rm` it deletes a cleanly-stopped app and leaves it
// deleted.
assert!(body.contains("Restart=always"));
assert!(!body.contains("Restart=on-failure"));
}
}