fix(security): restart a companion whose image was rebuilt underneath it

A rebuilt image never reached a running companion. ensure_image_present
rebuilds in place under the same tag, so the quadlet body is identical,
write_if_changed reports no change, and enable_now is a no-op on a running
service — the container keeps the old layers indefinitely.

That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and
Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images
to bind loopback: correct images on disk, three-day-old containers still
running. Closing those ports needed a manual 'podman rm -f' per container,
which no other node would ever get. Compare the running container's image ID
against the built one and restart when they diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 10:17:58 -04:00
co-authored by Claude Fable 5
parent ea4c072183
commit 9b30daaf9c
@@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
}
// Start is idempotent — if already running, systemctl returns 0.
quadlet::enable_now(&unit.service_name()).await?;
// A rebuilt image does NOT reach a container that is already running.
// `ensure_image_present` rebuilds in place under the same tag, so the unit
// body is byte-identical, `write_if_changed` reports no change, and
// `enable_now` is a no-op on a running service — the container keeps the
// old layers indefinitely. That is exactly how archi-dev-box kept serving
// the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123
// rebuilt every one of those images to bind loopback: the images were
// correct on disk and the running containers were three days old
// (2026-08-05). Compare image IDs and restart when they diverge.
if let Some(running) = container_image_id(spec.name).await {
if let Some(built) = image_id(&image).await {
if running != built {
info!(
companion = spec.name,
"running container uses a stale image; restarting onto the rebuilt one"
);
quadlet::restart_service(&unit.service_name()).await?;
}
}
}
info!(companion = spec.name, "companion started");
Ok(())
}
/// Image ID a container is actually running, or `None` when it does not exist.
async fn container_image_id(name: &str) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.Image}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!id.is_empty()).then_some(id)
}
/// Current ID behind an image reference, or `None` when absent.
async fn image_id(image_ref: &str) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(["image", "inspect", image_ref, "--format", "{{.Id}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!id.is_empty()).then_some(id)
}
/// Build companion image locally if a Dockerfile exists, otherwise
/// pull from the lfg2025 registry. Returns the image ref the quadlet
/// should reference (`localhost/<base>:latest` for build, registry