fix: netavark GLIBC mismatch in ISO, container adopt, app updates
ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41) which broke container networking on Debian 12 targets. Rootfs already installs netavark from Debian 12 repos — just configure the backend. Install RPC now adopts existing containers (from first-boot) instead of erroring on duplicates. Container scanner extracts real versions from image tags and detects available updates against pinned versions. Frontend shows update button with version info when updates are available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ use crate::data_model::{
|
||||
Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest,
|
||||
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
||||
};
|
||||
use super::image_versions;
|
||||
|
||||
pub struct DockerPackageScanner {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
@@ -149,6 +150,25 @@ impl DockerPackageScanner {
|
||||
|
||||
let tor_address = read_tor_address(&app_id).await;
|
||||
|
||||
// Extract actual version from container image tag
|
||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||
|
||||
// Check for available update by comparing running image vs pinned image
|
||||
let available_update = image_versions::pinned_image_for_app(&app_id)
|
||||
.and_then(|pinned| {
|
||||
if pinned != container.image {
|
||||
let pinned_version = image_versions::extract_version_from_image(&pinned);
|
||||
// Don't flag if both are "latest" — no meaningful diff
|
||||
if pinned_version != "latest" || running_version != "latest" {
|
||||
Some(pinned_version)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
health: container.health.clone(),
|
||||
@@ -161,7 +181,7 @@ impl DockerPackageScanner {
|
||||
manifest: Manifest {
|
||||
id: app_id.clone(),
|
||||
title: metadata.title.clone(),
|
||||
version: "1.0.0".to_string(),
|
||||
version: running_version,
|
||||
description: Description {
|
||||
short: metadata.description.clone(),
|
||||
long: metadata.description.clone(),
|
||||
@@ -188,6 +208,7 @@ impl DockerPackageScanner {
|
||||
None
|
||||
},
|
||||
},
|
||||
available_update,
|
||||
installed: Some(InstalledPackageDataEntry {
|
||||
current_dependents: HashMap::new(),
|
||||
current_dependencies: HashMap::new(),
|
||||
@@ -627,5 +648,6 @@ fn package_state_str(state: &PackageState) -> &str {
|
||||
PackageState::RestoringBackup => "restoring-backup",
|
||||
PackageState::Removing => "removing",
|
||||
PackageState::BackingUp => "backing-up",
|
||||
PackageState::Updating => "updating",
|
||||
}
|
||||
}
|
||||
|
||||
307
core/archipelago/src/container/image_versions.rs
Normal file
307
core/archipelago/src/container/image_versions.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
//! Parser for image-versions.sh — single source of truth for pinned container images.
|
||||
//!
|
||||
//! Reads the deployed file at /opt/archipelago/image-versions.sh (or the repo-local
|
||||
//! scripts/image-versions.sh as fallback) and exposes lookup functions so the container
|
||||
//! scanner can compare running images against pinned targets.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
use tracing::debug;
|
||||
|
||||
/// Cached parse result, invalidated when file mtime changes.
|
||||
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
|
||||
|
||||
struct CacheEntry {
|
||||
mtime: SystemTime,
|
||||
images: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// File search order — production path first, then repo-local for dev.
|
||||
const PATHS: &[&str] = &[
|
||||
"/opt/archipelago/image-versions.sh",
|
||||
"scripts/image-versions.sh",
|
||||
];
|
||||
|
||||
/// Parse image-versions.sh and return map of variable names to full image refs.
|
||||
/// Result is cached and only re-parsed when the file's mtime changes.
|
||||
fn load_image_versions() -> HashMap<String, String> {
|
||||
let (path, mtime) = match find_file() {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
debug!("image-versions.sh not found in any search path");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
// Check cache
|
||||
{
|
||||
let cache = CACHE.lock().unwrap();
|
||||
if let Some(ref entry) = *cache {
|
||||
if entry.mtime == mtime {
|
||||
return entry.images.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse fresh
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("Failed to read {}: {}", path, e);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
let images = parse_image_versions(&content);
|
||||
debug!("Parsed {} image versions from {}", images.len(), path);
|
||||
|
||||
// Update cache
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
*cache = Some(CacheEntry {
|
||||
mtime,
|
||||
images: images.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
images
|
||||
}
|
||||
|
||||
fn find_file() -> Option<(String, SystemTime)> {
|
||||
for p in PATHS {
|
||||
let path = Path::new(p);
|
||||
if let Ok(meta) = path.metadata() {
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
return Some((p.to_string(), mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse shell variable assignments, expanding $ARCHY_REGISTRY.
|
||||
fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
let mut vars = HashMap::new();
|
||||
let mut registry = String::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match VAR="value" or VAR=value
|
||||
if let Some((key, val)) = parse_assignment(line) {
|
||||
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||
if key == "ARCHY_REGISTRY" {
|
||||
registry = expanded.clone();
|
||||
}
|
||||
vars.insert(key.to_string(), expanded);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only *_IMAGE entries
|
||||
vars.retain(|k, _| k.ends_with("_IMAGE"));
|
||||
vars
|
||||
}
|
||||
|
||||
fn parse_assignment(line: &str) -> Option<(&str, &str)> {
|
||||
let eq = line.find('=')?;
|
||||
let key = &line[..eq];
|
||||
|
||||
// Validate key is a shell variable name
|
||||
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let val = &line[eq + 1..];
|
||||
// Strip surrounding quotes
|
||||
let val = val
|
||||
.strip_prefix('"')
|
||||
.and_then(|v| v.strip_suffix('"'))
|
||||
.unwrap_or(val);
|
||||
|
||||
Some((key, val))
|
||||
}
|
||||
|
||||
/// Map app ID (as seen by the container scanner) to image variable name.
|
||||
fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
match app_id {
|
||||
// Bitcoin stack
|
||||
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => Some("BITCOIN_KNOTS_IMAGE"),
|
||||
"lnd" => Some("LND_IMAGE"),
|
||||
"electrumx" => Some("ELECTRUMX_IMAGE"),
|
||||
"electrs" | "mempool-electrs" => Some("ELECTRUMX_IMAGE"),
|
||||
|
||||
// Mempool stack (primary = web)
|
||||
"mempool" | "mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
|
||||
// BTCPay stack (primary = server)
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => Some("BTCPAY_IMAGE"),
|
||||
|
||||
// Apps
|
||||
"homeassistant" | "home-assistant" => Some("HOMEASSISTANT_IMAGE"),
|
||||
"grafana" => Some("GRAFANA_IMAGE"),
|
||||
"uptime-kuma" => Some("UPTIME_KUMA_IMAGE"),
|
||||
"jellyfin" => Some("JELLYFIN_IMAGE"),
|
||||
"photoprism" => Some("PHOTOPRISM_IMAGE"),
|
||||
"ollama" => Some("OLLAMA_IMAGE"),
|
||||
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
|
||||
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
|
||||
"searxng" => Some("SEARXNG_IMAGE"),
|
||||
"cryptpad" => Some("CRYPTPAD_IMAGE"),
|
||||
"filebrowser" => Some("FILEBROWSER_IMAGE"),
|
||||
"nginx-proxy-manager" => Some("NPM_IMAGE"),
|
||||
"portainer" => Some("PORTAINER_IMAGE"),
|
||||
"tailscale" => Some("TAILSCALE_IMAGE"),
|
||||
|
||||
// Fedimint
|
||||
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
|
||||
"fedimint-gateway" => Some("FEDIMINT_GATEWAY_IMAGE"),
|
||||
|
||||
// Nostr / VPN
|
||||
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
|
||||
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
|
||||
"fips" => Some("FIPS_IMAGE"),
|
||||
|
||||
// Immich (primary = server)
|
||||
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
|
||||
|
||||
// Penpot (primary = frontend)
|
||||
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
|
||||
|
||||
// DWN
|
||||
"dwn" => Some("DWN_SERVER_IMAGE"),
|
||||
|
||||
// AI
|
||||
"routstr" => Some("ROUTSTR_IMAGE"),
|
||||
|
||||
// Networking
|
||||
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
|
||||
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full pinned image reference for an app ID.
|
||||
pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
|
||||
let var = image_var_for_app(app_id)?;
|
||||
let images = load_image_versions();
|
||||
images.get(var).cloned()
|
||||
}
|
||||
|
||||
/// Extract version tag from a full image reference.
|
||||
/// e.g. "80.71.235.15:3000/archipelago/lnd:v0.18.4-beta" → "v0.18.4-beta"
|
||||
/// Returns "latest" if no tag or tag is empty.
|
||||
pub fn extract_version_from_image(image: &str) -> String {
|
||||
// Split off the tag after the last colon, but only if it comes after the last slash
|
||||
// (to avoid splitting on registry port like "80.71.235.15:3000")
|
||||
if let Some(slash_pos) = image.rfind('/') {
|
||||
let after_slash = &image[slash_pos..];
|
||||
if let Some(colon_pos) = after_slash.rfind(':') {
|
||||
let tag = &after_slash[colon_pos + 1..];
|
||||
if !tag.is_empty() {
|
||||
return tag.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"latest".to_string()
|
||||
}
|
||||
|
||||
/// Container names and their image variable names for multi-container stacks.
|
||||
/// Returns empty vec for single-container apps.
|
||||
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||
match app_id {
|
||||
"mempool" | "mempool-web" => vec![
|
||||
("archy-mempool-db", "MARIADB_IMAGE"),
|
||||
("mempool-api", "MEMPOOL_BACKEND_IMAGE"),
|
||||
("archy-mempool-web", "MEMPOOL_WEB_IMAGE"),
|
||||
],
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => vec![
|
||||
("archy-btcpay-db", "BTCPAY_POSTGRES_IMAGE"),
|
||||
("archy-nbxplorer", "NBXPLORER_IMAGE"),
|
||||
("btcpay-server", "BTCPAY_IMAGE"),
|
||||
],
|
||||
"immich" | "immich_server" => vec![
|
||||
("immich_postgres", "IMMICH_POSTGRES_IMAGE"),
|
||||
("immich_redis", "REDIS_IMAGE"),
|
||||
("immich_server", "IMMICH_SERVER_IMAGE"),
|
||||
],
|
||||
"penpot" | "penpot-frontend" => vec![
|
||||
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
|
||||
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
|
||||
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
|
||||
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
|
||||
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pinned images for a stack update. Returns vec of (container_name, full_image_ref).
|
||||
pub fn pinned_images_for_stack(app_id: &str) -> Vec<(String, String)> {
|
||||
let images = load_image_versions();
|
||||
containers_for_stack(app_id)
|
||||
.into_iter()
|
||||
.filter_map(|(name, var)| {
|
||||
images.get(var).map(|img| (name.to_string(), img.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
assert_eq!(
|
||||
extract_version_from_image("80.71.235.15:3000/archipelago/lnd:v0.18.4-beta"),
|
||||
"v0.18.4-beta"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_version_from_image("80.71.235.15:3000/archipelago/grafana:10.2.0"),
|
||||
"10.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_version_from_image("localhost/myapp:latest"),
|
||||
"latest"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_version_from_image("80.71.235.15:3000/archipelago/bitcoin-knots:latest"),
|
||||
"latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_image_versions() {
|
||||
let content = r#"
|
||||
ARCHY_REGISTRY="80.71.235.15:3000/archipelago"
|
||||
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
|
||||
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
||||
# comment
|
||||
NOT_AN_IMAGE="something"
|
||||
"#;
|
||||
let parsed = parse_image_versions(content);
|
||||
assert_eq!(
|
||||
parsed.get("LND_IMAGE"),
|
||||
Some(&"80.71.235.15:3000/archipelago/lnd:v0.18.4-beta".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("GRAFANA_IMAGE"),
|
||||
Some(&"80.71.235.15:3000/archipelago/grafana:10.2.0".to_string())
|
||||
);
|
||||
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
|
||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_var_mapping() {
|
||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||
assert_eq!(image_var_for_app("bitcoin-knots"), Some("BITCOIN_KNOTS_IMAGE"));
|
||||
assert_eq!(image_var_for_app("unknown-app"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod image_versions;
|
||||
|
||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||
pub use docker_packages::DockerPackageScanner;
|
||||
|
||||
Reference in New Issue
Block a user