Make each peer file card a flex column filling its grid cell (flex flex-col h-full) and pin the body row (filename + Play/Download) with mt-auto, so cards with a media preview and cards without line their footers up across the row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
421 lines
14 KiB
Rust
421 lines
14 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", ®istry);
|
|
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"),
|
|
"netbird" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
|
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
|
"netbird-server" => Some("NETBIRD_SERVER_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"),
|
|
|
|
// 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()
|
|
}
|
|
|
|
/// Return the pinned tag only when the running image is genuinely behind.
|
|
/// Registry host changes alone are not app updates, and floating tags are not
|
|
/// explicit versions we should advertise to users as available updates.
|
|
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
|
let pinned = pinned_image_for_app(app_id)?;
|
|
available_update_for_images(&pinned, running_image)
|
|
}
|
|
|
|
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
|
let pinned_version = extract_version_from_image(&pinned);
|
|
if is_floating_tag(&pinned_version) {
|
|
return None;
|
|
}
|
|
|
|
let running_version = extract_version_from_image(running_image);
|
|
if pinned_version == running_version {
|
|
return None;
|
|
}
|
|
|
|
let pinned_repo = image_without_registry_or_tag(&pinned);
|
|
let running_repo = image_without_registry_or_tag(running_image);
|
|
if pinned_repo != running_repo {
|
|
return None;
|
|
}
|
|
|
|
Some(pinned_version)
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
fn is_floating_tag(tag: &str) -> bool {
|
|
matches!(tag, "latest" | "stable" | "release" | "main")
|
|
}
|
|
|
|
pub fn image_without_registry_or_tag(image: &str) -> &str {
|
|
let without_tag = strip_tag(image);
|
|
match without_tag.split_once('/') {
|
|
Some((first, rest))
|
|
if first.contains('.') || first.contains(':') || first == "localhost" =>
|
|
{
|
|
rest
|
|
}
|
|
_ => without_tag,
|
|
}
|
|
}
|
|
|
|
fn strip_tag(image: &str) -> &str {
|
|
if let Some(slash_pos) = image.rfind('/') {
|
|
let after_slash = &image[slash_pos..];
|
|
if let Some(colon_pos) = after_slash.rfind(':') {
|
|
return &image[..slash_pos + colon_pos];
|
|
}
|
|
}
|
|
image
|
|
}
|
|
|
|
/// 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"),
|
|
],
|
|
"netbird" => vec![
|
|
("netbird", "NETBIRD_PROXY_IMAGE"),
|
|
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
|
|
("netbird-server", "NETBIRD_SERVER_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 strips_registry_and_tag_for_image_identity() {
|
|
assert_eq!(
|
|
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
|
|
"lfg2025/lnd"
|
|
);
|
|
assert_eq!(
|
|
image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
|
|
"lfg2025/lnd"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn floating_tags_are_not_explicit_updates() {
|
|
assert!(is_floating_tag("latest"));
|
|
assert!(is_floating_tag("stable"));
|
|
assert!(!is_floating_tag("v0.18.4-beta"));
|
|
}
|
|
|
|
#[test]
|
|
fn available_update_ignores_registry_only_changes() {
|
|
assert_eq!(
|
|
available_update_for_images(
|
|
"146.59.87.168:3000/lfg2025/nextcloud:29",
|
|
"git.tx1138.com/lfg2025/nextcloud:29",
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn available_update_returns_pinned_version_for_same_repo_newer_tag() {
|
|
assert_eq!(
|
|
available_update_for_images(
|
|
"146.59.87.168:3000/lfg2025/nextcloud:29",
|
|
"146.59.87.168:3000/lfg2025/nextcloud:28",
|
|
),
|
|
Some("29".to_string())
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|