Archipelago — open-source initial import
This commit is contained in:
@@ -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(¤t, 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user