fix: netavark GLIBC mismatch in ISO, container adopt, app updates

ISO build no longer copies netavark from build host (Debian 13/GLIBC 2.41)
which broke container networking on Debian 12 targets. Rootfs already
installs netavark from Debian 12 repos — just configure the backend.

Install RPC now adopts existing containers (from first-boot) instead of
erroring on duplicates. Container scanner extracts real versions from
image tags and detects available updates against pinned versions.

Frontend shows update button with version info when updates are available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-09 11:47:35 +02:00
parent d0b9f168f4
commit a8c6a36cd1
21 changed files with 830 additions and 66 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -86,6 +86,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
},
installed: None,
install_progress: None,
available_update: None,
}
}

View File

@@ -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<Option<serde_json::Value>> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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

View File

@@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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;
}
}

View File

@@ -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<dyn ContainerRuntimeTrait>,
@@ -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",
}
}

View File

@@ -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<Option<CacheEntry>> = Mutex::new(None);
struct CacheEntry {
mtime: SystemTime,
images: HashMap<String, String>,
}
/// File search order — production path first, then repo-local for dev.
const PATHS: &[&str] = &[
"/opt/archipelago/image-versions.sh",
"scripts/image-versions.sh",
];
/// Parse image-versions.sh and return map of variable names to full image refs.
/// Result is cached and only re-parsed when the file's mtime changes.
fn load_image_versions() -> HashMap<String, String> {
let (path, mtime) = match find_file() {
Some(v) => v,
None => {
debug!("image-versions.sh not found in any search path");
return HashMap::new();
}
};
// Check cache
{
let cache = CACHE.lock().unwrap();
if let Some(ref entry) = *cache {
if entry.mtime == mtime {
return entry.images.clone();
}
}
}
// Parse fresh
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
debug!("Failed to read {}: {}", path, e);
return HashMap::new();
}
};
let images = parse_image_versions(&content);
debug!("Parsed {} image versions from {}", images.len(), path);
// Update cache
{
let mut cache = CACHE.lock().unwrap();
*cache = Some(CacheEntry {
mtime,
images: images.clone(),
});
}
images
}
fn find_file() -> Option<(String, SystemTime)> {
for p in PATHS {
let path = Path::new(p);
if let Ok(meta) = path.metadata() {
if let Ok(mtime) = meta.modified() {
return Some((p.to_string(), mtime));
}
}
}
None
}
/// Parse shell variable assignments, expanding $ARCHY_REGISTRY.
fn parse_image_versions(content: &str) -> HashMap<String, String> {
let mut vars = HashMap::new();
let mut registry = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
// Match VAR="value" or VAR=value
if let Some((key, val)) = parse_assignment(line) {
let expanded = val.replace("$ARCHY_REGISTRY", &registry);
if key == "ARCHY_REGISTRY" {
registry = expanded.clone();
}
vars.insert(key.to_string(), expanded);
}
}
// Keep only *_IMAGE entries
vars.retain(|k, _| k.ends_with("_IMAGE"));
vars
}
fn parse_assignment(line: &str) -> Option<(&str, &str)> {
let eq = line.find('=')?;
let key = &line[..eq];
// Validate key is a shell variable name
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
let val = &line[eq + 1..];
// Strip surrounding quotes
let val = val
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or(val);
Some((key, val))
}
/// Map app ID (as seen by the container scanner) to image variable name.
fn image_var_for_app(app_id: &str) -> Option<&'static str> {
match app_id {
// Bitcoin stack
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => Some("BITCOIN_KNOTS_IMAGE"),
"lnd" => Some("LND_IMAGE"),
"electrumx" => Some("ELECTRUMX_IMAGE"),
"electrs" | "mempool-electrs" => Some("ELECTRUMX_IMAGE"),
// Mempool stack (primary = web)
"mempool" | "mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
// BTCPay stack (primary = server)
"btcpay" | "btcpay-server" | "btcpayserver" => Some("BTCPAY_IMAGE"),
// Apps
"homeassistant" | "home-assistant" => Some("HOMEASSISTANT_IMAGE"),
"grafana" => Some("GRAFANA_IMAGE"),
"uptime-kuma" => Some("UPTIME_KUMA_IMAGE"),
"jellyfin" => Some("JELLYFIN_IMAGE"),
"photoprism" => Some("PHOTOPRISM_IMAGE"),
"ollama" => Some("OLLAMA_IMAGE"),
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
"searxng" => Some("SEARXNG_IMAGE"),
"cryptpad" => Some("CRYPTPAD_IMAGE"),
"filebrowser" => Some("FILEBROWSER_IMAGE"),
"nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"),
"tailscale" => Some("TAILSCALE_IMAGE"),
// Fedimint
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
"fedimint-gateway" => Some("FEDIMINT_GATEWAY_IMAGE"),
// Nostr / VPN
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
"fips" => Some("FIPS_IMAGE"),
// Immich (primary = server)
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
// Networking
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
_ => None,
}
}
/// Get the full pinned image reference for an app ID.
pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
let var = image_var_for_app(app_id)?;
let images = load_image_versions();
images.get(var).cloned()
}
/// Extract version tag from a full image reference.
/// e.g. "80.71.235.15:3000/archipelago/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty.
pub fn extract_version_from_image(image: &str) -> String {
// Split off the tag after the last colon, but only if it comes after the last slash
// (to avoid splitting on registry port like "80.71.235.15:3000")
if let Some(slash_pos) = image.rfind('/') {
let after_slash = &image[slash_pos..];
if let Some(colon_pos) = after_slash.rfind(':') {
let tag = &after_slash[colon_pos + 1..];
if !tag.is_empty() {
return tag.to_string();
}
}
}
"latest".to_string()
}
/// Container names and their image variable names for multi-container stacks.
/// Returns empty vec for single-container apps.
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
match app_id {
"mempool" | "mempool-web" => vec![
("archy-mempool-db", "MARIADB_IMAGE"),
("mempool-api", "MEMPOOL_BACKEND_IMAGE"),
("archy-mempool-web", "MEMPOOL_WEB_IMAGE"),
],
"btcpay" | "btcpay-server" | "btcpayserver" => vec![
("archy-btcpay-db", "BTCPAY_POSTGRES_IMAGE"),
("archy-nbxplorer", "NBXPLORER_IMAGE"),
("btcpay-server", "BTCPAY_IMAGE"),
],
"immich" | "immich_server" => vec![
("immich_postgres", "IMMICH_POSTGRES_IMAGE"),
("immich_redis", "REDIS_IMAGE"),
("immich_server", "IMMICH_SERVER_IMAGE"),
],
"penpot" | "penpot-frontend" => vec![
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
],
_ => vec![],
}
}
/// Get all pinned images for a stack update. Returns vec of (container_name, full_image_ref).
pub fn pinned_images_for_stack(app_id: &str) -> Vec<(String, String)> {
let images = load_image_versions();
containers_for_stack(app_id)
.into_iter()
.filter_map(|(name, var)| {
images.get(var).map(|img| (name.to_string(), img.clone()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_version() {
assert_eq!(
extract_version_from_image("80.71.235.15:3000/archipelago/lnd:v0.18.4-beta"),
"v0.18.4-beta"
);
assert_eq!(
extract_version_from_image("80.71.235.15:3000/archipelago/grafana:10.2.0"),
"10.2.0"
);
assert_eq!(
extract_version_from_image("localhost/myapp:latest"),
"latest"
);
assert_eq!(
extract_version_from_image("80.71.235.15:3000/archipelago/bitcoin-knots:latest"),
"latest"
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
ARCHY_REGISTRY="80.71.235.15:3000/archipelago"
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
# comment
NOT_AN_IMAGE="something"
"#;
let parsed = parse_image_versions(content);
assert_eq!(
parsed.get("LND_IMAGE"),
Some(&"80.71.235.15:3000/archipelago/lnd:v0.18.4-beta".to_string())
);
assert_eq!(
parsed.get("GRAFANA_IMAGE"),
Some(&"80.71.235.15:3000/archipelago/grafana:10.2.0".to_string())
);
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
}
#[test]
fn test_image_var_mapping() {
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
assert_eq!(image_var_for_app("bitcoin-knots"), Some("BITCOIN_KNOTS_IMAGE"));
assert_eq!(image_var_for_app("unknown-app"), None);
}
}

View File

@@ -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;

View File

@@ -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<InstalledPackageDataEntry>,
#[serde(rename = "install-progress")]
pub install_progress: Option<InstallProgress>,
/// 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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

View File

@@ -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

View File

@@ -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<Record<string, unknown>> {
return this.call({
method: 'marketplace.get',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -26,6 +26,28 @@
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Update available -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click="$emit('update')"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update to v{{ pkg['available-update'] }}
</button>
<!-- Updating in progress -->
<span
v-if="pkg.state === 'updating'"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center gap-2"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Updating...
</span>
<button
v-if="packageKey === 'lnd'"
@click="$emit('channels')"
@@ -130,6 +152,28 @@
<!-- Action Buttons (Auto Grid) -->
<div class="grid grid-cols-2 gap-2">
<!-- Update available (mobile) -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click="$emit('update')"
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update to v{{ pkg['available-update'] }}
</button>
<!-- Updating in progress (mobile) -->
<span
v-if="pkg.state === 'updating'"
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Updating...
</span>
<button
v-if="canLaunch"
@click="$emit('launch')"
@@ -200,6 +244,7 @@ defineEmits<{
stop: []
restart: []
uninstall: []
update: []
channels: []
}>()

View File

@@ -6,7 +6,12 @@
<div class="space-y-3">
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
<div class="text-right">
<span class="text-white font-medium">v{{ pkg.manifest.version }}</span>
<span v-if="pkg['available-update']" class="text-orange-300 text-xs ml-2">
v{{ pkg['available-update'] }} available
</span>
</div>
</div>
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.developer') }}</span>

View File

@@ -125,6 +125,8 @@ export function getStatusClass(state: PackageState, health?: string | null, exit
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
case PackageState.Installing:
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
case PackageState.Updating:
return 'bg-orange-500/20 text-orange-200 border border-orange-500/30'
default:
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
}
@@ -148,12 +150,15 @@ export function getStatusDotClass(state: PackageState, health?: string | null, e
return 'bg-yellow-400 animate-pulse'
case PackageState.Installing:
return 'bg-blue-400 animate-pulse'
case PackageState.Updating:
return 'bg-orange-400 animate-pulse'
default:
return 'bg-gray-400'
}
}
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
if (state === PackageState.Updating) return 'updating...'
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'

View File

@@ -44,6 +44,10 @@
class="tier-badge"
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tier }}</span>
<span
v-if="pkg['available-update']"
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
>Update</span>
</div>
<p class="text-sm text-white/50">{{ version ? `v${version}` : '' }}</p>
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
@@ -250,6 +254,6 @@ const tier = computed(() => {
const isTransitioning = computed(() => {
const s = props.pkg.state
const h = props.pkg.health
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || (s === 'running' && h === 'starting')
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
})
</script>

View File

@@ -138,6 +138,8 @@ export function getStatusClass(state: PackageState, health?: string | null, exit
return 'bg-yellow-500/20 text-yellow-200'
case PackageState.Installing:
return 'bg-blue-500/20 text-blue-200'
case PackageState.Updating:
return 'bg-orange-500/20 text-orange-200'
default:
return 'bg-gray-500/20 text-gray-200'
}
@@ -147,6 +149,7 @@ export function getStatusLabel(state: PackageState, health?: string | null, exit
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Updating) return 'updating...'
if (state === PackageState.Running) return 'running'
if (state === PackageState.Exited || state === PackageState.Stopped) {
if (exitCode === 137) return 'killed (OOM)'