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:
co-authored by
Claude Opus 4.6
parent
d0b9f168f4
commit
a8c6a36cd1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -86,6 +86,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
available_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user