fix(bitcoin): bulletproof multi-version switching (Knots & Core)

Three stacked bugs made "switch version" silently fail / crash-loop, and
the data-access mismatch corrupted a node's index during recovery attempts.

Backend renderer:
- sync_quadlet_unit ignored the per-app pinned version and re-rendered the
  quadlet with the manifest's :latest every reconcile tick, reverting any
  switch. Factor the install-time catalog/pin resolution into a shared
  resolve_catalog_image() and call it in BOTH install_fresh and
  sync_quadlet_unit.
- The renderer folded manifest `entrypoint: ["sh","-lc"]` into Exec=, which
  only worked when the image entrypoint was a passthrough shell wrapper. The
  versioned images use ENTRYPOINT ["bitcoind"], so Exec=sh -lc ... became
  `bitcoind sh -lc ...` and crash-looped. Emit a real Entrypoint= override;
  exec_changed now also compares Entrypoint=.

Images:
- Build all bitcoin images (Core + Knots, every version) as container-root
  (USER removed) like the legacy :latest image. Chain data is owned by the
  data_uid (container uid 102); root reads it via CAP_DAC_OVERRIDE (granted in
  the manifest). A non-root USER (the previous uid 1000) can't read existing
  chain data → "Error initializing block database". Still fully rootless:
  container-root maps to the unprivileged host service user.

Catalog:
- bitcoin-knots versions[]: 29.3.knots20260508/20260507/20260210 +
  29.2.knots20251110, "latest" tracking newest.
- bitcoin-core versions[]: add 29.2 + a "latest" entry. All images rebuilt
  root and published to the mirror.

Frontend:
- AppSidebar version dropdown: rename the latest option to "Always use the
  latest version" (no v prefix), fix right padding, and guarantee the current
  selection matches a real option (was rendering blank).
- New InstallVersionModal: full-screen version chooser shown from the App
  Store / Discover install button for multi-version apps (Bitcoin Knots/Core),
  app icon + "Install <name>", latest pre-selected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-29 05:46:04 -04:00
co-authored by Claude Opus 4.8
parent 169ff2e2cd
commit 095a76cd20
10 changed files with 314 additions and 74 deletions
@@ -929,6 +929,51 @@ pub struct AdoptionReport {
pub adopted: Vec<String>,
}
/// Decouple the app image from the shipped manifest: prefer the remote app
/// catalog when it covers this app with a same-repo image, and let a
/// runner-pinned version win over the catalog default so "install/switch to
/// the version I chose" is honored across every install + recreate. Falls
/// through to the catalog default when unpinned or the pin can't be resolved
/// (unknown/repo-mismatch), and to the manifest image when the catalog is
/// absent/uncovered.
///
/// Applied in-place to a cloned manifest. MUST run anywhere a quadlet is
/// rendered — both install_fresh (pull + create) and sync_quadlet_unit (the
/// reconciler's drift re-render); otherwise the reconciler rewrites the unit
/// back to the manifest's shipped `:latest` tag and silently reverts a pinned
/// version on the next tick.
fn resolve_catalog_image(resolved_manifest: &mut AppManifest) {
let Some(current) = resolved_manifest.app.container.image.clone() else {
return;
};
let app_id = resolved_manifest.app.id.clone();
let resolved_image = crate::container::version_config::pinned_version(&app_id)
.and_then(|v| {
crate::container::app_catalog::catalog_image_for_version(&app_id, &v, &current).or_else(
|| {
tracing::warn!(
app_id = %app_id,
pinned = %v,
"version_config: pinned version not resolvable in catalog — using default"
);
None
},
)
})
.or_else(|| crate::container::app_catalog::catalog_image_override(&app_id, &current));
if let Some(catalog_image) = resolved_image {
if catalog_image != current {
tracing::info!(
app_id = %app_id,
from = %current,
to = %catalog_image,
"app-catalog: overriding manifest image"
);
resolved_manifest.app.container.image = Some(catalog_image);
}
}
}
/// Internal: track a manifest together with the absolute directory it was loaded
/// from, so Build sources can resolve relative `context:` paths.
#[derive(Debug, Clone)]
@@ -1809,45 +1854,7 @@ impl ProdContainerOrchestrator {
self.ensure_app_secrets(&lm.manifest.app.id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
// Decouple the app image from the shipped manifest: prefer the remote
// app catalog when it covers this app with a same-repo image. This makes
// both the pull below and create_container() below use the catalog tag,
// so an app update no longer requires a binary/runtime release. Falls
// back to the manifest image when the catalog is absent/uncovered.
if let Some(current) = resolved_manifest.app.container.image.clone() {
let app_id = resolved_manifest.app.id.clone();
// Multi-version support: a runner-pinned version wins over the catalog
// default so "install/switch to the version I chose" is honored across
// every install + recreate. Falls through to the catalog default when
// unpinned or the pin can't be resolved (unknown/repo-mismatch).
let resolved_image = crate::container::version_config::pinned_version(&app_id)
.and_then(|v| {
crate::container::app_catalog::catalog_image_for_version(&app_id, &v, &current)
.or_else(|| {
tracing::warn!(
app_id = %app_id,
pinned = %v,
"version_config: pinned version not resolvable in catalog — using default"
);
None
})
})
.or_else(|| {
crate::container::app_catalog::catalog_image_override(&app_id, &current)
});
if let Some(catalog_image) = resolved_image {
if catalog_image != current {
tracing::info!(
app_id = %app_id,
from = %current,
to = %catalog_image,
"app-catalog: overriding manifest image"
);
resolved_manifest.app.container.image = Some(catalog_image);
}
}
}
resolve_catalog_image(&mut resolved_manifest);
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
anyhow::anyhow!(
@@ -2206,6 +2213,10 @@ impl ProdContainerOrchestrator {
let mut resolved = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved)?;
// Same catalog/pinned-version image resolution the installer applies, so
// the drift re-render doesn't revert a pinned version back to the
// manifest's shipped `:latest` tag on the next reconcile tick.
resolve_catalog_image(&mut resolved);
let unit = quadlet::QuadletUnit::from_manifest(&resolved, name);
let new_body = unit.render();
let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body);
+23 -11
View File
@@ -268,14 +268,21 @@ impl QuadletUnit {
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
let _ = writeln!(s, "HealthRetries={}", h.retries);
}
if let Some(ep) = &self.entrypoint {
// Quadlet's Exec= replaces the image entrypoint+cmd. When
// the manifest provides both entrypoint and command we
// concatenate; if only command is set we'll emit that on
// its own below.
let mut parts: Vec<String> = ep.clone();
if let Some((first, rest)) = self.entrypoint.as_deref().and_then(<[String]>::split_first) {
// Quadlet's Exec= sets only the command (the args passed to the
// image's ENTRYPOINT) — it does NOT replace the entrypoint. So a
// manifest entrypoint like `sh -lc` must be emitted as a real
// Entrypoint= override; otherwise it gets appended to whatever
// ENTRYPOINT the image baked in (e.g. the versioned bitcoind
// images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper
// into `bitcoind sh -lc ...` and crash-looped). Emitting
// Entrypoint= makes the unit independent of the image's entrypoint.
let _ = writeln!(s, "Entrypoint={first}");
let mut parts: Vec<String> = rest.to_vec();
parts.extend(self.command.iter().cloned());
let _ = writeln!(s, "Exec={}", shell_join(&parts));
if !parts.is_empty() {
let _ = writeln!(s, "Exec={}", shell_join(&parts));
}
} else if !self.command.is_empty() {
let _ = writeln!(s, "Exec={}", shell_join(&self.command));
}
@@ -769,9 +776,11 @@ pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
}
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
let old_exec = directive_values(old_body, "Exec=");
let new_exec = directive_values(new_body, "Exec=");
old_exec != new_exec
// Entrypoint= and Exec= together define what the container runs, so a drift
// in either must recreate the container (e.g. when this renderer first
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`).
directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|| directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
}
fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
@@ -1063,7 +1072,10 @@ mod tests {
assert!(s.contains("ReadOnly=true"));
assert!(s.contains("NoNewPrivileges=true"));
assert!(s.contains("PodmanArgs=--cpus=2"));
assert!(s.contains("Exec=/usr/local/bin/bitcoind -server=1 -rpcbind=0.0.0.0"));
// Manifest entrypoint becomes a real Entrypoint= override (not folded
// into Exec=), so the unit doesn't depend on the image's own ENTRYPOINT.
assert!(s.contains("Entrypoint=/usr/local/bin/bitcoind"));
assert!(s.contains("Exec=-server=1 -rpcbind=0.0.0.0"));
assert!(s.contains("Restart=on-failure"));
assert!(s.contains("Network=archy-net"));
}