Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,574 @@
//! Async wrappers for `package.install`, `package.uninstall`, `package.update`.
//!
//! The inner `handle_package_*` functions are large (install is 480 lines with
//! the stack dispatchers, update is 300, uninstall is 200) and do their own
//! fine-grained progress tracking via `install_progress` and `uninstall_stage`.
//! We wrap them rather than refactor them.
//!
//! Each wrapper:
//! 1. Parses + validates the RPC params (cheap, synchronous). Errors here
//! return immediately to the caller before any state change.
//! 2. Flips the package state to the transitional variant
//! (`Installing` / `Removing` / `Updating`) so the UI sees it on the
//! next WebSocket push (before the RPC response even lands).
//! 3. `tokio::spawn`s a background task that invokes the existing
//! `handle_package_*` method on the Arc-held self.
//! 4. On task success: no state change needed — the inner handler has
//! already written the terminal state (Running for install/update, or
//! removed the entry for uninstall).
//! 5. On task failure: revert state to the pre-transition value (or delete
//! the entry for install, since there was no pre-state), write a line
//! to the persistent install log, and clear any stale progress fields.
//! 6. Returns `{ "status": "installing" }` etc. immediately.
//!
//! The server package-scan loop's `merge_preserving_transitional` helper
//! already knows to preserve `Installing` / `Removing` / `Updating` between
//! scans, so live progress updates broadcast from inside the spawned task
//! reach the UI correctly.
use super::install::install_log;
use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState;
use crate::state::StateManager;
use anyhow::Result;
use std::sync::Arc;
use tracing::{error, info, warn};
impl RpcHandler {
/// Async wrapper for `package.install`. Returns `{ "status": "installing" }`
/// immediately after flipping state to `Installing` and spawning the
/// actual install pipeline. On failure, removes the package entry from
/// state so the UI reverts to "not installed".
pub(in crate::api::rpc) async fn spawn_package_install(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// Extract + validate package_id synchronously so bad params fail
// fast without touching state.
let params_val = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params_val
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
package_id,
entry.state
));
}
}
}
// Flip state to Installing BEFORE the spawn so the first WebSocket
// push carries the transitional state. Uses the same
// `create_installing_entry` path the inner handler would use once
// it starts pulling, so the UI sees a consistent shape.
flip_to_installing(&self.state_manager, &package_id).await;
install_log(&format!("INSTALL SPAWN: {}", package_id)).await;
let handler = Arc::clone(&self);
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_install(params).await {
Ok(_) => {
info!("package.install {}: complete", package_id_spawn);
// The install pipeline has verified the container is up
// and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with
// `interfaces.main.ui` from the live port binding) lands
// BEFORE we flip to Running — without this the Launch
// button is missing for up to 60s after a successful
// install, because the skeletal install-time manifest
// has `interfaces: None`.
kick_scanner_and_wait(&handler).await;
// We MUST explicitly transition out of Installing here:
// `merge_preserving_transitional` in the package-scan
// loop treats Installing as RPC-owned and refuses to
// let the scanner overwrite it with the observed
// Running state. Without this write, the entry stays
// stuck at Installing forever.
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
)
.await;
handler.clear_install_progress(&package_id_spawn).await;
// Auto-expose the app over Tor (best-effort, detached) —
// every installed app gets its .onion without a manual
// "Add Service" step.
let tor_handler = Arc::clone(&handler);
let tor_app = package_id_spawn.clone();
tokio::spawn(async move {
tor_handler.auto_add_tor_service(&tor_app).await;
});
}
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
// handle_package_install saves the catalog-provided
// dynamic app config to /var/lib/archipelago/app-configs
// BEFORE the install pipeline runs, so a failure can
// strand that file (and the optimistic state entry) with
// no container behind it. Probe once here; both cleanup
// branches below only fire when the app has no footprint.
// A retry re-saves the config (the frontend sends
// containerConfig on every install), so removal is safe.
let left_container =
failed_install_left_container(&handler, &package_id_spawn).await;
// Dependency-gate rejections happen BEFORE any resource
// (container/image/data dir) exists for this package, so
// keeping the optimistic entry would leave a phantom
// "Stopped" tile whose Start fails with `no such object`
// (the log-confirmed LND fresh-install failure). Remove
// the entry so the card reverts to installable, and
// surface the reason as a notification instead.
if let Some(gate) = e.downcast_ref::<super::dependencies::DependencyGateError>()
{
if !left_container {
remove_dynamic_app_config(&package_id_spawn).await;
}
remove_entry_with_notification(
&handler,
&package_id_spawn,
"install-deps",
&gate.to_string(),
)
.await;
return;
}
// A failed install that left NO container behind has no
// real footprint either — keeping the entry would leave
// the same phantom "Stopped" tile in My Apps (and the
// scanner-side absence eviction takes 3 scans to catch
// it). Remove the saved config + entry and surface the
// failure as a notification, exactly like the gate case.
if !left_container {
remove_dynamic_app_config(&package_id_spawn).await;
remove_entry_with_notification(
&handler,
&package_id_spawn,
"install-failed",
&format!("Install failed: {:#}", e),
)
.await;
return;
}
// A container exists (crash-after-start kept for
// visibility, retry over an existing install, upgrade) —
// don't remove the entry, that's what made the card
// vanish from My Apps mid-install / between retry-loop
// attempts (e.g. tailscale's entrypoint failure). Leave
// the entry visible with state=Stopped + the install
// error in install_progress.message so the user can see
// what went wrong and decide whether to retry or
// uninstall. clear_install_progress would erase the
// message, so we set it explicitly here instead. The
// phase is cleared (None) so no stale InstallPhase
// lingers on the card.
let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
handler.state_manager.update_data(data).await;
}
}
}
});
Ok(serde_json::json!({
"status": "installing",
"package_id": package_id,
}))
}
/// Async wrapper for `package.uninstall`. Returns `{ "status": "removing" }`
/// immediately. State stays `Removing` until the inner handler finishes
/// (including the `sudo rm -rf` of app data, which can take minutes for
/// bitcoin-core's chainstate). On failure, reverts to the pre-transition
/// state (usually Running or Stopped) so the user can retry.
pub(in crate::api::rpc) async fn spawn_package_uninstall(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params_val = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params_val
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
// Reject if already in a transitional lifecycle.
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
package_id,
entry.state
));
}
}
}
let pre_state =
flip_package_state(&self.state_manager, &package_id, PackageState::Removing).await;
install_log(&format!("UNINSTALL SPAWN: {}", package_id)).await;
let handler = Arc::clone(&self);
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_uninstall(params).await {
Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn);
// Inner handler already removed the package entry on
// success. Nothing more to do here.
}
Err(e) => {
error!("package.uninstall {} failed: {:#}", package_id_spawn, e);
install_log(&format!("UNINSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
// Revert to pre-transition state so the user can retry.
// Also clear any stale uninstall_stage label.
if let Some(prev) = pre_state {
set_package_state_and_clear_uninstall_stage(
&handler.state_manager,
&package_id_spawn,
prev,
)
.await;
}
}
}
});
Ok(serde_json::json!({
"status": "removing",
"package_id": package_id,
}))
}
/// Async wrapper for `package.update`. Returns `{ "status": "updating" }`
/// immediately. The inner handler already manages its own rollback on
/// failure (restarts old containers); this wrapper just flips state and
/// spawns.
pub(in crate::api::rpc) async fn spawn_package_update(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params_val = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params_val
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
// Reject if already in a transitional lifecycle.
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
package_id,
entry.state
));
}
}
}
// The inner handler flips state to Updating itself, but we do it
// here too so the transitional state lands before the spawn yields.
let pre_state =
flip_package_state(&self.state_manager, &package_id, PackageState::Updating).await;
install_log(&format!("UPDATE SPAWN: {}", package_id)).await;
let handler = Arc::clone(&self);
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_update(params).await {
Ok(_) => {
info!("package.update {}: complete", package_id_spawn);
// Same reasoning as install: the merge_preserving_transitional
// helper treats Updating as RPC-owned, so we MUST write the
// terminal Running state ourselves or the entry will stay
// stuck at Updating forever. The update pipeline has
// already verified the new container is running via its
// post-recreate check.
// Kick the scanner first so any manifest changes from the
// new image version (interfaces, ports, etc.) land before
// we flip to Running.
kick_scanner_and_wait(&handler).await;
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
)
.await;
}
Err(e) => {
error!("package.update {} failed: {:#}", package_id_spawn, e);
install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await;
// Inner handler already ran rollback_update + cleared
// update state, but be defensive: revert to pre-state
// in case the inner flow died before its cleanup.
if let Some(prev) = pre_state {
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
}
}
}
});
Ok(serde_json::json!({
"status": "updating",
"package_id": package_id,
}))
}
}
// ---------------------------------------------------------------------------
// State-manager helpers (free fns, usable from inside spawned tasks)
// ---------------------------------------------------------------------------
/// Create or update the entry for this package with `Installing` state.
/// Matches what the inner handler's `set_install_progress` would do on first
/// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
let (mut data, _) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.state = PackageState::Installing;
state_manager.update_data(data).await;
}
/// True when the failed install still has a real footprint: any container
/// belonging to `package_id` exists (any state — created/exited count too;
/// the install-crash path deliberately keeps the exited container visible),
/// or the app carries a user-stopped marker (Quadlet units run with `--rm`,
/// so a cleanly user-stopped app legitimately has no podman record). Errors
/// from the podman probe count as "exists" — never clean up on an uncertain
/// reading.
async fn failed_install_left_container(handler: &RpcHandler, package_id: &str) -> bool {
if crate::crash_recovery::load_user_stopped(&handler.config.data_dir)
.await
.contains(package_id)
{
return true;
}
match super::config::get_containers_for_app(package_id).await {
Ok(containers) => !containers.is_empty(),
Err(e) => {
warn!(
"install cleanup {}: container probe failed ({:#}); keeping saved config",
package_id, e
);
true
}
}
}
/// Remove the catalog-provided dynamic app config that
/// `handle_package_install` saved before the pipeline ran (mirror of the
/// write in install.rs). Only called when the app has no container — for an
/// existing install (retry/upgrade) the file is still the app's live runtime
/// config and must be kept.
async fn remove_dynamic_app_config(package_id: &str) {
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", package_id);
match tokio::fs::remove_file(&config_path).await {
Ok(()) => info!(
"Removed dynamic app config for {} after failed install (no container)",
package_id
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!(
"Failed to remove dynamic app config for {}: {}",
package_id, e
),
}
}
/// Remove the package's optimistic state entry (clearing any pending install
/// phase with it) so the card reverts to installable, and surface the failure
/// reason as an error notification instead.
async fn remove_entry_with_notification(
handler: &RpcHandler,
package_id: &str,
id_prefix: &str,
message: &str,
) {
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
handler.state_manager.update_data(data).await;
}
/// Flip an existing entry's state and return the pre-flip value (or None if
/// no entry existed). Used for revert-on-failure.
async fn flip_package_state(
state_manager: &StateManager,
package_id: &str,
new_state: PackageState,
) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
state_manager.update_data(data).await;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
}
/// Set state unconditionally (no-op if entry no longer exists).
async fn set_package_state(
state_manager: &StateManager,
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
}
/// Set state and clear the uninstall_stage label. Used when an uninstall
/// fails and we revert — the user doesn't want a stale "Removing app data"
/// message sitting on a Running entry.
async fn set_package_state_and_clear_uninstall_stage(
state_manager: &StateManager,
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
entry.uninstall_stage = None;
state_manager.update_data(data).await;
}
}
/// Kick the container scanner to run immediately and wait for it to finish
/// (with a 2s timeout). Used by install/update success paths so the fresh
/// manifest — with `interfaces.main.ui` populated from the now-running
/// container's port binding — lands BEFORE we flip state to Running.
///
/// Without this, the frontend sees `state = running` but the skeletal
/// install-time manifest (interfaces = None), and hides the Launch button
/// for up to the full 60s scan interval.
///
/// The scan merges via `merge_preserving_transitional`, which keeps
/// state = Installing (we haven't flipped yet) while taking the fresh
/// manifest. After this returns, the caller writes Running on top of the
/// now-populated manifest.
async fn kick_scanner_and_wait(handler: &RpcHandler) {
let mut rx = handler.scan_tick.subscribe();
let start = *rx.borrow_and_update();
handler.scan_kick.notify_one();
// 2s is well above a typical podman scan (~200ms on .228, ~500ms worst
// case). If it times out we proceed anyway — the next 60s scan will
// self-heal and the worst case is the pre-fix behavior (Launch button
// appears a bit late).
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
while *rx.borrow_and_update() == start {
if rx.changed().await.is_err() {
break;
}
}
})
.await;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
// Container lifecycle operations.
//
// Split into focused sub-modules:
// - install.rs — Image pulling, container creation, volume setup, multi-container stacks
// - runtime.rs — Start, stop, restart, uninstall operations
// - dependencies.rs — Dependency resolution, startup ordering, network requirements
//
// All public handler methods (handle_package_*) are implemented on RpcHandler
// in their respective sub-modules and remain callable from the RPC dispatcher.
@@ -0,0 +1,17 @@
mod async_lifecycle;
mod config;
mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
mod set_config;
mod stacks;
mod update;
mod validation;
// Re-export items needed by sibling modules (container.rs, security.rs, transitional.rs)
pub(in crate::api::rpc) use install::install_log;
pub(super) use validation::validate_app_id;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! Install progress tracking and podman pull output parsing.
use crate::api::rpc::RpcHandler;
use crate::data_model::{
Description, InstallPhase, InstallProgress, Manifest, PackageDataEntry, PackageState,
StaticFiles,
};
impl RpcHandler {
/// Set install progress for a package and broadcast the update.
/// Creates a minimal package entry if one doesn't exist yet.
///
/// Prefer `set_install_phase` — this byte-counter API is kept for
/// the rare case where the pull stream actually parses, but podman
/// almost never emits parseable progress on a piped stderr.
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing;
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: existing_phase,
message: None,
});
self.state_manager.update_data(data).await;
}
/// Set the install pipeline phase and broadcast. This is the
/// primary progress signal — the UI maps each phase to a
/// percentage and a user-facing label. Byte counters are retained
/// for the rare case podman emits parseable progress.
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
// Preparing / PullingImage / CreatingContainer / StartingContainer /
// WaitingHealthy / PostInstall all map to the Installing state.
// Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it.
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded))
.unwrap_or((0, 0));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: Some(phase),
message: None,
});
self.state_manager.update_data(data).await;
}
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
self.state_manager.update_data(data).await;
}
/// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
self.state_manager.update_data(data).await;
}
/// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
self.state_manager.update_data(data).await;
}
/// Update install progress (static method for use in async closures).
pub(super) async fn update_install_progress(
state_manager: &crate::state::StateManager,
package_id: &str,
downloaded: u64,
total: u64,
) {
let (mut data, _rev) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
state_manager.update_data(data).await;
}
}
/// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry {
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
// `.svg` or `.webp` assets and produces a broken-image flicker.
// The frontend's `icon` computed falls through to the curated
// map which has correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
}
}
/// Parse podman pull progress output.
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
/// Returns (downloaded_bytes, total_bytes) if parseable.
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
let line = line.trim();
let parts: Vec<&str> = line.split('/').collect();
if parts.len() != 2 {
return None;
}
let downloaded = parse_size_value(parts[0].trim())?;
let total = parse_size_value(parts[1].trim())?;
if total > 0 {
Some((downloaded, total))
} else {
None
}
}
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
fn parse_size_value(s: &str) -> Option<u64> {
let s = s.trim();
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
(s[..pos].split_whitespace().last()?, 1024 * 1024 * 1024)
} else if let Some(pos) = s.rfind("MiB") {
(s[..pos].split_whitespace().last()?, 1024 * 1024)
} else if let Some(pos) = s.rfind("KiB") {
(s[..pos].split_whitespace().last()?, 1024)
} else if let Some(pos) = s.rfind("GB") {
(s[..pos].split_whitespace().last()?, 1_000_000_000)
} else if let Some(pos) = s.rfind("MB") {
(s[..pos].split_whitespace().last()?, 1_000_000)
} else if let Some(pos) = s.rfind("KB") {
(s[..pos].split_whitespace().last()?, 1_000)
} else if let Some(pos) = s.rfind('B') {
(s[..pos].split_whitespace().last()?, 1)
} else {
return None;
};
let num: f64 = num_str.parse().ok()?;
Some((num * multiplier as f64) as u64)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,417 @@
//! Multi-version support — version listing + in-app version switch / pin /
//! auto-update toggle (`docs/bitcoin-multi-version-design.md` §3 Phase 3).
//!
//! Two RPCs:
//! - `package.versions` — read the selectable versions for an app plus the
//! runner's current pin / auto-update preference and (best-effort) the
//! version actually running. Drives the install modal + "Version & Updates"
//! card.
//! - `package.set-config` — persist a version pin (or un-pin to track latest)
//! and/or the auto-update toggle, then recreate the app at the chosen image
//! when the version actually changed. A DOWNGRADE (older release over a
//! newer chainstate — the highest-risk operation, design §4) is refused
//! unless the caller passes `confirm: true`, so the UI can warn first.
use super::config::get_containers_for_app;
use super::install::install_log;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::container::{app_catalog, version_config};
use anyhow::Result;
use std::sync::Arc;
use tracing::{info, warn};
/// Apps that participate in multi-version selection today. Kept narrow on
/// purpose: version switching recreates the container, which is only safe for
/// the single-container, orchestrator-managed Bitcoin backends whose data and
/// downgrade semantics we understand. Any app the catalog gives a `versions[]`
/// list also qualifies (third-party registry apps inherit the capability).
fn supports_versions(app_id: &str) -> bool {
matches!(app_id, "bitcoin-core" | "bitcoin-knots")
|| !app_catalog::catalog_versions(app_id).is_empty()
}
/// Extract the tag from a full image reference, leaving a `registry:port/repo`
/// host-port colon intact (only a colon AFTER the last `/` is a tag).
fn image_tag(image: &str) -> Option<String> {
let after_slash = image.rsplit_once('/').map(|(_, r)| r).unwrap_or(image);
after_slash
.rsplit_once(':')
.map(|(_, tag)| tag.to_string())
.filter(|t| !t.is_empty())
}
/// Best-effort: the version tag of the backend container actually running for
/// `app_id`, by inspecting its image. `None` when not installed or unreadable.
async fn installed_version(app_id: &str) -> Option<String> {
let containers = get_containers_for_app(app_id).await.ok()?;
// Prefer the backend container (exact id / `archy-<id>`) over UI companions.
//
// The fallback is deliberately narrow. It used to be `containers.first()`
// unconditionally, which for a multi-container stack reported a SIBLING's
// version as the app's own: with btcpay-server's container absent, its
// postgres dependency was first in the list, so package.versions answered
// installedVersion "15.17" against an available "2.4.2". That is not a
// cosmetic mislabel — it is the number the update decision is made from.
//
// A single-container app is unambiguous, so the fallback still covers apps
// whose container is named differently from their id. With several
// containers and no identifiable backend, "unknown" is the honest answer.
let name = select_backend_container(app_id, &containers)?;
let out = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.ImageName}}"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let image = String::from_utf8_lossy(&out.stdout).trim().to_string();
let tag = image_tag(&image)?;
// A floating tag (latest/stable/...) names the reference used to CREATE the
// container, not what's actually running — podman never re-resolves it once
// cached, so a stale local `:latest` reports "latest" even when the real
// `latest` moved on months ago (.228, 2026-07-01: ran a 4-month-old cached
// image while a newer one already sat locally, unused). Ask the Bitcoin
// backends directly instead of trusting the tag literal in that case.
if is_floating_tag(&tag) {
if let Some(real) = bitcoind_reported_version(app_id, name).await {
return Some(real);
}
}
Some(tag)
}
/// Pick the container that represents `app_id` itself, never a stack sibling.
///
/// See the note at the call site: an unconditional "first container" fallback
/// reported a dependency's image tag as the app's installed version.
fn select_backend_container<'a>(app_id: &str, containers: &'a [String]) -> Option<&'a str> {
if let Some(exact) = containers
.iter()
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
{
return Some(exact.as_str());
}
// Unambiguous only when there is nothing else it could be.
if containers.len() == 1 {
return Some(containers[0].as_str());
}
None
}
fn is_floating_tag(tag: &str) -> bool {
matches!(tag, "latest" | "stable" | "release" | "main")
}
/// Best-effort: ask the running bitcoind binary for its own version, trimmed to
/// the catalog's version-tag format (e.g. `29.3.knots20260210`, `29.2`). `None`
/// for apps other than the Bitcoin backends (no generic way to introspect a
/// third-party image's content version this way) or if the exec fails.
async fn bitcoind_reported_version(app_id: &str, container_name: &str) -> Option<String> {
if !matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
return None;
}
let out = tokio::process::Command::new("podman")
.args(["exec", container_name, "bitcoind", "--version"])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
parse_bitcoind_version_output(&String::from_utf8_lossy(&out.stdout))
}
/// Parses e.g. "Bitcoin Knots daemon version v29.3.knots20260210\n..." or
/// "Bitcoin Core version v29.2.0\n..." down to the version tag after `version v`.
fn parse_bitcoind_version_output(output: &str) -> Option<String> {
let first_line = output.lines().next()?;
let (_, version) = first_line.rsplit_once("version v")?;
let version = version.trim();
if version.is_empty() {
return None;
}
Some(version.to_string())
}
impl RpcHandler {
/// `package.versions` — what a runner can install / switch to for this app,
/// plus their current preference and the running version.
pub(in crate::api::rpc) async fn handle_package_versions(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(app_id)?;
let versions = app_catalog::catalog_versions(app_id);
let default = app_catalog::catalog_default_version(app_id);
let cfg = version_config::read(app_id);
let installed = installed_version(app_id).await;
Ok(serde_json::json!({
"id": app_id,
"supportsVersions": supports_versions(app_id),
"default": default,
"installedVersion": installed,
"pinnedVersion": cfg.pinned_version,
"autoUpdate": cfg.auto_update,
"versions": versions.iter().map(|v| serde_json::json!({
"version": v.version,
"default": v.default,
"deprecated": v.deprecated,
"eol": v.eol,
})).collect::<Vec<_>>(),
}))
}
/// `package.set-config` — persist version pin + auto-update preference and
/// recreate on an actual version change. Downgrades require `confirm:true`.
pub(in crate::api::rpc) async fn handle_package_set_config(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
validate_app_id(&app_id)?;
if !supports_versions(&app_id) {
return Err(anyhow::anyhow!(
"{} has no selectable versions in the catalog",
app_id
));
}
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let existing = version_config::read(&app_id);
let default = app_catalog::catalog_default_version(&app_id);
// ---- Resolve the requested pin (if a version was supplied) ----------
// Absent `version` => leave the pin unchanged (an auto-update-only edit).
// `version == default` => un-pin (track latest). Any other version must
// exist in the catalog and resolve to a same-repo image, else reject.
let version_param = params
.get("version")
.and_then(|v| v.as_str())
.map(str::to_string);
let mut new_pin = existing.pinned_version.clone();
let mut version_changed = false;
if let Some(req) = version_param.as_deref() {
let resolved_pin = if default.as_deref() == Some(req) {
None // selecting the default un-pins
} else {
// Validate the version is real + same-repo before pinning.
if !app_catalog::catalog_versions(&app_id)
.iter()
.any(|v| v.version == req)
{
return Err(anyhow::anyhow!(
"version {} is not offered for {}",
req,
app_id
));
}
Some(req.to_string())
};
version_changed = resolved_pin != existing.pinned_version;
new_pin = resolved_pin;
}
let new_auto_update = params
.get("autoUpdate")
.and_then(|v| v.as_bool())
.unwrap_or(existing.auto_update);
// ---- Downgrade gate (design §4: warn + confirm + allow) -------------
// "Current" = what wrote the on-disk chainstate: the running version if
// we can read it, else the existing pin, else the catalog default.
if version_changed {
let target = version_param.as_deref().unwrap_or_default();
let current = installed_version(&app_id)
.await
.or_else(|| existing.pinned_version.clone())
.or_else(|| default.clone());
if let Some(current) = current {
if version_config::is_downgrade(&current, target) && !confirm {
warn!(
"set-config {}: refusing un-confirmed downgrade {} -> {}",
app_id, current, target
);
return Ok(serde_json::json!({
"status": "confirm_required",
"kind": "downgrade",
"id": app_id,
"currentVersion": current,
"targetVersion": target,
"warning": format!(
"Switching {app_id} from {current} down to {target} is a \
downgrade. Bitcoin may refuse to start on a chainstate \
written by the newer version without a full reindex, and \
a pruned node can lose block data. Re-confirm to proceed."
),
}));
}
}
}
// ---- Persist preference --------------------------------------------
version_config::write(
&app_id,
&version_config::AppVersionConfig {
pinned_version: new_pin.clone(),
auto_update: new_auto_update,
},
)?;
install_log(&format!(
"SET-CONFIG {}: pinned={:?} autoUpdate={} (version_changed={})",
app_id, new_pin, new_auto_update, version_changed
))
.await;
info!(
app_id = %app_id,
pinned = ?new_pin,
auto_update = new_auto_update,
version_changed,
"package.set-config applied"
);
// ---- Recreate when the version actually changed + app is installed --
// The orchestrator's install/recreate path reads the pin we just wrote
// (prod_orchestrator image resolution), so reusing the update machinery
// pulls + recreates at the chosen image. An auto-update-only edit, or a
// change to a not-installed app, just persists the preference.
let mut recreating = false;
if version_changed {
let installed = get_containers_for_app(&app_id)
.await
.map(|c| !c.is_empty())
.unwrap_or(false);
if installed {
recreating = true;
// Fire the existing async update flow; it flips state to
// Updating and recreates honoring the new pin. The UI polls.
self.clone()
.spawn_package_update(Some(serde_json::json!({ "id": app_id })))
.await?;
}
}
Ok(serde_json::json!({
"status": "ok",
"id": app_id,
"pinnedVersion": new_pin,
"autoUpdate": new_auto_update,
"versionChanged": version_changed,
"recreating": recreating,
}))
}
}
#[cfg(test)]
mod tests {
use super::{
image_tag, is_floating_tag, parse_bitcoind_version_output, select_backend_container,
};
#[test]
fn floating_tag_detects_generic_channel_names() {
for tag in ["latest", "stable", "release", "main"] {
assert!(is_floating_tag(tag), "{tag}");
}
for tag in ["29.3.knots20260508", "28.4", "v29.2.0"] {
assert!(!is_floating_tag(tag), "{tag}");
}
}
#[test]
fn parses_knots_version_line() {
assert_eq!(
parse_bitcoind_version_output(
"Bitcoin Knots daemon version v29.3.knots20260210\nCopyright...\n"
)
.as_deref(),
Some("29.3.knots20260210")
);
}
#[test]
fn parses_core_version_line() {
assert_eq!(
parse_bitcoind_version_output("Bitcoin Core version v29.2.0\n").as_deref(),
Some("29.2.0")
);
}
#[test]
fn parse_returns_none_when_output_has_no_version_marker() {
assert_eq!(parse_bitcoind_version_output("garbage output\n"), None);
assert_eq!(parse_bitcoind_version_output(""), None);
}
/// The BTCPay case: with btcpay-server's own container absent, its postgres
/// dependency was first in the app's container list and its tag (15.17) was
/// reported as BTCPay's installed version, against an available 2.4.2.
#[test]
fn backend_selection_never_falls_back_to_a_sibling_in_a_stack() {
let stack = vec!["archy-btcpay-db".to_string(), "archy-nbxplorer".to_string()];
assert_eq!(select_backend_container("btcpay-server", &stack), None);
let with_backend = vec!["archy-btcpay-db".to_string(), "btcpay-server".to_string()];
assert_eq!(
select_backend_container("btcpay-server", &with_backend),
Some("btcpay-server")
);
}
#[test]
fn backend_selection_accepts_a_lone_differently_named_container() {
let single = vec!["immich_server".to_string()];
assert_eq!(
select_backend_container("immich", &single),
Some("immich_server")
);
}
#[test]
fn backend_selection_prefers_the_archy_prefixed_name() {
let names = vec!["something-else".to_string(), "archy-nbxplorer".to_string()];
assert_eq!(
select_backend_container("nbxplorer", &names),
Some("archy-nbxplorer")
);
}
#[test]
fn image_tag_keeps_registry_port_colon() {
assert_eq!(
image_tag("source.archipelago-foundation.org/lfg2025/bitcoin:28.4").as_deref(),
Some("28.4")
);
assert_eq!(
image_tag("source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260508")
.as_deref(),
Some("29.3.knots20260508")
);
// No tag => None (don't mistake the registry port for a tag).
assert_eq!(
image_tag("source.archipelago-foundation.org/lfg2025/bitcoin"),
None
);
assert_eq!(
image_tag("docker.io/library/redis:7"),
Some("7".to_string())
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
//! Per-app manual update handler.
//!
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
//! remove old container(s) → recreate (orchestrator-first, legacy fallback) → 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::{InstallPhase, PackageState};
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{error, info, warn};
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
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)?;
// Resolve the target image. Prefer the remote app catalog (decoupled
// from the binary OTA), falling back to the image-versions.sh pin. This
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
// the image itself (manifest + catalog + version_config pin) in its
// upgrade path, so an app the catalog doesn't carry a primary image for
// (e.g. bitcoin-core, image lives in the embedded manifest + versions[])
// still upgrades. Only the legacy/stack path below hard-requires it.
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
.or_else(|| image_versions::pinned_image_for_app(package_id));
// Note: the `already updating` guard lives in `spawn_package_update`
// (the async wrapper that dispatch actually routes to). By the time
// this inner function runs, the wrapper has already flipped state to
// `Updating`, so duplicating the check here would be a false positive.
install_log(&format!(
"UPDATE: {} → {}",
package_id,
pinned.as_deref().unwrap_or("(orchestrator-resolved)")
))
.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;
}
// Preferred path: for single-container apps managed by manifests, route
// updates through the orchestrator's upgrade lifecycle instead of the
// legacy shell/CLI flow. Keep stack-style packages on legacy for now.
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
let orchestrator_app_id = orchestrator_update_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
install_log(&format!(
"UPDATE ORCH: {} — attempting orchestrator upgrade as {}",
package_id, orchestrator_app_id
))
.await;
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.upgrade(orchestrator_app_id).await {
Ok(()) => {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
if health != "healthy" {
warn!(
"Update {}: orchestrator upgrade completed with health={} (expected healthy)",
package_id, health
);
}
}
install_log(&format!(
"UPDATE ORCH OK: {} (app={})",
package_id, orchestrator_app_id
))
.await;
self.clear_install_progress(package_id).await;
return Ok(serde_json::json!({
"status": "updated",
"package_id": package_id,
}));
}
Err(e) if is_unknown_app_id_error(&e) => {
info!(
"Update {}: orchestrator has no manifest mapping yet, falling back to legacy updater",
package_id
);
install_log(&format!(
"UPDATE ORCH SKIP: {} — unknown app_id, using legacy flow",
package_id
))
.await;
}
Err(e) => {
install_log(&format!("UPDATE ORCH FAIL: {} — {}", package_id, e)).await;
self.clear_install_progress(package_id).await;
self.clear_update_state(package_id).await;
return Err(e.context(format!("Orchestrator update {} failed", package_id)));
}
}
}
}
// Legacy/stack path hard-requires a concrete primary image (the
// orchestrator path above already returned for apps it manages).
let pinned = match pinned {
Some(p) => p,
None => {
self.clear_update_state(package_id).await;
return Err(anyhow::anyhow!("No pinned image found for {}", package_id));
}
};
// 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)))
}
}
}
/// Manual "check for updates": refresh the remote app catalog now. The
/// package scanner recomputes each app's `available-update` from the fresh
/// catalog on its next cycle and pushes it to the UI. When the catalog
/// bytes changed, the orchestrator's manifest overlay is reloaded in the
/// same call so catalog-shipped manifest fixes apply without a service
/// restart. Best-effort — a fetch failure leaves the cached catalog in
/// place and reports `refreshed: false`.
pub(in crate::api::rpc) async fn handle_package_check_updates(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
Ok(refresh) => {
let mut manifests_reloaded = serde_json::Value::Null;
if refresh.changed {
if let Some(orch) = &self.orchestrator {
match orch.reload_manifests().await {
Ok(n) => manifests_reloaded = serde_json::json!(n),
Err(e) => tracing::warn!(
"check-updates: manifest reload after catalog change failed: {e}"
),
}
}
}
Ok(serde_json::json!({
"status": "ok",
"refreshed": true,
"catalog_apps": refresh.apps,
"catalog_changed": refresh.changed,
"manifests_reloaded": manifests_reloaded,
}))
}
Err(e) => Ok(serde_json::json!({
"status": "ok",
"refreshed": false,
"error": e.to_string(),
})),
}
}
/// Core update execution: stop → pull → remove → recreate → verify.
async fn execute_update(
&self,
package_id: &str,
containers: &[String],
images_to_pull: &[(String, String)],
) -> Result<()> {
// Phase: Preparing — about to stop the running container(s) so
// we can swap images. Fast.
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
// 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
}
}
// Phase: PullingImage — about to fetch each pinned image in turn.
self.set_install_phase(package_id, InstallPhase::PullingImage)
.await;
// 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;
}
}
// Phase: CreatingContainer — about to recreate each container.
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
// 4. Recreate containers (orchestrator-first, reconcile fallback)
info!("Update {}: recreating containers", package_id);
for name in containers {
self.recreate_container_for_update(package_id, name).await?;
// Brief delay between containers for dependency initialization
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// Phase: WaitingHealthy — reconcile has started every container,
// now verifying each reached running state.
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.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(())
}
async fn recreate_container_for_update(
&self,
package_id: &str,
container_name: &str,
) -> Result<()> {
let Some(orchestrator) = self.orchestrator.as_ref() else {
return Err(anyhow::anyhow!(
"Cannot recreate {} during update {}: orchestrator unavailable",
container_name,
package_id
));
};
let mut attempted = Vec::new();
for app_id in candidate_app_ids_for_container(container_name) {
attempted.push(app_id.clone());
match orchestrator.install(&app_id).await {
Ok(created_name) => {
install_log(&format!(
"UPDATE ORCH RECREATE OK: {} — container={} app_id={} created={}",
package_id, container_name, app_id, created_name
))
.await;
return Ok(());
}
Err(e) if is_unknown_app_id_error(&e) => {
continue;
}
Err(e) => {
return Err(e.context(format!(
"orchestrator recreate failed for update {} (container={}, app_id={})",
package_id, container_name, app_id
)));
}
}
}
Err(anyhow::anyhow!(
"No manifest mapping found while recreating {} during update {} (attempted app_ids: {})",
container_name,
package_id,
attempted.join(", ")
))
}
/// 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 cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if archipelago_container::image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
cmd.kill_on_drop(true);
let mut child = cmd
.arg(image)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to start image pull")?;
let progress_task = 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();
Some(tokio::spawn(async move {
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;
}
}
}))
} else {
None
};
let status = match tokio::time::timeout(PODMAN_UPDATE_PULL_TIMEOUT, child.wait()).await {
Ok(result) => result.context("Failed to wait for image pull")?,
Err(_) => {
let _ = child.kill().await;
return Err(anyhow::anyhow!(
"podman pull {} timed out after {}s",
image,
PODMAN_UPDATE_PULL_TIMEOUT.as_secs()
));
}
};
if let Some(task) = progress_task {
let _ = task.await;
}
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 mut stack_images = image_versions::pinned_images_for_stack(package_id);
if stack_images.is_empty() {
// Single container app — pinned_primary already prefers the catalog.
return vec![(package_id.to_string(), pinned_primary.to_string())];
}
// Stack app: override per-container images with the catalog where it
// provides them; components the catalog omits keep the image-versions.sh
// pin. This lets a single component (e.g. the IndeeHub frontend) be
// bumped without touching the rest of the stack.
let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id);
if !catalog_images.is_empty() {
for (name, image) in stack_images.iter_mut() {
if let Some(catalog_image) = catalog_images.get(name) {
*image = catalog_image.clone();
}
}
}
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 (forward path ran `podman rm`).
// Recreate via orchestrator-first path with legacy fallback.
if let Err(recreate_err) =
self.recreate_container_for_update(package_id, name).await
{
error!(
"Rollback: failed to recreate {} during rollback of {}: {}",
name, package_id, recreate_err
);
}
}
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;
}
}
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && !uses_legacy_update_flow(package_id)
}
fn orchestrator_update_app_id(package_id: &str) -> &str {
match package_id {
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
}
fn uses_legacy_update_flow(package_id: &str) -> bool {
matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("unknown app_id"))
}
fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match container_name {
"bitcoin-knots" | "bitcoin-core" => {
push("bitcoin-knots");
push("bitcoin-core");
}
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
"archy-electrs-ui" => push("electrs-ui"),
"mempool" => {
push("archy-mempool-web");
push("mempool");
}
_ => {}
}
push(container_name);
if let Some(stripped) = container_name.strip_prefix("archy-") {
push(stripped);
}
out
}
#[cfg(test)]
mod tests {
use super::{
candidate_app_ids_for_container, orchestrator_update_app_id,
should_try_orchestrator_update, uses_legacy_update_flow,
};
#[test]
fn legacy_flow_for_stack_apps() {
for app in ["immich", "penpot", "indeedhub"] {
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
}
}
#[test]
fn orchestrator_flow_for_single_apps() {
for app in [
"lnd",
"bitcoin-core",
"searxng",
"grafana",
"btcpay-server",
"mempool",
"fedimint",
] {
assert!(
!uses_legacy_update_flow(app),
"{app} should be orchestrator-first"
);
assert!(
should_try_orchestrator_update(app, true),
"{app} should use orchestrator when available"
);
}
}
#[test]
fn no_orchestrator_means_no_orchestrator_flow() {
assert!(!should_try_orchestrator_update("lnd", false));
assert!(!should_try_orchestrator_update("btcpay-server", false));
}
#[test]
fn container_name_candidates_cover_common_aliases() {
assert_eq!(
candidate_app_ids_for_container("bitcoin-knots"),
vec!["bitcoin-knots", "bitcoin-core"]
);
assert_eq!(
candidate_app_ids_for_container("archy-bitcoin-ui"),
vec!["bitcoin-ui", "archy-bitcoin-ui"]
);
assert_eq!(
candidate_app_ids_for_container("mempool"),
vec!["archy-mempool-web", "mempool"]
);
assert_eq!(
candidate_app_ids_for_container("archy-mempool-db"),
vec!["archy-mempool-db", "mempool-db"]
);
}
#[test]
fn update_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-knots");
assert_eq!(orchestrator_update_app_id("bitcoin-core"), "bitcoin-core");
assert_eq!(orchestrator_update_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_update_app_id("mempool-electrs"), "electrumx");
assert_eq!(orchestrator_update_app_id("fedimint"), "fedimint");
}
}
@@ -0,0 +1,18 @@
use anyhow::Result;
/// Validate that a package/app ID is safe (lowercase alphanumeric + hyphens, 1-64 chars).
pub(in crate::api::rpc) fn validate_app_id(id: &str) -> Result<()> {
if id.is_empty() || id.len() > 64 {
anyhow::bail!("Invalid app id: must be 1-64 characters");
}
if !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
anyhow::bail!("Invalid app id: only lowercase letters, digits, and hyphens allowed");
}
if id.starts_with('-') {
anyhow::bail!("Invalid app id: must not start with a hyphen");
}
Ok(())
}