diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 08e558cb..cf149d5c 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -45,6 +45,7 @@ impl RpcHandler { "package.stop" => self.handle_package_stop(params).await, "package.restart" => self.handle_package_restart(params).await, "package.uninstall" => self.handle_package_uninstall(params).await, + "package.update" => self.handle_package_update(params).await, "app.filebrowser-token" => self.handle_filebrowser_token().await, // Bundled app management (for pre-loaded container images) diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 9a437bd1..7198d056 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -98,10 +98,39 @@ impl RpcHandler { .trim() .is_empty() { - return Err(anyhow::anyhow!( - "Container {} already exists. Stop and remove it first.", - package_id - )); + // Container already exists (e.g. created by first-boot) — adopt it + info!("Container {} already exists, adopting as installed", package_id); + install_log(&format!("INSTALL ADOPT: {} — container already exists", package_id)).await; + + // Check container state + let state_output = tokio::process::Command::new("podman") + .args(["inspect", package_id, "--format", "{{.State.Status}}"]) + .output() + .await + .context("Failed to inspect existing container")?; + let state = String::from_utf8_lossy(&state_output.stdout).trim().to_string(); + + if state != "running" { + // Start the stopped/exited container + info!("Starting existing container {} (was {})", package_id, state); + let start_output = tokio::process::Command::new("podman") + .args(["start", package_id]) + .output() + .await + .context("Failed to start existing container")?; + if !start_output.status.success() { + let stderr = String::from_utf8_lossy(&start_output.stderr); + install_log(&format!("INSTALL ADOPT FAIL: {} — start failed: {}", package_id, stderr)).await; + return Err(anyhow::anyhow!("Container {} exists but failed to start: {}", package_id, stderr)); + } + } + + install_log(&format!("INSTALL ADOPT OK: {} — already running", package_id)).await; + return Ok(serde_json::json!({ + "success": true, + "package_id": package_id, + "message": format!("Package {} already installed and running", package_id) + })); } // Pull or verify image diff --git a/core/archipelago/src/api/rpc/package/mod.rs b/core/archipelago/src/api/rpc/package/mod.rs index 7c2ac27c..5052e1a4 100644 --- a/core/archipelago/src/api/rpc/package/mod.rs +++ b/core/archipelago/src/api/rpc/package/mod.rs @@ -5,6 +5,7 @@ mod lifecycle; mod progress; mod runtime; mod stacks; +mod update; mod validation; // Re-export items needed by sibling modules (container.rs, security.rs) diff --git a/core/archipelago/src/api/rpc/package/progress.rs b/core/archipelago/src/api/rpc/package/progress.rs index ebfe964d..a78dc843 100644 --- a/core/archipelago/src/api/rpc/package/progress.rs +++ b/core/archipelago/src/api/rpc/package/progress.rs @@ -86,6 +86,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry { }, installed: None, install_progress: None, + available_update: None, } } diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index e27a0c1c..07fb72aa 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -7,6 +7,48 @@ use crate::api::rpc::RpcHandler; use anyhow::{Context, Result}; use tracing::info; +use super::install::install_log; + +/// Adopt an existing container stack: start all named containers and return success. +/// Returns `Ok(Some(json))` if the primary container was found (adopted), +/// or `Ok(None)` if it was not found (proceed with fresh install). +async fn adopt_stack_if_exists( + primary_container: &str, + stack_name: &str, + all_containers: &[&str], +) -> Result> { + let check = tokio::process::Command::new("podman") + .args(["ps", "-a", "--format", "{{.Names}}"]) + .output() + .await + .context("Failed to list containers")?; + let stdout = String::from_utf8_lossy(&check.stdout); + let names: Vec<&str> = stdout.lines().map(|l| l.trim()).collect(); + + if !names.iter().any(|n| *n == primary_container) { + return Ok(None); + } + + info!("{} stack already exists (found {}), adopting", stack_name, primary_container); + install_log(&format!("INSTALL ADOPT: {} — stack already exists", stack_name)).await; + + for container in all_containers { + if names.iter().any(|n| n == container) { + let _ = tokio::process::Command::new("podman") + .args(["start", container]) + .output() + .await; + } + } + + install_log(&format!("INSTALL ADOPT OK: {} — started existing containers", stack_name)).await; + Ok(Some(serde_json::json!({ + "success": true, + "package_id": stack_name, + "message": format!("{} already installed and running", stack_name) + }))) +} + const REGISTRY: &str = "80.71.235.15:3000/archipelago"; /// Pull an image with retry and exponential backoff (3 attempts). @@ -47,17 +89,21 @@ async fn pull_image_with_retry(image: &str) -> Result<()> { impl RpcHandler { /// Install Immich stack (postgres + redis + server). pub(super) async fn install_immich_stack(&self) -> Result { + if let Some(adopted) = adopt_stack_if_exists( + "immich_server", + "immich", + &["immich_postgres", "immich_redis", "immich_server"], + ).await? { + return Ok(adopted); + } + + // Clean up stale "immich" container (old naming) before fresh install let check = tokio::process::Command::new("podman") .args(["ps", "-a", "--format", "{{.Names}}"]) .output() .await .context("Failed to list containers")?; let stdout = String::from_utf8_lossy(&check.stdout); - if stdout.contains("immich_server") { - return Err(anyhow::anyhow!( - "Immich already installed. Stop and remove it first." - )); - } if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") { let _ = tokio::process::Command::new("podman") .args(["stop", "immich"]) @@ -182,16 +228,12 @@ impl RpcHandler { /// Install Penpot stack (postgres + valkey + backend + exporter + frontend). pub(super) async fn install_penpot_stack(&self) -> Result { - let check = tokio::process::Command::new("podman") - .args(["ps", "-a", "--format", "{{.Names}}"]) - .output() - .await - .context("Failed to list containers")?; - let stdout = String::from_utf8_lossy(&check.stdout); - if stdout.contains("penpot-frontend") { - return Err(anyhow::anyhow!( - "Penpot already installed. Stop and remove it first." - )); + if let Some(adopted) = adopt_stack_if_exists( + "penpot-frontend", + "penpot", + &["penpot-postgres", "penpot-valkey", "penpot-backend", "penpot-exporter", "penpot-frontend"], + ).await? { + return Ok(adopted); } let images = [ @@ -366,18 +408,12 @@ impl RpcHandler { /// Install BTCPay stack (postgres + nbxplorer + btcpay-server). pub(super) async fn install_btcpay_stack(&self) -> Result { - use super::install::install_log; - - let check = tokio::process::Command::new("podman") - .args(["ps", "-a", "--format", "{{.Names}}"]) - .output() - .await - .context("Failed to list containers")?; - let stdout = String::from_utf8_lossy(&check.stdout); - if stdout.lines().any(|l| l.trim() == "btcpay-server") { - return Err(anyhow::anyhow!( - "BTCPay already installed. Stop and remove it first." - )); + if let Some(adopted) = adopt_stack_if_exists( + "btcpay-server", + "btcpay", + &["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"], + ).await? { + return Ok(adopted); } // Dependency check: Bitcoin must be running @@ -534,18 +570,12 @@ impl RpcHandler { /// Install Mempool stack (mariadb + mempool-api + mempool-web). pub(super) async fn install_mempool_stack(&self) -> Result { - use super::install::install_log; - - let check = tokio::process::Command::new("podman") - .args(["ps", "-a", "--format", "{{.Names}}"]) - .output() - .await - .context("Failed to list containers")?; - let stdout = String::from_utf8_lossy(&check.stdout); - if stdout.lines().any(|l| l.trim() == "mempool" || l.trim() == "archy-mempool-web") { - return Err(anyhow::anyhow!( - "Mempool already installed. Stop and remove it first." - )); + if let Some(adopted) = adopt_stack_if_exists( + "archy-mempool-web", + "mempool", + &["archy-mempool-db", "archy-mempool-api", "archy-mempool-web"], + ).await? { + return Ok(adopted); } // Dependency check: Bitcoin + ElectrumX must be running diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs new file mode 100644 index 00000000..198fbc53 --- /dev/null +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -0,0 +1,291 @@ +//! Per-app manual update handler. +//! +//! Flow: validate → set Updating state → graceful stop → pull new image(s) → +//! remove old container(s) → recreate via reconcile script → verify running. +//! Data volumes are preserved (bind mounts, not stored in container). + +use super::config::get_containers_for_app; +use super::install::install_log; +use super::progress::parse_pull_progress; +use super::runtime::stop_timeout_secs; +use super::validation::validate_app_id; +use crate::api::rpc::RpcHandler; +use crate::container::image_versions; +use crate::data_model::PackageState; +use anyhow::{Context, Result}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tracing::{error, info, warn}; + +impl RpcHandler { + /// Update a package to the version pinned in image-versions.sh. + /// This is a manual operation — the user clicks "Update" in the UI. + pub(in crate::api::rpc) async fn handle_package_update( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let package_id = params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; + validate_app_id(package_id)?; + + // Verify an update is actually available + let pinned = image_versions::pinned_image_for_app(package_id) + .ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?; + + // Reject if already updating + { + let (data, _) = self.state_manager.get_snapshot().await; + if let Some(entry) = data.package_data.get(package_id) { + if entry.state == PackageState::Updating { + return Err(anyhow::anyhow!("{} is already updating", package_id)); + } + } + } + + install_log(&format!("UPDATE: {} → {}", package_id, pinned)).await; + + // Set state to Updating + { + let (mut data, _) = self.state_manager.get_snapshot().await; + if let Some(entry) = data.package_data.get_mut(package_id) { + entry.state = PackageState::Updating; + entry.available_update = None; + } + self.state_manager.update_data(data).await; + } + + // Resolve images to pull — either a stack or single container + let images_to_pull = self.resolve_images_to_pull(package_id, &pinned); + + // Get all containers for this app + let containers = get_containers_for_app(package_id).await?; + if containers.is_empty() { + self.clear_update_state(package_id).await; + return Err(anyhow::anyhow!("No containers found for {}", package_id)); + } + + // Execute update — on failure, attempt rollback by restarting old containers + match self + .execute_update(package_id, &containers, &images_to_pull) + .await + { + Ok(()) => { + install_log(&format!("UPDATE OK: {}", package_id)).await; + self.clear_install_progress(package_id).await; + Ok(serde_json::json!({ + "status": "updated", + "package_id": package_id, + })) + } + Err(e) => { + error!("Update {} failed: {}. Attempting rollback.", package_id, e); + install_log(&format!("UPDATE FAIL: {} — {}. Rolling back.", package_id, e)) + .await; + self.rollback_update(package_id, &containers).await; + self.clear_install_progress(package_id).await; + self.clear_update_state(package_id).await; + Err(e.context(format!("Update {} failed, rolled back", package_id))) + } + } + } + + /// Core update execution: stop → pull → remove → recreate → verify. + async fn execute_update( + &self, + package_id: &str, + containers: &[String], + images_to_pull: &[(String, String)], + ) -> Result<()> { + // 1. Graceful stop all containers (reverse order for dependencies) + info!("Update {}: stopping {} containers", package_id, containers.len()); + for name in containers.iter().rev() { + let timeout = stop_timeout_secs(name); + info!("Update {}: stopping {} (timeout: {}s)", package_id, name, timeout); + let out = tokio::process::Command::new("podman") + .args(["stop", "-t", timeout, name]) + .output() + .await + .context(format!("Failed to stop {}", name))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + warn!("Update {}: stop {} failed: {}", package_id, name, stderr.trim()); + // Continue — container might already be stopped + } + } + + // 2. Pull new images with progress + info!("Update {}: pulling {} images", package_id, images_to_pull.len()); + for (i, (name, image)) in images_to_pull.iter().enumerate() { + info!("Update {}: pulling image {}/{} ({})", package_id, i + 1, images_to_pull.len(), image); + self.pull_update_image(package_id, image).await + .context(format!("Failed to pull {} for {}", image, name))?; + } + + // 3. Remove old containers + info!("Update {}: removing old containers", package_id); + for name in containers { + let out = tokio::process::Command::new("podman") + .args(["rm", name]) + .output() + .await + .context(format!("Failed to remove {}", name))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + // Force remove as fallback + warn!("Update {}: rm {} failed ({}), forcing", package_id, name, stderr.trim()); + let _ = tokio::process::Command::new("podman") + .args(["rm", "-f", name]) + .output() + .await; + } + } + + // 4. Recreate via reconcile script (single source of truth for container specs) + info!("Update {}: recreating containers via reconcile", package_id); + for name in containers { + let out = tokio::process::Command::new("bash") + .args([ + "/opt/archipelago/scripts/reconcile-containers.sh", + &format!("--container={}", name), + "--force", + ]) + .output() + .await + .context(format!("Failed to reconcile {}", name))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stdout = String::from_utf8_lossy(&out.stdout); + error!( + "Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}", + package_id, name, stdout.trim(), stderr.trim() + ); + return Err(anyhow::anyhow!( + "Reconcile failed for {}: {}", + name, + stderr.trim() + )); + } + // Brief delay between containers for dependency initialization + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + // 5. Verify containers reached running state + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + for name in containers { + let status = tokio::process::Command::new("podman") + .args(["inspect", name, "--format", "{{.State.Status}}"]) + .output() + .await; + if let Ok(o) = status { + let state = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if state == "exited" { + warn!("Update {}: container {} exited after recreate", package_id, name); + } + } + } + + Ok(()) + } + + /// Pull a single image with progress broadcasting (reuses install progress pattern). + async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> { + self.set_install_progress(package_id, 0, 0).await; + + let mut child = tokio::process::Command::new("podman") + .args(["pull", image]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("Failed to start image pull")?; + + if let Some(stderr) = child.stderr.take() { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + let pkg_id = package_id.to_string(); + let state_mgr = self.state_manager.clone(); + + while let Ok(Some(line)) = lines.next_line().await { + if let Some((downloaded, total)) = parse_pull_progress(&line) { + Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total) + .await; + } + } + } + + let status = child + .wait() + .await + .context("Failed to wait for image pull")?; + if !status.success() { + return Err(anyhow::anyhow!("podman pull {} failed", image)); + } + + self.set_install_progress(package_id, 100, 100).await; + Ok(()) + } + + /// Determine which images need to be pulled for this update. + /// For multi-container stacks, pulls all component images. + /// For single-container apps, pulls just the pinned image. + fn resolve_images_to_pull( + &self, + package_id: &str, + pinned_primary: &str, + ) -> Vec<(String, String)> { + let stack_images = image_versions::pinned_images_for_stack(package_id); + if stack_images.is_empty() { + // Single container app + vec![(package_id.to_string(), pinned_primary.to_string())] + } else { + stack_images + } + } + + /// Rollback: restart old containers if they still exist. + /// Called when update fails partway through. + async fn rollback_update(&self, package_id: &str, containers: &[String]) { + warn!("Rolling back update for {}", package_id); + for name in containers { + // Try to start — works if container still exists (wasn't removed yet) + let out = tokio::process::Command::new("podman") + .args(["start", name]) + .output() + .await; + match out { + Ok(o) if o.status.success() => { + info!("Rollback: restarted {}", name); + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + warn!("Rollback: could not restart {}: {}", name, stderr.trim()); + // Container was already removed — try reconcile to recreate with old image + let _ = tokio::process::Command::new("bash") + .args([ + "/opt/archipelago/scripts/reconcile-containers.sh", + &format!("--container={}", name), + "--force", + ]) + .output() + .await; + } + Err(e) => { + error!("Rollback: failed to restart {}: {}", name, e); + } + } + } + } + + /// Clear the Updating state (used on failure/rollback). + async fn clear_update_state(&self, package_id: &str) { + let (mut data, _) = self.state_manager.get_snapshot().await; + if let Some(entry) = data.package_data.get_mut(package_id) { + // Don't overwrite state from scanner — just clear if still Updating + if entry.state == PackageState::Updating { + entry.state = PackageState::Stopped; + } + } + self.state_manager.update_data(data).await; + } +} diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index f88cc862..dc1446a9 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -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, @@ -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", } } diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs new file mode 100644 index 00000000..b8a466f5 --- /dev/null +++ b/core/archipelago/src/container/image_versions.rs @@ -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> = Mutex::new(None); + +struct CacheEntry { + mtime: SystemTime, + images: HashMap, +} + +/// 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 { + 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 { + 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 { + 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); + } +} diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index c42d055f..899b8759 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -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; diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index 2a49ff55..019bd3ce 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -116,6 +116,7 @@ pub enum PackageState { Removing, #[serde(rename = "backing-up")] BackingUp, + Updating, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -133,6 +134,9 @@ pub struct PackageDataEntry { pub installed: Option, #[serde(rename = "install-progress")] pub install_progress: Option, + /// Pinned image version from image-versions.sh when it differs from running version + #[serde(rename = "available-update", skip_serializing_if = "Option::is_none")] + pub available_update: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index d80b05b0..5068b408 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -905,14 +905,10 @@ mkdir -p "$ARCH_DIR" mkdir -p "$ARCH_DIR/bin" mkdir -p "$ARCH_DIR/scripts" -# Embed netavark + aardvark-dns for container DNS (podman CNI lacks DNS) -if [ -f /usr/lib/podman/netavark ] && [ -f /usr/lib/podman/aardvark-dns ]; then - cp /usr/lib/podman/netavark "$ARCH_DIR/bin/netavark" - cp /usr/lib/podman/aardvark-dns "$ARCH_DIR/bin/aardvark-dns" - echo " Embedded netavark + aardvark-dns in ISO" -else - echo " WARNING: netavark/aardvark-dns not found — install with: apt install aardvark-dns netavark" -fi +# netavark + aardvark-dns are installed in the rootfs via Dockerfile.rootfs (Debian 12 packages). +# Do NOT copy from the build host — the host may run a newer glibc (e.g. Debian 13) +# and the resulting binary will fail on the Debian 12 target with GLIBC_2.39 not found. +echo " netavark + aardvark-dns: included in rootfs (Debian 12 packages)" # Copy the pre-built rootfs echo " Including root filesystem..." @@ -2033,24 +2029,18 @@ insecure = true REGCONF chown -R 1000:1000 /mnt/target/home/archipelago/.config -# Install netavark + aardvark-dns for container DNS resolution on archy-net. -# Debian 12's podman defaults to CNI which lacks DNS. Netavark provides built-in DNS. -# Binaries are embedded in the ISO at build time (archipelago/bin/). -if [ -f "$BOOT_MEDIA/archipelago/bin/netavark" ] && [ -f "$BOOT_MEDIA/archipelago/bin/aardvark-dns" ]; then - mkdir -p /mnt/target/usr/lib/podman - cp "$BOOT_MEDIA/archipelago/bin/netavark" /mnt/target/usr/lib/podman/netavark - cp "$BOOT_MEDIA/archipelago/bin/aardvark-dns" /mnt/target/usr/lib/podman/aardvark-dns - chmod +x /mnt/target/usr/lib/podman/netavark /mnt/target/usr/lib/podman/aardvark-dns - # Configure podman to use netavark backend (enables container DNS) +# Configure podman to use netavark backend (enables container DNS on archy-net). +# netavark + aardvark-dns binaries come from the rootfs (Debian 12 apt packages). +if [ -f /mnt/target/usr/lib/podman/netavark ]; then mkdir -p /mnt/target/home/archipelago/.config/containers cat > /mnt/target/home/archipelago/.config/containers/containers.conf <<'CONTAINERSCONF' [network] network_backend = "netavark" CONTAINERSCONF chown -R 1000:1000 /mnt/target/home/archipelago/.config/containers - echo " Installed netavark + aardvark-dns (container DNS enabled)" + echo " Configured netavark backend (container DNS enabled)" else - echo " WARNING: netavark/aardvark-dns not found in ISO — container DNS will not work" + echo " WARNING: netavark not found in rootfs — container DNS will not work" fi # Laptop support: ignore lid close so server keeps running diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 691c2b81..bc8c8564 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -545,6 +545,14 @@ class RPCClient { }) } + async updatePackage(id: string): Promise<{ status: string }> { + return this.call({ + method: 'package.update', + params: { id }, + timeout: 660000, // Bitcoin Knots needs up to 600s for graceful shutdown + }) + } + async getMarketplace(url: string): Promise> { return this.call({ method: 'marketplace.get', diff --git a/neode-ui/src/stores/app.ts b/neode-ui/src/stores/app.ts index 912b53c9..319da6eb 100644 --- a/neode-ui/src/stores/app.ts +++ b/neode-ui/src/stores/app.ts @@ -59,6 +59,7 @@ export const useAppStore = defineStore('app', () => { startPackage: server.startPackage, stopPackage: server.stopPackage, restartPackage: server.restartPackage, + updatePackage: server.updatePackage, updateServer: server.updateServer, restartServer: server.restartServer, shutdownServer: server.shutdownServer, diff --git a/neode-ui/src/stores/server.ts b/neode-ui/src/stores/server.ts index a2bb3fb2..aea311b4 100644 --- a/neode-ui/src/stores/server.ts +++ b/neode-ui/src/stores/server.ts @@ -17,7 +17,7 @@ export const useServerStore = defineStore('server', () => { watch(() => sync.packages, (packages) => { if (!packages) return for (const [appId, pkg] of Object.entries(packages)) { - if ((pkg.state as string) === 'installing') { + if ((pkg.state as string) === 'installing' || (pkg.state as string) === 'updating') { // Backend confirms it's installing — update or create tracking entry if (!installingApps.value.has(appId)) { installingApps.value.set(appId, { @@ -121,6 +121,10 @@ export const useServerStore = defineStore('server', () => { return rpcClient.restartPackage(id) } + async function updatePackage(id: string): Promise<{ status: string }> { + return rpcClient.updatePackage(id) + } + // Server actions async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> { return rpcClient.updateServer(marketplaceUrl) @@ -169,6 +173,7 @@ export const useServerStore = defineStore('server', () => { startPackage, stopPackage, restartPackage, + updatePackage, updateServer, restartServer, shutdownServer, diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index 98c13523..1cf03240 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -74,6 +74,7 @@ export const PackageState = { Restoring: 'restoring-backup', Removing: 'removing', BackingUp: 'backing-up', + Updating: 'updating', } as const export type PackageState = typeof PackageState[keyof typeof PackageState] @@ -90,6 +91,7 @@ export interface PackageDataEntry { manifest: Manifest installed?: InstalledPackageDataEntry 'install-progress'?: InstallProgress + 'available-update'?: string | null } export interface Manifest { diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 3ef39e73..0c45ae1a 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -33,6 +33,7 @@ @stop="stopApp" @restart="restartApp" @uninstall="uninstallApp" + @update="updateApp" @channels="router.push('/dashboard/apps/lnd/channels')" /> @@ -333,6 +334,14 @@ async function restartApp() { } } +async function updateApp() { + try { + await store.updatePackage(appId.value) + } catch (err) { + showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`) + } +} + function showUninstallModal() { if (!pkg.value) return uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title } diff --git a/neode-ui/src/views/appDetails/AppHeroSection.vue b/neode-ui/src/views/appDetails/AppHeroSection.vue index f9c95f73..902d7dee 100644 --- a/neode-ui/src/views/appDetails/AppHeroSection.vue +++ b/neode-ui/src/views/appDetails/AppHeroSection.vue @@ -26,6 +26,28 @@
+ + + + + + + + + Updating... + + + + + + + + Updating... +