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:
co-authored by
Claude Opus 4.8
parent
169ff2e2cd
commit
095a76cd20
@@ -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, ¤t).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, ¤t));
|
||||
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, ¤t)
|
||||
.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, ¤t)
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user