diff --git a/core/archipelago/src/api/rpc/package/set_config.rs b/core/archipelago/src/api/rpc/package/set_config.rs index d7d0cca2..e728a1af 100644 --- a/core/archipelago/src/api/rpc/package/set_config.rs +++ b/core/archipelago/src/api/rpc/package/set_config.rs @@ -46,10 +46,18 @@ fn image_tag(image: &str) -> Option { async fn installed_version(app_id: &str) -> Option { let containers = get_containers_for_app(app_id).await.ok()?; // Prefer the backend container (exact id / `archy-`) over UI companions. - let name = containers - .iter() - .find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}")) - .or_else(|| containers.first())?; + // + // The fallback is deliberately narrow. It used to be `containers.first()` + // unconditionally, which for a multi-container stack reported a SIBLING's + // version as the app's own: with btcpay-server's container absent, its + // postgres dependency was first in the list, so package.versions answered + // installedVersion "15.17" against an available "2.4.2". That is not a + // cosmetic mislabel — it is the number the update decision is made from. + // + // A single-container app is unambiguous, so the fallback still covers apps + // whose container is named differently from their id. With several + // containers and no identifiable backend, "unknown" is the honest answer. + let name = select_backend_container(app_id, &containers)?; let out = tokio::process::Command::new("podman") .args(["inspect", name, "--format", "{{.ImageName}}"]) .output() @@ -74,6 +82,24 @@ async fn installed_version(app_id: &str) -> Option { Some(tag) } +/// Pick the container that represents `app_id` itself, never a stack sibling. +/// +/// See the note at the call site: an unconditional "first container" fallback +/// reported a dependency's image tag as the app's installed version. +fn select_backend_container<'a>(app_id: &str, containers: &'a [String]) -> Option<&'a str> { + if let Some(exact) = containers + .iter() + .find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}")) + { + return Some(exact.as_str()); + } + // Unambiguous only when there is nothing else it could be. + if containers.len() == 1 { + return Some(containers[0].as_str()); + } + None +} + fn is_floating_tag(tag: &str) -> bool { matches!(tag, "latest" | "stable" | "release" | "main") } @@ -295,7 +321,9 @@ impl RpcHandler { #[cfg(test)] mod tests { - use super::{image_tag, is_floating_tag, parse_bitcoind_version_output}; + use super::{ + image_tag, is_floating_tag, parse_bitcoind_version_output, select_backend_container, + }; #[test] fn floating_tag_detects_generic_channel_names() { @@ -332,6 +360,42 @@ mod tests { assert_eq!(parse_bitcoind_version_output(""), None); } + /// The BTCPay case: with btcpay-server's own container absent, its postgres + /// dependency was first in the app's container list and its tag (15.17) was + /// reported as BTCPay's installed version, against an available 2.4.2. + #[test] + fn backend_selection_never_falls_back_to_a_sibling_in_a_stack() { + let stack = vec!["archy-btcpay-db".to_string(), "archy-nbxplorer".to_string()]; + assert_eq!(select_backend_container("btcpay-server", &stack), None); + + let with_backend = vec![ + "archy-btcpay-db".to_string(), + "btcpay-server".to_string(), + ]; + assert_eq!( + select_backend_container("btcpay-server", &with_backend), + Some("btcpay-server") + ); + } + + #[test] + fn backend_selection_accepts_a_lone_differently_named_container() { + let single = vec!["immich_server".to_string()]; + assert_eq!( + select_backend_container("immich", &single), + Some("immich_server") + ); + } + + #[test] + fn backend_selection_prefers_the_archy_prefixed_name() { + let names = vec!["something-else".to_string(), "archy-nbxplorer".to_string()]; + assert_eq!( + select_backend_container("nbxplorer", &names), + Some("archy-nbxplorer") + ); + } + #[test] fn image_tag_keeps_registry_port_colon() { assert_eq!(