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>
308 lines
9.7 KiB
Rust
308 lines
9.7 KiB
Rust
//! 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);
|
|
}
|
|
}
|