archy/core/archipelago/src/container/image_versions.rs

321 lines
11 KiB
Rust

//! Parser for image-versions.sh — single source of truth for pinned container images.
//!
//! Reads the deployed file at /opt/archipelago/scripts/image-versions.sh (the canonical
//! location installed by the image-recipe) with fallbacks for older layouts and the
//! repo-local scripts/image-versions.sh for development runs from the repo root.
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 — canonical production path first, older layout second,
/// repo-local for dev last. The canonical deployed path is
/// /opt/archipelago/scripts/image-versions.sh; earlier builds put it directly
/// in /opt/archipelago/, so that path is kept as a fallback for not-yet-updated
/// nodes. The repo-relative entry matches `cargo run` from the repo root.
const PATHS: &[&str] = &[
"/opt/archipelago/scripts/image-versions.sh",
"/opt/archipelago/image-versions.sh",
"/home/archipelago/Projects/archy/scripts/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", &registry);
if key == "ARCHY_REGISTRY" {
registry = expanded.clone();
}
vars.insert(key.to_string(), expanded);
}
}
// Keep only *_IMAGE entries whose value looks like a container image
// reference (contains a `:` tag separator and at least one `/` path
// component). Rejects placeholder values like "something" so a
// hand-edit typo in image-versions.sh never gets treated as an image.
vars.retain(|k, v| k.ends_with("_IMAGE") && v.contains(':') && v.contains('/'));
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"),
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
// Mempool stack (primary = web)
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
// BTCPay stack (primary = server)
"btcpay" | "btcpay-server" | "btcpayserver" | "archy-btcpay-ui" => 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. "git.tx1138.com/lfg2025/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 "registry.example.com: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("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
"v0.18.4-beta"
);
assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/grafana:10.2.0"),
"10.2.0"
);
assert_eq!(
extract_version_from_image("localhost/myapp:latest"),
"latest"
);
assert_eq!(
extract_version_from_image("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
"latest"
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
ARCHY_REGISTRY="git.tx1138.com/lfg2025"
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(&"git.tx1138.com/lfg2025/lnd:v0.18.4-beta".to_string())
);
assert_eq!(
parsed.get("GRAFANA_IMAGE"),
Some(&"git.tx1138.com/lfg2025/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);
}
}