Merge branch 'bitcoin-version-bulletproof' into mesh-multiversion-integration

This commit is contained in:
archipelago
2026-06-29 06:45:50 -04:00
13 changed files with 478 additions and 76 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"));
}