feat(orchestrator): complete container migration and release hardening

This commit is contained in:
archipelago
2026-04-28 15:00:58 -04:00
parent 4d05705315
commit 8f83b37d51
94 changed files with 5034 additions and 1003 deletions
+1 -2
View File
@@ -130,8 +130,7 @@ impl ApiHandler {
/// persisted a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) =
crate::container::registry::load_registries(&self.config.data_dir).await
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
{
for reg in config.active_registries() {
let scheme = if reg.tls_verify { "https" } else { "http" };
+27 -46
View File
@@ -408,9 +408,8 @@ async fn bitcoin_rpc_post_with_retry_cfg<T: serde::de::DeserializeOwned>(
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"));
}
Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")
}))
Err(last_err
.unwrap_or_else(|| anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")))
}
#[cfg(test)]
@@ -428,7 +427,11 @@ mod tests {
/// oneshot cancel channel).
async fn spawn_mock<F, Fut>(
handler: F,
) -> (String, tokio::task::JoinHandle<()>, tokio::sync::oneshot::Sender<()>)
) -> (
String,
tokio::task::JoinHandle<()>,
tokio::sync::oneshot::Sender<()>,
)
where
F: Fn(Request<Body>) -> Fut + Send + Sync + Clone + 'static,
Fut: std::future::Future<Output = Response<Body>> + Send + 'static,
@@ -447,7 +450,9 @@ mod tests {
let url = format!("http://{}", server.local_addr());
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let graceful = server.with_graceful_shutdown(async { let _ = rx.await; });
let graceful = server.with_graceful_shutdown(async {
let _ = rx.await;
});
let _ = graceful.await;
});
(url, handle, tx)
@@ -477,16 +482,10 @@ mod tests {
.await;
let client = reqwest::Client::builder().build().unwrap();
let v: u64 = bitcoin_rpc_post_with_retry(
&client,
&url,
"user",
"pass",
"getblockcount",
&[],
)
.await
.expect("should succeed");
let v: u64 =
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[])
.await
.expect("should succeed");
assert_eq!(v, 42);
assert_eq!(count.load(Ordering::SeqCst), 1, "should not have retried");
}
@@ -512,15 +511,8 @@ mod tests {
.await;
let client = reqwest::Client::builder().build().unwrap();
let result: Result<u64> = bitcoin_rpc_post_with_retry(
&client,
&url,
"user",
"pass",
"getblockcount",
&[],
)
.await;
let result: Result<u64> =
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
assert!(result.is_err(), "non-JSON response should error out");
assert_eq!(
count.load(Ordering::SeqCst),
@@ -544,15 +536,9 @@ mod tests {
.build()
.unwrap();
let start = std::time::Instant::now();
let result: Result<u64> = bitcoin_rpc_post_with_retry(
&client,
&closed_url,
"user",
"pass",
"getblockcount",
&[],
)
.await;
let result: Result<u64> =
bitcoin_rpc_post_with_retry(&client, &closed_url, "user", "pass", "getblockcount", &[])
.await;
let elapsed = start.elapsed();
assert!(result.is_err(), "connect-refused should exhaust retries");
let min_backoff: std::time::Duration = BITCOIN_RPC_BACKOFFS.iter().sum();
@@ -629,15 +615,8 @@ mod tests {
.await;
let client = reqwest::Client::builder().build().unwrap();
let result: Result<u64> = bitcoin_rpc_post_with_retry(
&client,
&url,
"user",
"pass",
"getblockcount",
&[],
)
.await;
let result: Result<u64> =
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
assert!(result.is_err());
assert_eq!(
count.load(Ordering::SeqCst),
@@ -652,12 +631,14 @@ mod tests {
#[test]
fn retry_budget_invariants() {
assert_eq!(BITCOIN_RPC_MAX_ATTEMPTS, 3);
assert_eq!(BITCOIN_RPC_BACKOFFS.len(), (BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize);
assert_eq!(
BITCOIN_RPC_BACKOFFS.len(),
(BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize
);
// Total wall-time ceiling:
// 3 attempts * 15s + (0.5s + 1.5s) backoff = 47s
let total: std::time::Duration =
BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
let total: std::time::Duration = BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
assert!(total < std::time::Duration::from_secs(60));
}
}
+110 -7
View File
@@ -78,7 +78,8 @@ impl RpcHandler {
// spawn_transitional returns as soon as the background task is
// launched (<1s). The UI sees Starting… immediately via WebSocket.
self.spawn_transitional(Op::Start, app_id.to_string()).await?;
self.spawn_transitional(Op::Start, app_id.to_string())
.await?;
Ok(serde_json::json!({ "status": "starting" }))
}
@@ -102,7 +103,8 @@ impl RpcHandler {
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
// background; the RPC returns now with "stopping".
self.spawn_transitional(Op::Stop, app_id.to_string()).await?;
self.spawn_transitional(Op::Stop, app_id.to_string())
.await?;
Ok(serde_json::json!({ "status": "stopping" }))
}
@@ -299,12 +301,26 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let status = orchestrator
.status(app_id)
.await
.context("Failed to get container status")?;
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.status(&candidate).await {
Ok(status) => return Ok(serde_json::to_value(status)?),
Err(e) => last_err = Some(e),
}
}
Ok(serde_json::to_value(status)?)
// Fallback for alias drift: query podman directly by likely container
// names so status checks stay useful during migration.
for name in status_container_name_candidates(app_id) {
if let Some(v) = inspect_container_state_value(&name).await {
return Ok(v);
}
}
if let Some(e) = last_err {
return Err(e.context("Failed to get container status"));
}
Err(anyhow::anyhow!("Failed to get container status"))
}
pub(super) async fn handle_container_logs(
@@ -408,3 +424,90 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map))
}
}
fn status_app_id_candidates(app_id: &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 app_id {
"bitcoin-knots" => {
push("bitcoin-knots");
push("bitcoin-core");
push("bitcoin");
}
"bitcoin-core" | "bitcoin" => {
push("bitcoin-core");
push("bitcoin-knots");
push("bitcoin");
}
"electrs" | "mempool-electrs" => {
push("electrs");
push("mempool-electrs");
push("electrumx");
}
_ => push(app_id),
}
out
}
fn status_container_name_candidates(app_id: &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 app_id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
"bitcoin-ui" => push("archy-bitcoin-ui"),
"lnd-ui" => push("archy-lnd-ui"),
"electrs-ui" => push("archy-electrs-ui"),
"electrs" | "mempool-electrs" => push("electrumx"),
_ => {}
}
push(app_id);
if let Some(stripped) = app_id.strip_prefix("archy-") {
push(stripped);
} else {
push(&format!("archy-{}", app_id));
}
out
}
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
let out = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}}",
])
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
if line.is_empty() {
return None;
}
let mut parts = line.split_whitespace();
let status = parts.next().unwrap_or("unknown");
let running = parts.next().unwrap_or("false") == "true";
Some(serde_json::json!({
"name": name,
"status": status,
"state": status,
"running": running,
}))
}
+15 -10
View File
@@ -231,8 +231,7 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
@@ -287,10 +286,13 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
debug!(
"Browsing peer content at {} (fips={})",
onion,
fips_npub.is_some()
);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
@@ -348,8 +350,7 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
@@ -407,11 +408,15 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/preview", content_id);
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
debug!(
"Fetching content preview from {}{} (fips={})",
onion,
path,
fips_npub.is_some()
);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
@@ -403,7 +403,10 @@ impl RpcHandler {
});
let own_fips_npub = match own_fips_npub {
Some(n) => Some(n),
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
None => crate::fips::service::read_upstream_npub()
.await
.ok()
.flatten(),
};
let state = federation::build_local_state(
@@ -461,8 +464,7 @@ impl RpcHandler {
// the entry causes sync loops where the node syncs with itself
// forever. Drop it quietly — no useful recovery path.
let (own_data, _) = self.state_manager.get_snapshot().await;
let own_did_result =
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_did_result = identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_onion_trim = own_data
.server_info
.tor_address
@@ -568,11 +570,7 @@ impl RpcHandler {
let new_peer_did = did.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Err(e) = crate::federation::sync_with_peer_by_did(
&data_dir,
&new_peer_did,
)
.await
if let Err(e) = crate::federation::sync_with_peer_by_did(&data_dir, &new_peer_did).await
{
tracing::debug!(
peer_did = %new_peer_did,
+1 -2
View File
@@ -169,8 +169,7 @@ impl RpcHandler {
if !anchor.address.contains(':') {
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
}
let list =
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
// Push just the newly-added anchor into the running daemon so
// the user sees effect without waiting for the periodic apply.
let results = fips::anchors::apply(&[anchor]).await;
@@ -742,24 +742,25 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
vec![single.to_string()]
} else {
// Default: every enabled relay in the user's Manage Relays list.
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
.await
.unwrap_or_default();
statuses
.into_iter()
.filter(|s| s.enabled)
.map(|s| s.url)
.collect()
};
let relay_urls: Vec<String> =
if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
vec![single.to_string()]
} else {
// Default: every enabled relay in the user's Manage Relays list.
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
.await
.unwrap_or_default();
statuses
.into_iter()
.filter(|s| s.enabled)
.map(|s| s.url)
.collect()
};
if relay_urls.is_empty() {
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
+1 -1
View File
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::{LndAmount, LndBalanceResponse, read_lnd_admin_macaroon};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse};
#[derive(Debug, Serialize)]
struct LndInfo {
+1 -1
View File
@@ -4,7 +4,7 @@ mod payments;
mod wallet;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result, anyhow};
use anyhow::{anyhow, Context, Result};
/// Canonical on-host path for LND's admin macaroon.
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
@@ -761,7 +761,9 @@ impl RpcHandler {
.await
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
let meta = blob_store.put(&bytes, &mime, filename_hint, None, false).await?;
let meta = blob_store
.put(&bytes, &mime, filename_hint, None, false)
.await?;
if meta.cid != cid {
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
}
@@ -62,9 +62,7 @@ impl RpcHandler {
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing
| PackageState::Removing
| PackageState::Updating
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
@@ -114,8 +112,7 @@ impl RpcHandler {
}
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e))
.await;
install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e)).await;
// No pre-state to revert to — remove the entry entirely so
// the UI shows the app as not installed. The next package
// scan will re-create it only if podman actually has a
@@ -156,9 +153,7 @@ impl RpcHandler {
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing
| PackageState::Removing
| PackageState::Updating
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
@@ -185,11 +180,7 @@ impl RpcHandler {
}
Err(e) => {
error!("package.uninstall {} failed: {:#}", package_id_spawn, e);
install_log(&format!(
"UNINSTALL FAIL: {} — {:#}",
package_id_spawn, e
))
.await;
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 {
@@ -234,9 +225,7 @@ impl RpcHandler {
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing
| PackageState::Removing
| PackageState::Updating
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
@@ -279,14 +268,12 @@ impl RpcHandler {
}
Err(e) => {
error!("package.update {} failed: {:#}", package_id_spawn, e);
install_log(&format!("UPDATE FAIL: {}{:#}", package_id_spawn, e))
.await;
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;
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
}
}
}
+22 -23
View File
@@ -174,7 +174,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
}
"nextcloud" => (
"curl -sf http://localhost:80/status.php || exit 1",
"curl -s -o /dev/null http://localhost:80/status.php || exit 1",
"30s",
"3",
),
@@ -194,7 +194,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
"filebrowser" => ("curl -sf http://localhost:80/health || exit 1", "30s", "3"),
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
"botfights" => (
"node -e \"fetch(\\\"http://127.0.0.1:9100/api/health\\\").then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"",
"30s",
"3",
),
"searxng" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s",
@@ -210,11 +215,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"portainer" => (
"curl -sf http://localhost:9000/api/status || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
@@ -402,7 +403,6 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
format!("{}/mempool", base),
format!("{}/mysql-mempool", base),
format!("{}/electrumx", base),
format!("{}/mempool-electrs", base),
],
"fedimint" => vec![
format!("{}/fedimint", base),
@@ -533,9 +533,7 @@ pub(super) async fn get_app_config(
"--bitcoin.node=bitcoind".to_string(),
format!("--bitcoind.rpcuser={}", rpc_user),
format!("--bitcoind.rpcpass={}", rpc_pass),
"--bitcoind.rpchost=host.containers.internal:8332".to_string(),
"--bitcoind.zmqpubrawblock=tcp://host.containers.internal:28332".to_string(),
"--bitcoind.zmqpubrawtx=tcp://host.containers.internal:28333".to_string(),
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
"--rpclisten=0.0.0.0:10009".to_string(),
"--restlisten=0.0.0.0:8080".to_string(),
"--listen=0.0.0.0:9735".to_string(),
@@ -549,7 +547,8 @@ pub(super) async fn get_app_config(
"BTCPAY_PROTOCOL=http".to_string(),
format!("BTCPAY_HOST={}:23000", host_ip),
"BTCPAY_CHAINS=btc".to_string(),
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
"BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838".to_string(),
"BTCPAY_BTCRPCURL=http://bitcoin-knots:8332".to_string(),
format!("BTCPAY_BTCRPCUSER={}", rpc_user),
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
format!("BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
@@ -561,7 +560,7 @@ pub(super) async fn get_app_config(
"mempool" | "mempool-web" => (
vec!["4080:8080".to_string()],
vec![],
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
vec!["BACKEND_MAINNET_HTTP_HOST=mempool-api".to_string()],
None,
None,
),
@@ -570,12 +569,12 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=host.containers.internal".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", host_ip),
"CORE_RPC_HOST=bitcoin-knots".to_string(),
"CORE_RPC_PORT=8332".to_string(),
format!("CORE_RPC_USERNAME={}", rpc_user),
"CORE_RPC_USERNAME=archipelago".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
@@ -592,7 +591,7 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
vec![
format!(
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
"DAEMON_URL=http://{}:{}@bitcoin-knots:8332/",
rpc_user, rpc_pass
),
"COIN=Bitcoin".to_string(),
@@ -610,7 +609,7 @@ pub(super) async fn get_app_config(
"MYSQL_DATABASE=mempool".to_string(),
"MYSQL_USER=mempool".to_string(),
format!("MYSQL_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mempool-db-root-password", "rootpass")),
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mysql-root-db-password", "rootpass")),
],
None,
None,
@@ -752,14 +751,14 @@ pub(super) async fn get_app_config(
vec!["9000:9000".to_string()],
vec![
"/var/lib/archipelago/portainer:/data".to_string(),
"/var/run/podman/podman.sock:/var/run/docker.sock".to_string(),
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
],
vec![],
None,
None,
),
"uptime-kuma" => (
vec!["3001:3001".to_string()],
vec!["3002:3001".to_string()],
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec!["TZ=UTC".to_string()],
None,
@@ -791,13 +790,13 @@ pub(super) async fn get_app_config(
"FM_BIND_UI=0.0.0.0:8175".to_string(),
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
format!("FM_API_URL=ws://{}:8174", host_ip),
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
"FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string(),
],
None,
Some(vec![
"--data-dir".to_string(),
"/data".to_string(),
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
]),
),
"fedimint-gateway" => {
@@ -821,7 +820,7 @@ pub(super) async fn get_app_config(
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
format!("http://{}:8332", host_ip),
"http://bitcoin-knots:8332".to_string(),
"--bitcoind-username".to_string(),
rpc_user.to_string(),
"--bitcoind-password".to_string(),
+232 -74
View File
@@ -98,7 +98,8 @@ impl RpcHandler {
}
// Phase: Preparing — validating deps and configs before any slow I/O.
self.set_install_phase(package_id, InstallPhase::Preparing).await;
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
// Dependency checks
let deps = detect_running_deps().await?;
@@ -179,6 +180,56 @@ impl RpcHandler {
}));
}
// Preferred path for apps already modeled in the production orchestrator.
// Keep legacy install flow as default while migration is in progress.
if should_try_orchestrator_install(package_id, self.orchestrator.is_some()) {
let orchestrator_app_id = orchestrator_install_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
install_log(&format!(
"INSTALL ORCH: {} — attempting orchestrator install as {}",
package_id, orchestrator_app_id
))
.await;
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.install(orchestrator_app_id).await {
Ok(container_name) => {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
install_log(&format!(
"INSTALL ORCH OK: {} (app={}) — container={}",
package_id, orchestrator_app_id, container_name
))
.await;
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"container_name": container_name,
"message": format!("Package {} installed and started", package_id)
}));
}
Err(e) if is_unknown_app_id_error(&e) => {
info!(
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
package_id
);
install_log(&format!(
"INSTALL ORCH SKIP: {} — unknown app_id, using legacy flow",
package_id
))
.await;
}
Err(e) => {
install_log(&format!("INSTALL ORCH FAIL: {}{}", package_id, e)).await;
return Err(
e.context(format!("Orchestrator install {} failed", package_id))
);
}
}
}
}
// Pull or verify image
install_log(&format!(
"INSTALL PULL: {} — pulling image {}",
@@ -189,7 +240,8 @@ impl RpcHandler {
// parseable progress on a piped stderr, so the UI shows an
// indeterminate "Downloading image…" at this fixed percentage
// until pull completes.
self.set_install_phase(package_id, InstallPhase::PullingImage).await;
self.set_install_phase(package_id, InstallPhase::PullingImage)
.await;
let has_local_fallback = self.pull_or_verify_image(package_id, docker_image).await?;
install_log(&format!(
"INSTALL PULL OK: {} — image ready (local_fallback={})",
@@ -199,7 +251,8 @@ impl RpcHandler {
// Phase: CreatingContainer — image is local, now writing configs,
// data directories, chowning to container UID, building the run
// argv. Fast (sub-second to a few seconds).
self.set_install_phase(package_id, InstallPhase::CreatingContainer).await;
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
// Normalize container name for legacy aliases
let container_name = match package_id {
@@ -392,6 +445,14 @@ impl RpcHandler {
run_args.push(&mem_arg);
run_args.push("--cpus=2");
// Uptime Kuma image entrypoint (`extra/entrypoint.sh`) attempts
// `setpriv --clear-groups` and fails under our rootless + cap-drop
// defaults. Run the server directly via dumb-init to keep startup
// stable on production nodes.
if package_id == "uptime-kuma" {
run_args.push("--entrypoint=/usr/bin/dumb-init");
}
// Health checks
let health_args = get_health_check_args(package_id, &rpc_pass);
for arg in &health_args {
@@ -451,7 +512,8 @@ impl RpcHandler {
// Phase: StartingContainer — podman run accepted. Next we poll
// inspect until State.Status == running (up to 60s).
self.set_install_phase(package_id, InstallPhase::StartingContainer).await;
self.set_install_phase(package_id, InstallPhase::StartingContainer)
.await;
// Post-start health verification: wait up to 60s for container to be running
let mut container_running = false;
@@ -460,7 +522,8 @@ impl RpcHandler {
// container hasn't come up yet, so the phase label changes
// from "Starting container" to "Waiting for healthy".
if i == 1 {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy).await;
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
@@ -524,7 +587,8 @@ impl RpcHandler {
// Phase: PostInstall — container is up and running. Now any
// app-specific post-install (chain init, wallet setup, waiting
// for a first block). Varies by app; some are no-ops.
self.set_install_phase(package_id, InstallPhase::PostInstall).await;
self.set_install_phase(package_id, InstallPhase::PostInstall)
.await;
// Post-install hooks — await completion before returning success
self.run_post_install_hooks(package_id).await;
@@ -798,20 +862,30 @@ impl RpcHandler {
/// Create data directories for volume mounts under /var/lib/archipelago/.
/// Get the mapped host UID for a container's internal UID.
/// Rootless podman maps container UIDs: host_uid = subuid_start + container_uid
/// Default subuid start for archipelago user is 100000.
/// Rootless podman UID maps commonly look like:
/// container 0 -> host real uid (e.g. 1000)
/// container 1.. -> host subuid range starting at 100000
/// So for uid>=1, host_uid = 99999 + container_uid.
fn mapped_uid(package_id: &str) -> u32 {
let container_uid = match package_id {
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => 101,
"grafana" => 472,
"lnd" => 1000,
"mariadb" | "mysql" | "mysql-mempool" | "archy-mempool-db" => 999,
"postgres" | "btcpay-postgres" | "immich-postgres"
| "archy-btcpay-db" | "nextcloud-db" => 70,
"postgres" | "immich-postgres" | "nextcloud-db" => 70,
// Current BTCPay Postgres image runs as uid 999 inside the
// container, so its rootless host-mapped uid is 100998.
"btcpay-postgres" | "archy-btcpay-db" => 999,
"electrumx" | "electrs" => 1000,
_ => 0, // Most containers run as root (UID 0)
};
100000 + container_uid
if container_uid == 0 {
// Archipelago daemon runs as rootless user (typically uid 1000).
// Container uid 0 maps to that real host uid.
1000
} else {
99999 + container_uid
}
}
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
@@ -824,36 +898,44 @@ impl RpcHandler {
debug!("Creating directory: {} (owner: {})", host_path, uid_str);
// Create directory directly (service has ReadWritePaths access).
// sudo is blocked by NoNewPrivileges=yes in the systemd service.
if let Err(e) = std::fs::create_dir_all(host_path) {
tracing::warn!("Failed to create directory {}: {}", host_path, e);
}
// Set ownership to the mapped UID for rootless podman.
// Try sudo chown first (works on LUKS), fall back to podman unshare.
// Try sudo chown first, then fall back to podman unshare
// for subuid-mapped UIDs only.
let host_uid = format!("{}:{}", uid, uid);
let sudo_result = tokio::process::Command::new("sudo")
.args(["chown", "-R", &host_uid, host_path])
.output()
.await;
let sudo_ok = sudo_result.as_ref().is_ok_and(|o| o.status.success());
if !sudo_ok {
// Fallback: podman unshare (works on non-LUKS ext4)
let container_uid = uid - 100000;
let container_uid_str = format!("{}:{}", container_uid, container_uid);
let chown_result = tokio::process::Command::new("podman")
.args(["unshare", "chown", "-R", &container_uid_str, host_path])
.output()
.await;
match chown_result {
Ok(out) if !out.status.success() => {
tracing::warn!(
"chown failed for {} (both sudo and podman unshare)",
host_path,
);
if uid >= 100000 {
let container_uid = uid - 100000;
let container_uid_str = format!("{}:{}", container_uid, container_uid);
let chown_result = tokio::process::Command::new("podman")
.args(["unshare", "chown", "-R", &container_uid_str, host_path])
.output()
.await;
match chown_result {
Ok(out) if !out.status.success() => {
tracing::warn!(
"chown failed for {} (both sudo and podman unshare)",
host_path,
);
}
Err(e) => tracing::warn!("Failed to chown {}: {}", host_path, e),
_ => {}
}
Err(e) => tracing::warn!("Failed to chown {}: {}", host_path, e),
_ => {}
} else {
tracing::warn!(
"chown fallback skipped for {}: host uid {} has no subuid mapping",
host_path,
uid
);
}
}
}
@@ -1221,54 +1303,18 @@ autopilot.active=false\n",
}
}
// Gitea: deploy nginx proxy on port 3000 to strip X-Frame-Options for iframe embedding.
// Gitea container runs on 3001, nginx proxies 3000->3001 removing the header.
// Gitea: keep it on its native host port (3001) and serve it under
// /app/gitea/ via the main Archipelago nginx config. Avoids colliding
// with Grafana, which also uses host port 3000.
if package_id == "gitea" {
let nginx_conf = r#"# Gitea iframe proxy — strips X-Frame-Options for Archipelago iframe
server {
listen 3000;
server_name _;
client_max_body_size 1G;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
}
"#;
let conf_path = "/etc/nginx/conf.d/gitea-iframe.conf";
if let Err(e) = tokio::fs::write(conf_path, nginx_conf).await {
tracing::warn!("Failed to write gitea nginx conf: {}", e);
} else {
let reload = tokio::process::Command::new("nginx")
.args(["-s", "reload"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
info!("Gitea: nginx iframe proxy deployed on port 3000");
}
Ok(o) => tracing::warn!(
"Gitea nginx reload failed: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => tracing::warn!("Gitea nginx reload error: {}", e),
}
}
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
// Set ROOT_URL in Gitea config — port 3000 is the nginx iframe proxy,
// which is the public-facing port users and the UI iframe access.
// Set ROOT_URL to the UI path-based route so links/assets stay
// anchored under Archipelago's app proxy endpoint.
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3000/|' /data/gitea/conf/app.ini || true", host_ip)])
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}/app/gitea/|' /data/gitea/conf/app.ini || true", host_ip)])
.output()
.await;
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
@@ -1277,8 +1323,15 @@ server {
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
.output()
.await;
// Reload main nginx so /app/gitea/ routing changes take effect.
let _ = tokio::process::Command::new("nginx")
.args(["-s", "reload"])
.output()
.await;
info!(
"Gitea: ROOT_URL set to http://{}:3000/, X_FRAME_OPTIONS cleared",
"Gitea: ROOT_URL set to http://{}/app/gitea/, X_FRAME_OPTIONS cleared",
host_ip
);
}
@@ -1565,9 +1618,15 @@ server {
// Reassign priorities: target = 0, everyone else = 10, 20, 30…
// in their existing priority order.
let target_url = url.to_string();
config.registries.sort_by_key(|r| (r.url != target_url, r.priority));
config
.registries
.sort_by_key(|r| (r.url != target_url, r.priority));
for (i, r) in config.registries.iter_mut().enumerate() {
r.priority = if r.url == target_url { 0 } else { (i as u32) * 10 };
r.priority = if r.url == target_url {
0
} else {
(i as u32) * 10
};
}
crate::container::registry::save_registries(&self.config.data_dir, &config).await?;
@@ -1695,3 +1754,102 @@ async fn resolve_host_gateway() -> String {
// Last resort
"--add-host=host.containers.internal:10.0.2.2".to_string()
}
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && uses_orchestrator_install_flow(package_id)
}
fn orchestrator_install_app_id(package_id: &str) -> &str {
match package_id {
"bitcoin-knots" => "bitcoin-core",
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
}
fn uses_orchestrator_install_flow(package_id: &str) -> bool {
matches!(
package_id,
// Step 7 UI apps
"bitcoin-ui"
| "electrs-ui"
| "lnd-ui"
// Step 8b backend ports
| "bitcoin-core"
| "bitcoin-knots"
| "lnd"
| "fedimint"
| "fedimint-gateway"
| "filebrowser"
| "electrumx"
| "electrs"
| "mempool-electrs"
| "archy-mempool-db"
| "mempool-api"
| "archy-mempool-web"
| "archy-btcpay-db"
| "archy-nbxplorer"
| "btcpay-server"
)
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("unknown app_id"))
}
#[cfg(test)]
mod tests {
use super::{
orchestrator_install_app_id, should_try_orchestrator_install,
uses_orchestrator_install_flow,
};
#[test]
fn orchestrator_install_allowlist_includes_ported_backends() {
for app in [
"bitcoin-ui",
"electrs-ui",
"lnd-ui",
"bitcoin-core",
"bitcoin-knots",
"lnd",
"fedimint",
"fedimint-gateway",
"filebrowser",
"electrumx",
"electrs",
"mempool-electrs",
"archy-mempool-db",
"mempool-api",
"archy-mempool-web",
"archy-btcpay-db",
"archy-nbxplorer",
"btcpay-server",
] {
assert!(uses_orchestrator_install_flow(app));
assert!(should_try_orchestrator_install(app, true));
}
}
#[test]
fn non_allowlisted_apps_stay_legacy_install() {
for app in ["searxng", "mempool", "indeedhub", "immich", "penpot"] {
assert!(!uses_orchestrator_install_flow(app));
assert!(!should_try_orchestrator_install(app, true));
}
}
#[test]
fn missing_orchestrator_disables_orchestrator_install() {
assert!(!should_try_orchestrator_install("bitcoin-ui", false));
}
#[test]
fn install_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_install_app_id("bitcoin-knots"), "bitcoin-core");
assert_eq!(orchestrator_install_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("lnd"), "lnd");
}
}
+1 -1
View File
@@ -10,5 +10,5 @@ mod update;
mod validation;
// Re-export items needed by sibling modules (container.rs, security.rs, transitional.rs)
pub(super) use validation::validate_app_id;
pub(in crate::api::rpc) use install::install_log;
pub(super) use validation::validate_app_id;
@@ -20,10 +20,7 @@ impl RpcHandler {
.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);
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
@@ -95,10 +92,7 @@ impl RpcHandler {
.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);
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
@@ -312,7 +312,8 @@ impl RpcHandler {
}
}
self.set_uninstall_stage(package_id, "Cleaning up volumes").await;
self.set_uninstall_stage(package_id, "Cleaning up volumes")
.await;
// Clean up dangling volumes associated with removed containers
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
@@ -341,7 +342,8 @@ impl RpcHandler {
// Clean data directories unless preserve_data
if !preserve_data {
self.set_uninstall_stage(package_id, "Removing app data").await;
self.set_uninstall_stage(package_id, "Removing app data")
.await;
let data_dirs = get_data_dirs_for_app(package_id);
for dir in &data_dirs {
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
@@ -731,4 +733,3 @@ async fn set_package_state(
}
}
}
+120 -11
View File
@@ -60,6 +60,70 @@ async fn adopt_stack_if_exists(
})))
}
async fn install_stack_via_orchestrator(
handler: &RpcHandler,
stack_name: &str,
app_ids: &[&str],
) -> Result<Option<serde_json::Value>> {
let Some(orchestrator) = handler.orchestrator.as_ref() else {
return Ok(None);
};
install_log(&format!(
"INSTALL ORCH: {} stack — attempting orchestrator install of [{}]",
stack_name,
app_ids.join(", ")
))
.await;
for app_id in app_ids {
match orchestrator.install(app_id).await {
Ok(container_name) => {
install_log(&format!(
"INSTALL ORCH: {} stack — app {} installed as {}",
stack_name, app_id, container_name
))
.await;
}
Err(e) if e.to_string().contains("unknown app_id") => {
install_log(&format!(
"INSTALL ORCH SKIP: {} stack — app {} unknown, falling back to legacy stack installer",
stack_name, app_id
))
.await;
return Ok(None);
}
Err(e) => {
install_log(&format!(
"INSTALL ORCH FAIL: {} stack — app {} failed: {}",
stack_name, app_id, e
))
.await;
return Err(e.context(format!(
"orchestrator stack install {} failed at app {}",
stack_name, app_id
)));
}
}
}
install_log(&format!("INSTALL ORCH OK: {} stack", stack_name)).await;
Ok(Some(serde_json::json!({
"success": true,
"package_id": stack_name,
"message": format!("{} stack installed and started", stack_name),
"path": "orchestrator"
})))
}
fn btcpay_stack_app_ids() -> &'static [&'static str] {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
fn mempool_stack_app_ids() -> &'static [&'static str] {
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
}
const REGISTRY: &str = "git.tx1138.com/lfg2025";
/// Pull an image with retry and exponential backoff (3 attempts).
@@ -136,7 +200,7 @@ impl RpcHandler {
let images = [
"git.tx1138.com/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
"git.tx1138.com/lfg2025/valkey:7-alpine",
"docker.io/valkey/valkey:7-alpine",
"git.tx1138.com/lfg2025/immich-server:release",
];
for img in &images {
@@ -152,6 +216,16 @@ impl RpcHandler {
])
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"1000:1000",
"/var/lib/archipelago/immich",
"/var/lib/archipelago/immich-db",
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "immich-net"])
.output()
@@ -210,13 +284,15 @@ impl RpcHandler {
"--network-alias",
"immich_redis",
"--cap-drop=ALL",
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=128m",
"--pids-limit=2048",
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
"git.tx1138.com/lfg2025/valkey:7-alpine",
"docker.io/valkey/valkey:7-alpine",
])
.output()
.await;
@@ -273,7 +349,6 @@ impl RpcHandler {
}))
}
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
if let Some(adopted) = adopt_stack_if_exists(
@@ -286,6 +361,12 @@ impl RpcHandler {
return Ok(adopted);
}
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
{
return Ok(orchestrated);
}
// Dependency check: Bitcoin must be running
let deps = super::dependencies::detect_running_deps().await?;
super::dependencies::check_install_deps("btcpay-server", &deps)?;
@@ -473,25 +554,36 @@ impl RpcHandler {
/// Install Mempool stack (mariadb + mempool-api + mempool-web).
pub(super) async fn install_mempool_stack(&self) -> Result<serde_json::Value> {
if let Some(adopted) = adopt_stack_if_exists(
"archy-mempool-web",
"mempool",
&["archy-mempool-db", "archy-mempool-api", "archy-mempool-web"],
"mempool",
&[
"archy-mempool-db",
"mempool-api",
"mempool",
"archy-mempool-web",
"archy-mempool-api",
],
)
.await?
{
return Ok(adopted);
}
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "mempool", mempool_stack_app_ids()).await?
{
return Ok(orchestrated);
}
// Dependency check: Bitcoin + ElectrumX must be running
let deps = super::dependencies::detect_running_deps().await?;
super::dependencies::check_install_deps("mempool", &deps)?;
let (_, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
install_log("INSTALL START: mempool (stack: mariadb + mempool-api + mempool-web)").await;
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let db_pass = super::config::read_or_generate_secret("mempool-db-password").await;
let root_pass = super::config::read_or_generate_secret("mempool-db-root-password").await;
let root_pass = super::config::read_or_generate_secret("mysql-root-db-password").await;
let images = [
&format!("{}/mariadb:11.4.10", REGISTRY),
@@ -594,17 +686,17 @@ impl RpcHandler {
"-e",
"MEMPOOL_BACKEND=electrum",
"-e",
"ELECTRUM_HOST=host.containers.internal",
"ELECTRUM_HOST=electrumx",
"-e",
"ELECTRUM_PORT=50001",
"-e",
"ELECTRUM_TLS_ENABLED=false",
"-e",
"CORE_RPC_HOST=host.containers.internal",
"CORE_RPC_HOST=bitcoin-knots",
"-e",
"CORE_RPC_PORT=8332",
"-e",
&format!("CORE_RPC_USERNAME={}", rpc_user),
"CORE_RPC_USERNAME=archipelago",
"-e",
&format!("CORE_RPC_PASSWORD={}", rpc_pass),
"-e",
@@ -965,3 +1057,20 @@ impl RpcHandler {
}))
}
}
#[cfg(test)]
mod tests {
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
#[test]
fn stack_app_id_sets_match_migration_manifests() {
assert_eq!(
btcpay_stack_app_ids(),
["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
);
assert_eq!(
mempool_stack_app_ids(),
["archy-mempool-db", "mempool-api", "archy-mempool-web"]
);
}
}
+251 -45
View File
@@ -1,7 +1,7 @@
//! Per-app manual update handler.
//!
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
//! remove old container(s) → recreate via reconcile script → verify running.
//! 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;
@@ -51,6 +51,64 @@ impl RpcHandler {
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)));
}
}
}
}
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
@@ -98,7 +156,8 @@ impl RpcHandler {
) -> 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;
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
// 1. Graceful stop all containers (reverse order for dependencies)
info!(
@@ -130,7 +189,8 @@ impl RpcHandler {
}
// Phase: PullingImage — about to fetch each pinned image in turn.
self.set_install_phase(package_id, InstallPhase::PullingImage).await;
self.set_install_phase(package_id, InstallPhase::PullingImage)
.await;
// 2. Pull new images with progress
info!(
@@ -175,45 +235,22 @@ impl RpcHandler {
}
}
// Phase: CreatingContainer — about to recreate each container
// via reconcile-containers.sh with the new image.
self.set_install_phase(package_id, InstallPhase::CreatingContainer).await;
// Phase: CreatingContainer — about to recreate each container.
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
// 4. Recreate via reconcile script (single source of truth for container specs)
info!("Update {}: recreating containers via reconcile", package_id);
// 4. Recreate containers (orchestrator-first, reconcile fallback)
info!("Update {}: recreating containers", package_id);
for name in containers {
let out = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await
.context(format!("Failed to reconcile {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
error!(
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
package_id,
name,
stdout.trim(),
stderr.trim()
);
return Err(anyhow::anyhow!(
"Reconcile failed for {}: {}",
name,
stderr.trim()
));
}
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;
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;
@@ -236,6 +273,51 @@ impl RpcHandler {
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;
@@ -307,17 +389,15 @@ impl RpcHandler {
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`).
// Use --create-missing so reconcile rebuilds it from its
// canonical spec instead of skipping it as optional.
let _ = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--create-missing",
"--force",
])
.output()
.await;
// 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);
@@ -338,3 +418,129 @@ impl RpcHandler {
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 {
"bitcoin-knots" => "bitcoin-core",
"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" => {
push("bitcoin-core");
push("bitcoin-knots");
}
"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-core", "bitcoin-knots"]
);
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-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");
}
}
+1 -2
View File
@@ -147,8 +147,7 @@ impl RpcHandler {
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
.await
.unwrap_or(false);
+2 -1
View File
@@ -109,7 +109,8 @@ impl RpcHandler {
// transitional variant. Done BEFORE the spawn so the WebSocket push
// beats the RPC response — the UI should see "Stopping…" the moment
// it gets the RPC ok, not on the next scan.
let pre_state = flip_to_transitional(&state_manager, &app_id, op.transitional_state()).await;
let pre_state =
flip_to_transitional(&state_manager, &app_id, op.transitional_state()).await;
let log_prefix = op.log_prefix();
let app_id_log = app_id.clone();
+3 -6
View File
@@ -162,10 +162,8 @@ impl RpcHandler {
// progress bar after navigation instead of showing the fake
// creep again. An RPC poll every ~1s during download drives a
// real progress indicator that survives route changes.
let downloaded = update::DOWNLOAD_BYTES
.load(std::sync::atomic::Ordering::Relaxed);
let total = update::DOWNLOAD_TOTAL
.load(std::sync::atomic::Ordering::Relaxed);
let downloaded = update::DOWNLOAD_BYTES.load(std::sync::atomic::Ordering::Relaxed);
let total = update::DOWNLOAD_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
let active = total > 0 && downloaded < total;
let completed = total > 0 && downloaded >= total;
@@ -175,8 +173,7 @@ impl RpcHandler {
// read timeout). The UI uses this to surface a Cancel button
// with explanatory copy.
let stalled = if active {
let last_at = update::DOWNLOAD_PROGRESS_AT
.load(std::sync::atomic::Ordering::Relaxed);
let last_at = update::DOWNLOAD_PROGRESS_AT.load(std::sync::atomic::Ordering::Relaxed);
if last_at > 0 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)