fix(container): an absent container is not proof of uninstallation

installed_app_ids judged installation on live containers alone. Watched on
archi-dev-box within the hour: lnd read as ABSENT, then as EXISTS again.
Containers on this node come and go — the boot reconciler logs
"previously-running app has no container after boot — recreating" for
bitcoin-knots and electrumx repeatedly — so a momentary gap looked exactly
like a removal, and the reaper would have taken a healthy companion's unit
with it. ORPHAN_GRACE narrows that window but cannot close it: nothing
bounds how long a gap lasts.

An app now counts as installed if its container exists in any state OR its
container name is in the durable last-running snapshot. That snapshot is
what crash_recovery itself calls "installation evidence" and what
reconcile_all_with_mode already trusts to recreate a previously-running app
whose container vanished — the same signal, for the same reason, now shared
rather than reinvented.

Only fedimint is a true orphan on this box: it appears in no adoption list
and has no quadlet unit of its own. lnd is installed and merely flapping,
which is a separate bug.

Container suite 215/215.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 07:44:05 -04:00
co-authored by Claude Opus 5
parent 7567c25c00
commit adc3c444cd
@@ -1686,14 +1686,27 @@ impl ProdContainerOrchestrator {
/// from `Some(vec![])`: a caller that removes things on absence must not
/// treat "I could not look" as "nothing is installed".
///
/// The presence of a container in ANY state — is the whole test. It
/// deliberately does NOT carry over the `user_stopped` / `disabled`
/// filters the old `manifest_ids` applied: a stopped app is still an
/// installed app, its container still
/// exists (exited), and treating it as uninstalled would make stopping an
/// app tear its companion down and rebuild it on the next start. "Is it
/// installed" and "is it currently meant to be running" are different
/// questions, and only the first one belongs here.
/// An app counts as installed if its container exists in ANY state, OR its
/// container name appears in the durable last-running snapshot.
///
/// Both halves are load-bearing. The snapshot is what `crash_recovery`
/// itself calls "installation evidence" and what `reconcile_all_with_mode`
/// already trusts to recreate a previously-running app whose container
/// vanished. It is needed here because a container's absence is NOT proof
/// of uninstallation: containers on this node demonstrably come and go —
/// `lnd` read as absent and then present again within the hour, and the
/// boot reconciler logs "previously-running app has no container after
/// boot — recreating" for bitcoin-knots and electrumx repeatedly. Judging
/// on live containers alone would let a momentary gap look like a removal
/// and cost a healthy companion its unit.
///
/// This also deliberately does NOT carry over the `user_stopped` /
/// `disabled` filters the old `manifest_ids` applied: a stopped app is
/// still an installed app, its container still exists (exited), and
/// treating it as uninstalled would make stopping an app tear its
/// companion down and rebuild it on the next start. "Is it installed" and
/// "is it currently meant to be running" are different questions, and only
/// the first one belongs here.
pub async fn installed_app_ids(&self) -> Option<Vec<String>> {
let present: std::collections::HashSet<String> = self
.runtime
@@ -1703,12 +1716,16 @@ impl ProdContainerOrchestrator {
.into_iter()
.map(|c| c.name)
.collect();
let was_running = crate::crash_recovery::load_last_running_names(&self.data_dir).await;
let state = self.state.read().await;
Some(
state
.manifests
.iter()
.filter(|(_, lm)| present.contains(&compute_container_name(&lm.manifest)))
.filter(|(_, lm)| {
let container = compute_container_name(&lm.manifest);
present.contains(&container) || was_running.contains(&container)
})
.map(|(app_id, _)| app_id.clone())
.collect(),
)