fix(versions): stop reporting a stack sibling's version as the app's own

package.versions answered installedVersion "15.17" for btcpay-server while
offering "2.4.2" — 15.17 being its postgres dependency's tag. With BTCPay's
own container absent, installed_version fell back to `containers.first()`,
which for a multi-container stack is an arbitrary sibling.

That is the number the update decision is made from, and it is what the UI
shows next to the available version, so a nonsense pair like "installed 15.17,
available 2.4.2" is presented as a legitimate upgrade.

The fallback now only applies when there is exactly one container, which still
covers apps whose container is named differently from their id (immich_server
for immich). With several containers and no identifiable backend, the honest
answer is "unknown" rather than a guess at a sibling.

Extracted as select_backend_container so the rule is testable directly.

Tests: the BTCPay stack case, the lone differently-named container, and the
archy- prefixed preference. Full suite 1157/1157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 13:14:30 -04:00
co-authored by Claude Opus 5
parent cbfda30579
commit 37c77f17ab
@@ -46,10 +46,18 @@ fn image_tag(image: &str) -> Option<String> {
async fn installed_version(app_id: &str) -> Option<String> {
let containers = get_containers_for_app(app_id).await.ok()?;
// Prefer the backend container (exact id / `archy-<id>`) 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<String> {
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!(