diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd84a55..b65e634e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +## v1.8.16-alpha (2026-09-15) + +- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh. +- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade. +- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success. +- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed. + ## v1.8.15-alpha (2026-09-13) - Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services. diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 3c05bb26..8b9bfeed 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -378,13 +378,13 @@ { "id": "mempool", "title": "Mempool Explorer", - "version": "3.0.0", + "version": "3.3.1-archy1", "description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.", "icon": "/assets/img/app-icons/mempool.webp", "author": "Mempool", "category": "money", "tier": "core", - "dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1", + "dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1", "repoUrl": "https://github.com/mempool/mempool", "requires": [ "bitcoin-knots", diff --git a/catalog.json b/catalog.json index 898a4b60..a7bfe007 100644 --- a/catalog.json +++ b/catalog.json @@ -67,13 +67,13 @@ { "id": "mempool", "title": "Mempool Explorer", - "version": "3.0.0", + "version": "3.3.1-archy1", "description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.", "icon": "/assets/img/app-icons/mempool.webp", "author": "Mempool", "category": "money", "tier": "core", - "dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1", + "dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1", "repoUrl": "https://github.com/mempool/mempool", "requires": [ "bitcoin-knots", diff --git a/core/archipelago/src/api/rpc/package/async_lifecycle.rs b/core/archipelago/src/api/rpc/package/async_lifecycle.rs index ada96e83..65a80ce2 100644 --- a/core/archipelago/src/api/rpc/package/async_lifecycle.rs +++ b/core/archipelago/src/api/rpc/package/async_lifecycle.rs @@ -330,7 +330,7 @@ impl RpcHandler { let package_id_spawn = package_id.clone(); tokio::spawn(async move { match handler.handle_package_update(params).await { - Ok(_) => { + Ok(result) => { info!("package.update {}: complete", package_id_spawn); // Same reasoning as install: the merge_preserving_transitional // helper treats Updating as RPC-owned, so we MUST write the @@ -345,7 +345,11 @@ impl RpcHandler { set_package_state( &handler.state_manager, &package_id_spawn, - PackageState::Running, + if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") { + pre_state.clone().unwrap_or(PackageState::Running) + } else { + PackageState::Running + }, ) .await; } diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs index 2df467b6..f6454ed0 100644 --- a/core/archipelago/src/api/rpc/package/update.rs +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -19,7 +19,7 @@ use tracing::{error, info, warn}; const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); impl RpcHandler { - /// Update a package to the version pinned in image-versions.sh. + /// Update a package to the freshly verified catalog target. /// This is a manual operation — the user clicks "Update" in the UI. pub(in crate::api::rpc) async fn handle_package_update( &self, @@ -32,6 +32,21 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; validate_app_id(package_id)?; + // An Update click must not act on an hourly cache that predates the + // button. Fetch and verify first; failure leaves running containers alone. + crate::container::app_catalog::refresh_catalog(&self.config.data_dir) + .await + .context( + "Cannot check the signed app catalog; update cancelled before changing containers", + )?; + if let Some(orch) = &self.orchestrator { + // Reload even when bytes did not change: a previous reload may have + // failed after the cache was written, or another refresher wrote it. + orch.reload_manifests() + .await + .context("Cannot load current app manifests; update cancelled")?; + } + // Resolve the target image. Prefer the remote app catalog (decoupled // from the binary OTA), falling back to the image-versions.sh pin. This // is OPTIONAL for orchestrator-managed apps: the orchestrator resolves @@ -42,6 +57,22 @@ impl RpcHandler { let pinned = crate::container::app_catalog::catalog_primary_image(package_id) .or_else(|| image_versions::pinned_image_for_app(package_id)); + let targets = pinned + .as_ref() + .map(|target| self.resolve_images_to_pull(package_id, target)); + if let Some(targets) = &targets { + let installed = inspect_update_images(package_id).await?; + if !update_targets_need_change(targets, &installed)? { + install_log(&format!( + "UPDATE SKIP: {} — target versions already installed", + package_id + )) + .await; + self.clear_install_progress(package_id).await; + return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id})); + } + } + // Note: the `already updating` guard lives in `spawn_package_update` // (the async wrapper that dispatch actually routes to). By the time // this inner function runs, the wrapper has already flipped state to @@ -80,6 +111,12 @@ impl RpcHandler { if let Some(orchestrator) = self.orchestrator.as_ref() { match orchestrator.upgrade(orchestrator_app_id).await { Ok(()) => { + if let Some(targets) = &targets { + verify_update_targets( + targets, + &inspect_update_images(package_id).await?, + )?; + } self.set_install_phase(package_id, InstallPhase::WaitingHealthy) .await; if let Ok(health) = orchestrator.health(orchestrator_app_id).await { @@ -133,7 +170,8 @@ impl RpcHandler { }; // Resolve images to pull — either a stack or single container - let images_to_pull = self.resolve_images_to_pull(package_id, &pinned); + let images_to_pull = + targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned)); // Get all containers for this app let containers = get_containers_for_app(package_id).await?; @@ -324,15 +362,22 @@ impl RpcHandler { .await; if let Ok(o) = status { let state = String::from_utf8_lossy(&o.stdout).trim().to_string(); - if state == "exited" { - warn!( - "Update {}: container {} exited after recreate", - package_id, name - ); - } + anyhow::ensure!( + o.status.success() && state == "running", + "Update {}: container {} is not running after recreate", + package_id, + name + ); + } else { + anyhow::bail!( + "Update {}: cannot inspect recreated container {}", + package_id, + name + ); } } + verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?; Ok(()) } @@ -514,6 +559,98 @@ impl RpcHandler { } } +async fn inspect_update_images(package_id: &str) -> Result> { + let containers = get_containers_for_app(package_id).await?; + anyhow::ensure!( + !containers.is_empty(), + "No containers found for {}", + package_id + ); + let mut command = tokio::process::Command::new("podman"); + command.arg("inspect").args(&containers).kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .context("Timed out checking installed images")??; + anyhow::ensure!( + output.status.success(), + "Cannot inspect installed images; update cancelled" + ); + let inspected: Vec = serde_json::from_slice(&output.stdout)?; + inspected + .iter() + .map(|entry| { + let name = entry + .get("Name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?; + let image = entry + .get("ImageName") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?; + Ok((name.trim_start_matches('/').to_string(), image.to_string())) + }) + .collect() +} + +fn installed_image_for_target<'a>( + app_id: &str, + installed: &'a [(String, String)], +) -> Option<&'a str> { + installed + .iter() + .find(|(name, _)| { + candidate_app_ids_for_container(name) + .iter() + .any(|id| id == app_id) + }) + .map(|(_, image)| image.as_str()) +} + +/// A successful recreate is not proof that it used the downloaded image. +fn verify_update_targets( + targets: &[(String, String)], + installed: &[(String, String)], +) -> Result<()> { + for (app_id, target) in targets { + let running = installed_image_for_target(app_id, installed).ok_or_else(|| { + anyhow::anyhow!("Update {}: target container missing after recreate", app_id) + })?; + anyhow::ensure!( + image_versions::extract_version_from_image(target) + == image_versions::extract_version_from_image(running) + || image_versions::compare_image_versions(target, running) + == Some(std::cmp::Ordering::Equal), + "Update {}: recreated container did not reach target version {}", + app_id, + image_versions::extract_version_from_image(target) + ); + } + Ok(()) +} + +/// Check every stack component, not just the version shown on its tile. A +/// newer backend must still update when its frontend version is unchanged. +/// A stale target for any component cancels before pulling or stopping anything. +fn update_targets_need_change( + targets: &[(String, String)], + installed: &[(String, String)], +) -> Result { + use std::cmp::Ordering; + let mut changed = false; + for (app_id, target) in targets { + let running = installed_image_for_target(app_id, installed); + match running.and_then(|image| image_versions::compare_image_versions(target, image)) { + Some(Ordering::Less) => anyhow::bail!( + "Catalog target for {} is older than the installed image; refusing downgrade", + app_id + ), + Some(Ordering::Equal) => {} + Some(Ordering::Greater) | None => changed = true, + } + } + Ok(changed) +} + fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool { orchestrator_available && !uses_legacy_update_flow(package_id) } @@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str { } fn uses_legacy_update_flow(package_id: &str) -> bool { - matches!( - package_id, - // Multi-container stacks still updated via the stack-aware path. - "immich" | "penpot" | "penpot-frontend" | "indeedhub" - ) + // A primary container already at its target does not mean its backend or + // database is current. Route every mapped stack through the component flow. + !image_versions::containers_for_stack(package_id).is_empty() + || matches!( + package_id, + // Multi-container stacks still updated via the stack-aware path. + "immich" | "penpot" | "penpot-frontend" | "indeedhub" + ) } fn is_unknown_app_id_error(err: &anyhow::Error) -> bool { @@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec { "archy-bitcoin-ui" => push("bitcoin-ui"), "archy-lnd-ui" => push("lnd-ui"), "archy-electrs-ui" => push("electrs-ui"), - "mempool" => { + "mysql-mempool" => push("archy-mempool-db"), + "btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"), + "homeassistant" | "archy-homeassistant" => push("home-assistant"), + "fedimintd" => push("fedimint"), + "electrs" | "mempool-electrs" => push("electrumx"), + "mempool" | "mempool-web" => { push("archy-mempool-web"); push("mempool"); } @@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec { mod tests { use super::{ candidate_app_ids_for_container, orchestrator_update_app_id, - should_try_orchestrator_update, uses_legacy_update_flow, + should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow, + verify_update_targets, }; + #[test] + fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() { + let installed = vec![( + "mempool".into(), + "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(), + )]; + let stale = vec![( + "archy-mempool-web".into(), + "r.test/lfg2025/mempool-frontend:v3.3.1".into(), + )]; + assert!(update_targets_need_change(&stale, &installed).is_err()); + let current = vec![( + "archy-mempool-web".into(), + "r.test/chaum/mempool-frontend:v3.3.1-archy1".into(), + )]; + assert!(!update_targets_need_change(¤t, &installed).unwrap()); + let legacy = vec![( + "mempool-web".into(), + "r.test/old/mempool-frontend:v3.3.1-archy1".into(), + )]; + assert!(!update_targets_need_change(¤t, &legacy).unwrap()); + let newer = vec![( + "archy-mempool-web".into(), + "r.test/chaum/mempool-frontend:v3.3.1-archy2".into(), + )]; + assert!(update_targets_need_change(&newer, &installed).unwrap()); + } + + #[test] + fn stack_update_checks_backend_even_when_frontend_matches() { + let installed = vec![ + ("mempool".into(), "r.test/team/web:3.3.1-archy1".into()), + ("mempool-api".into(), "r.test/team/api:3.3.1".into()), + ]; + let mut targets = vec![ + ( + "archy-mempool-web".into(), + "r.test/team/web:3.3.1-archy1".into(), + ), + ("mempool-api".into(), "r.test/team/api:3.3.2".into()), + ]; + assert!(update_targets_need_change(&targets, &installed).unwrap()); + targets[0].1 = "r.test/team/web:3.3.1".into(); + assert!(update_targets_need_change(&targets, &installed).is_err()); + } + + #[test] + fn update_completion_requires_the_target_version_to_be_installed() { + let targets = vec![( + "archy-mempool-web".into(), + "r.test/chaum/mempool-frontend:v3.3.1-archy1".into(), + )]; + let mut installed = vec![( + "mempool".into(), + "r.test/lfg2025/mempool-frontend:v3.3.1".into(), + )]; + assert!(verify_update_targets(&targets, &installed).is_err()); + assert!(verify_update_targets(&targets, &[]).is_err()); + installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(); + assert!(verify_update_targets(&targets, &installed).is_ok()); + } + #[test] fn legacy_flow_for_stack_apps() { - for app in ["immich", "penpot", "indeedhub"] { + for app in [ + "immich", + "penpot", + "indeedhub", + "mempool", + "btcpay-server", + "netbird", + ] { assert!(uses_legacy_update_flow(app), "{app} should stay legacy"); } } #[test] fn orchestrator_flow_for_single_apps() { - for app in [ - "lnd", - "bitcoin-core", - "searxng", - "grafana", - "btcpay-server", - "mempool", - "fedimint", - ] { + for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] { assert!( !uses_legacy_update_flow(app), "{app} should be orchestrator-first" diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 1a820550..557f5365 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -400,7 +400,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option authoritative. - return crate::container::image_versions::available_update_for_images( + return crate::container::image_versions::available_catalog_update_for_images( &catalog_image, running_image, ); diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index 0fd991c9..3acb4b94 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap { // Match VAR="value" or VAR=value if let Some((key, val)) = parse_assignment(line) { + // Read a self-default assignment without evaluating shell code. + let default_prefix = format!("${{{key}:-"); + let val = val + .strip_prefix(&default_prefix) + .and_then(|v| v.strip_suffix('}')) + .unwrap_or(val); let expanded = val.replace("$ARCHY_REGISTRY", ®istry); if key == "ARCHY_REGISTRY" { registry = expanded.clone(); @@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option Option { - let pinned_version = extract_version_from_image(&pinned); + if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) { + return None; + } + available_catalog_update_for_images(pinned, running_image) +} + +/// A signed catalog binds the image to an app id, so a publisher namespace +/// migration must not hide a real upgrade. Baseline pins still require the +/// same repository via `available_update_for_images` above. +pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option { + let pinned_version = extract_version_from_image(pinned); if is_floating_tag(&pinned_version) { return None; } - - let running_version = extract_version_from_image(running_image); - if pinned_version == running_version { - return None; - } - - let pinned_repo = image_without_registry_or_tag(&pinned); - let running_repo = image_without_registry_or_tag(running_image); - if pinned_repo != running_repo { - return None; - } - - // Never advertise a LOWER version as an update. - // - // Everything upstream of here is a version claim that can go stale: the - // signed catalog, a legacy catalog entry with no manifest, the - // image-versions.sh baseline pin. When one lags behind what a node is - // actually running, a bare `pinned != running` check turns that staleness - // into an "Update" button that rolls the node BACKWARDS — and a rollback - // to a version withdrawn for a vulnerability is precisely the case where - // that must not happen. Observed with BTCPay: 2.4.2 installed, a stale - // 2.3.9 pin, and the UI offering "update" to the exploited release. - // - // Only suppress when both tags parse as comparable version numbers, so - // apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3) - // keep the previous behaviour rather than silently losing updates. - if let (Some(p), Some(r)) = ( - parse_version_parts(&pinned_version), - parse_version_parts(&running_version), + if matches!( + compare_image_versions(pinned, running_image), + Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal) ) { - if p < r { - return None; - } + return None; } - Some(pinned_version) } +/// Compare explicit image tags, ignoring registry and namespace. `None` means +/// unknown ordering (including floating tags), never permission to downgrade. +/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream +/// release, not a SemVer prerelease below it. +pub fn compare_image_versions(target: &str, running: &str) -> Option { + use std::cmp::Ordering; + let target = extract_version_from_image(target); + let running = extract_version_from_image(running); + if is_floating_tag(&target) || is_floating_tag(&running) { + return None; + } + let target = target.strip_prefix('v').unwrap_or(&target); + let running = running.strip_prefix('v').unwrap_or(&running); + if target == running { + return Some(Ordering::Equal); + } + let mut target_core = parse_version_parts(target)?; + let mut running_core = parse_version_parts(running)?; + while target_core.last() == Some(&0) { + target_core.pop(); + } + while running_core.last() == Some(&0) { + running_core.pop(); + } + match target_core.cmp(&running_core) { + Ordering::Equal => { + fn patch_revision(tag: &str) -> Option { + if let Some((base, revision)) = tag.rsplit_once("-archy") { + if base.chars().all(|c| c.is_ascii_digit() || c == '.') { + return revision.parse().ok(); + } + } + tag.chars() + .all(|c| c.is_ascii_digit() || c == '.') + .then_some(0) + } + Some(patch_revision(target)?.cmp(&patch_revision(running)?)) + } + order => Some(order), + } +} + /// Numeric components of a version tag, for ordering comparisons only. /// /// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`), @@ -423,6 +452,57 @@ mod tests { ); } + #[test] + fn downstream_patch_is_newer_than_upstream_and_orders_revisions() { + let upstream = "registry.test/team/mempool-frontend:v3.3.1"; + let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1"; + let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2"; + assert_eq!(available_update_for_images(upstream, patch1), None); + assert_eq!(available_update_for_images(patch1, patch2), None); + assert_eq!( + available_update_for_images(patch1, upstream), + Some("v3.3.1-archy1".into()) + ); + assert_eq!( + available_update_for_images(patch2, patch1), + Some("v3.3.1-archy2".into()) + ); + } + + #[test] + fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() { + let old = "registry.test/lfg2025/mempool-frontend:v3.3.1"; + let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1"; + assert_eq!( + available_catalog_update_for_images(patched, old), + Some("v3.3.1-archy1".into()) + ); + assert_eq!( + available_catalog_update_for_images( + patched, + "registry.test/lfg2025/mempool-frontend:v3.3.1-archy1" + ), + None + ); + assert_eq!(available_update_for_images(patched, old), None); + } + + #[test] + fn equivalent_version_spelling_does_not_offer_update() { + assert_eq!( + available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"), + None + ); + assert_eq!( + available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"), + None + ); + assert_eq!( + compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"), + None + ); + } + #[test] fn test_parse_image_versions() { let content = r#" @@ -445,6 +525,22 @@ NOT_AN_IMAGE="something" assert!(!parsed.contains_key("ARCHY_REGISTRY")); } + #[test] + fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() { + let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh")); + assert_eq!( + images["MEMPOOL_WEB_IMAGE"], + "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1" + ); + assert_eq!( + images["MEMPOOL_BACKEND_IMAGE"], + "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1" + ); + assert!(images + .values() + .all(|v| !v.contains('$') && !v.contains('}'))); + } + #[test] fn test_image_var_mapping() { assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE")); diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 8f388ee2..a1cbde21 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -4667,6 +4667,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { let lock = self.app_lock(app_id).await; let _guard = lock.lock().await; let name = compute_container_name(&lm.manifest); + let mut resolved = lm.manifest.clone(); + resolve_catalog_image(&mut resolved); + if resolved.app.container.build.is_none() { + if let Some(target) = &resolved.app.container.image { + if let Ok(running) = self.runtime.get_container_status(&name).await { + match crate::container::image_versions::compare_image_versions( + target, + &running.image, + ) { + Some(std::cmp::Ordering::Less) => anyhow::bail!( + "Refusing to downgrade {} from {} to {} during update", + app_id, + running.image, + target + ), + Some(std::cmp::Ordering::Equal) => return Ok(()), + _ => {} + } + } + } + } let _ = self.runtime.stop_container(&name).await; let _ = self.runtime.remove_container(&name).await; self.install_fresh(&lm).await @@ -5076,6 +5097,7 @@ mod tests { calls: StdMutex>, /// container_name -> ContainerState. Absence = "doesn't exist". containers: StdMutex>, + running_images: StdMutex>, /// container_name -> Podman health status. health: StdMutex>, /// image_ref -> present. Absence = "not present in local storage". @@ -5200,7 +5222,13 @@ mod tests { health, exit_code: None, started_at: None, - image: "test-image".to_string(), + image: self + .running_images + .lock() + .unwrap() + .get(name) + .cloned() + .unwrap_or_else(|| "test-image".to_string()), created: "now".to_string(), ports: vec![], lan_address: None, @@ -6771,6 +6799,41 @@ app: assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]); } + #[tokio::test] + async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() { + for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] { + let rt = Arc::new(MockRuntime::default()); + rt.set_state("update-regression", ContainerState::Running); + rt.running_images.lock().unwrap().insert( + "update-regression".into(), + "registry.test/old/mempool-frontend:v3.3.1-archy1".into(), + ); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest( + "update-regression", + &format!("registry.test/new/mempool-frontend:{target}"), + ), + PathBuf::from("/tmp/update-regression"), + ) + .await; + assert_eq!( + orch.upgrade("update-regression").await.is_err(), + should_error + ); + assert!( + !rt.calls() + .iter() + .any(|call| call.starts_with("stop_container:") + || call.starts_with("remove_container:") + || call.starts_with("pull_image:") + || call.starts_with("create_container:")), + "{:?}", + rt.calls() + ); + } + } + #[tokio::test] async fn upgrade_removes_and_reinstalls() { let rt = Arc::new(MockRuntime::default()); diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index efe42830..51455303 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -2159,20 +2159,25 @@ async fn apply_per_app_auto_updates( } } -/// After a catalog refresh that changed the cached bytes, rebuild the -/// orchestrator's manifest map so registry-shipped manifest changes take -/// effect now instead of at the next service restart. -async fn reload_manifests_if_changed( - refresh: crate::container::app_catalog::CatalogRefresh, +/// Reload after every successful refresh, including unchanged bytes: the cache +/// may have been written before a previous reload failed. Auto-updates only run +/// when the catalog and the orchestrator's manifests are ready together. +async fn reload_catalog_manifests( + _refresh: crate::container::app_catalog::CatalogRefresh, orchestrator: &Option>, -) { - if !refresh.changed { - return; - } - let Some(orch) = orchestrator else { return }; +) -> bool { + let Some(orch) = orchestrator else { + return false; + }; match orch.reload_manifests().await { - Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"), - Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"), + Ok(n) => { + info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)"); + true + } + Err(e) => { + warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}"); + false + } } } @@ -2188,7 +2193,9 @@ pub async fn run_update_scheduler( // Refresh the app catalog once at startup so per-app "update available" // badges appear without waiting for the first hourly tick. match crate::container::app_catalog::refresh_catalog(&data_dir).await { - Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await, + Ok(refresh) => { + reload_catalog_manifests(refresh, &orchestrator).await; + } Err(e) => debug!( "Update scheduler: initial app-catalog refresh failed: {}", e @@ -2204,14 +2211,22 @@ pub async fn run_update_scheduler( // previously cached catalog stays in place (origin-always-wins). // A changed catalog also reloads the orchestrator's manifest overlay so // catalog-shipped manifest fixes apply without a service restart. - match crate::container::app_catalog::refresh_catalog(&data_dir).await { - Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await, - Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e), - } + let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await { + Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await, + Err(e) => { + debug!( + "Update scheduler: app-catalog refresh failed; skipping auto-updates: {}", + e + ); + false + } + }; - // Per-app auto-update-to-latest (multi-version support). Runs every tick - // regardless of the binary-OTA schedule below; opt-in + pin-respecting. - apply_per_app_auto_updates(&orchestrator).await; + // Per-app updates require fresh, loaded manifests; a failed refresh + // may still show cached badges but must not trigger container changes. + if catalog_ready { + apply_per_app_auto_updates(&orchestrator).await; + } let state = match load_state(&data_dir).await { Ok(s) => s, diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 3c05bb26..8b9bfeed 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -378,13 +378,13 @@ { "id": "mempool", "title": "Mempool Explorer", - "version": "3.0.0", + "version": "3.3.1-archy1", "description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.", "icon": "/assets/img/app-icons/mempool.webp", "author": "Mempool", "category": "money", "tier": "core", - "dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1", + "dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1", "repoUrl": "https://github.com/mempool/mempool", "requires": [ "bitcoin-knots", diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index dd5e8ef8..a1b69862 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -362,6 +362,19 @@ init()
+ +
+
+ v1.8.16-alpha + September 15, 2026 +
+
+

App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.

+

Fixed repeated Mempool update offers: downstream -archyN patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.

+

Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.

+

Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.

+
+
@@ -371,6 +384,7 @@ init()

Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.

Added regression coverage for Cuprate install and installed-state grouping.

+

Release validation was rerun on the corrected tree before OTA and ISO publication.

diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index d402ecc5..6f763279 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -34,7 +34,7 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0" # Mempool stack MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1" # The patched frontend is published by chaum on the same trusted registry. -MEMPOOL_WEB_IMAGE="${MEMPOOL_WEB_IMAGE:-source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1}" +MEMPOOL_WEB_IMAGE="source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1" MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10" # BTCPay diff --git a/tests/release/run.sh b/tests/release/run.sh index 914dc2fd..35cfe577 100755 --- a/tests/release/run.sh +++ b/tests/release/run.sh @@ -168,7 +168,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml # 3600s leaves headroom; a warm target/ finishes in a fraction of it. stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \ cargo test --manifest-path core/Cargo.toml -p archipelago -- \ - update:: lnd container::image_versions scanner drift missing_secret collision + update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision # ── Stage 4: live node smoke ───────────────────────────────────────── if [[ $LIVE -eq 1 ]]; then