feat(orchestrator): complete container migration and release hardening
This commit is contained in:
@@ -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" };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user