fix(orchestrator): durable uninstall marker for baseline apps + archival-bitcoin/version-report gaps
- mempool-api now declares dependencies:[bitcoin:archival] directly, closing a
gap where installing it standalone (a legitimate direct orchestrator-install
target) bypassed the mempool umbrella's pruning gate entirely.
- New durable user-uninstalled marker (crash_recovery.rs, mirrors user_stopped)
fixes required-baseline-app self-heal (bitcoin-knots/electrumx/lnd/mempool/
etc.) resurrecting itself after an explicit uninstall survives a restart or
reboot, since the in-memory disabled set is wiped by every load_manifests().
- installed_version() (set_config.rs) no longer trusts a floating image tag
("latest") as the reported running version -- a stale local :latest cache
reported "latest" forever regardless of what latest had moved on to. Now
falls back to asking the Bitcoin backend directly via `bitcoind --version`
when the tag is floating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
de8b2bb812
commit
5b7cd5d5d0
@@ -624,4 +624,20 @@ mod tests {
|
||||
// An id with no manifest on disk at all.
|
||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() {
|
||||
// `mempool-api` is a legitimate direct `package.install` target
|
||||
// (`uses_orchestrator_install_flow` in install.rs), reachable without
|
||||
// going through the `mempool`/`mempool-web` umbrella id that the old
|
||||
// hardcoded fallback list only recognized. It was missing from that
|
||||
// list, so installing/repairing it directly skipped the archival
|
||||
// Bitcoin gate entirely. Its manifest now declares `bitcoin:archival`
|
||||
// directly, closing the gap the manifest-driven path exists for.
|
||||
assert!(requires_unpruned_bitcoin("mempool-api"));
|
||||
assert!(manifest_declares_archival_bitcoin("mempool-api"));
|
||||
// `archy-mempool-web` has no direct Bitcoin RPC access
|
||||
// (bitcoin_integration.rpc_access: none) and correctly stays excluded.
|
||||
assert!(!requires_unpruned_bitcoin("archy-mempool-web"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,54 @@ async fn installed_version(app_id: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let image = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
image_tag(&image)
|
||||
let tag = image_tag(&image)?;
|
||||
// A floating tag (latest/stable/...) names the reference used to CREATE the
|
||||
// container, not what's actually running — podman never re-resolves it once
|
||||
// cached, so a stale local `:latest` reports "latest" even when the real
|
||||
// `latest` moved on months ago (.228, 2026-07-01: ran a 4-month-old cached
|
||||
// image while a newer one already sat locally, unused). Ask the Bitcoin
|
||||
// backends directly instead of trusting the tag literal in that case.
|
||||
if is_floating_tag(&tag) {
|
||||
if let Some(real) = bitcoind_reported_version(app_id, name).await {
|
||||
return Some(real);
|
||||
}
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
/// Best-effort: ask the running bitcoind binary for its own version, trimmed to
|
||||
/// the catalog's version-tag format (e.g. `29.3.knots20260210`, `29.2`). `None`
|
||||
/// for apps other than the Bitcoin backends (no generic way to introspect a
|
||||
/// third-party image's content version this way) or if the exec fails.
|
||||
async fn bitcoind_reported_version(app_id: &str, container_name: &str) -> Option<String> {
|
||||
if !matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
return None;
|
||||
}
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["exec", container_name, "bitcoind", "--version"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_bitcoind_version_output(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Parses e.g. "Bitcoin Knots daemon version v29.3.knots20260210\n..." or
|
||||
/// "Bitcoin Core version v29.2.0\n..." down to the version tag after `version v`.
|
||||
fn parse_bitcoind_version_output(output: &str) -> Option<String> {
|
||||
let first_line = output.lines().next()?;
|
||||
let (_, version) = first_line.rsplit_once("version v")?;
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(version.to_string())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -248,7 +295,42 @@ impl RpcHandler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::image_tag;
|
||||
use super::{image_tag, is_floating_tag, parse_bitcoind_version_output};
|
||||
|
||||
#[test]
|
||||
fn floating_tag_detects_generic_channel_names() {
|
||||
for tag in ["latest", "stable", "release", "main"] {
|
||||
assert!(is_floating_tag(tag), "{tag}");
|
||||
}
|
||||
for tag in ["29.3.knots20260508", "28.4", "v29.2.0"] {
|
||||
assert!(!is_floating_tag(tag), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_knots_version_line() {
|
||||
assert_eq!(
|
||||
parse_bitcoind_version_output(
|
||||
"Bitcoin Knots daemon version v29.3.knots20260210\nCopyright...\n"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("29.3.knots20260210")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_core_version_line() {
|
||||
assert_eq!(
|
||||
parse_bitcoind_version_output("Bitcoin Core version v29.2.0\n").as_deref(),
|
||||
Some("29.2.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_output_has_no_version_marker() {
|
||||
assert_eq!(parse_bitcoind_version_output("garbage output\n"), None);
|
||||
assert_eq!(parse_bitcoind_version_output(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_tag_keeps_registry_port_colon() {
|
||||
|
||||
Reference in New Issue
Block a user