From a8c6a36cd1dd947d956809982c3de70e8e087093 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 9 Apr 2026 11:47:35 +0200 Subject: [PATCH] fix: netavark GLIBC mismatch in ISO, container adopt, app updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../src/api/rpc/package/install.rs | 37 ++- core/archipelago/src/api/rpc/package/mod.rs | 1 + .../src/api/rpc/package/progress.rs | 1 + .../archipelago/src/api/rpc/package/stacks.rs | 108 +++--- .../archipelago/src/api/rpc/package/update.rs | 291 +++++++++++++++++ .../src/container/docker_packages.rs | 24 +- .../src/container/image_versions.rs | 307 ++++++++++++++++++ core/archipelago/src/container/mod.rs | 1 + core/archipelago/src/data_model.rs | 4 + image-recipe/build-auto-installer-iso.sh | 28 +- neode-ui/src/api/rpc-client.ts | 8 + neode-ui/src/stores/app.ts | 1 + neode-ui/src/stores/server.ts | 7 +- neode-ui/src/types/api.ts | 2 + neode-ui/src/views/AppDetails.vue | 9 + .../src/views/appDetails/AppHeroSection.vue | 45 +++ neode-ui/src/views/appDetails/AppSidebar.vue | 7 +- .../src/views/appDetails/appDetailsData.ts | 5 + neode-ui/src/views/apps/AppCard.vue | 6 +- neode-ui/src/views/apps/appsConfig.ts | 3 + 21 files changed, 830 insertions(+), 66 deletions(-) create mode 100644 core/archipelago/src/api/rpc/package/update.rs create mode 100644 core/archipelago/src/container/image_versions.rs 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... +