chore(release): stage v1.7.55-alpha

This commit is contained in:
Dorian
2026-05-13 15:09:22 -04:00
parent 3202b79e41
commit 835c525218
65 changed files with 2322 additions and 566 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
use super::build_response;
use crate::api::rpc::lnd::LND_REST_BASE_URL;
use crate::api::rpc::RpcHandler;
use crate::bitcoin_status;
use crate::electrs_status;
@@ -123,7 +124,7 @@ impl ApiHandler {
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
let url = format!("{LND_REST_BASE_URL}{suffix}");
match reqwest::get(&url).await {
Ok(resp) => {
let status = resp.status().as_u16();
+164 -8
View File
@@ -2,6 +2,10 @@ use super::package::validate_app_id;
use super::transitional::Op;
use super::RpcHandler;
use anyhow::{Context, Result};
use std::time::Duration;
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(10);
impl RpcHandler {
pub(super) async fn handle_container_install(
@@ -379,6 +383,10 @@ impl RpcHandler {
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
if let Some(health) = self.stack_health(app_id).await? {
return Ok(serde_json::json!({ app_id: health }));
}
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.health(&candidate).await {
@@ -434,6 +442,78 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map))
}
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
let Some(members) = stack_health_members(app_id) else {
return Ok(None);
};
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
let mut saw_starting = false;
let mut saw_unknown = false;
for member in members {
match member_health(orchestrator.as_ref(), member)
.await
.as_deref()
{
Ok(health) if health == "healthy" => {}
Ok(health) if health == "starting" => saw_starting = true,
Ok(health) if health == "unknown" => saw_unknown = true,
Ok(_) => return Ok(Some("unhealthy".to_string())),
Err(_) => saw_unknown = true,
}
}
if saw_unknown {
Ok(Some("unknown".to_string()))
} else if saw_starting {
Ok(Some("starting".to_string()))
} else {
Ok(Some("healthy".to_string()))
}
}
}
async fn member_health(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
app_id: &str,
) -> Result<String> {
if let Ok(health) = orchestrator.health(app_id).await {
return Ok(health);
}
for name in status_container_name_candidates(app_id) {
if let Some(health) = inspect_container_health_value(&name).await {
return Ok(health);
}
}
Ok("unknown".to_string())
}
fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
match app_id {
"mempool" | "mempool-web" => {
Some(&["archy-mempool-db", "mempool-api", "archy-mempool-web"])
}
"btcpay-server" | "btcpayserver" | "btcpay" => {
Some(&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"])
}
"immich" => Some(&["immich_postgres", "immich_redis", "immich_server"]),
"indeedhub" => Some(&[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
]),
"fedimint" => Some(&["fedimint"]),
_ => None,
}
}
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
@@ -504,15 +584,21 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
}
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()
if let Some(v) = ps_container_state_value(name).await {
return Some(v);
}
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
@@ -525,20 +611,90 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
let mut parts = line.split_whitespace();
let status = parts.next().unwrap_or("unknown");
let running = parts.next().unwrap_or("false") == "true";
let health = parts.next().unwrap_or("none");
Some(serde_json::json!({
"name": name,
"status": status,
"state": status,
"running": running,
"health": health,
}))
}
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
"-a",
"--filter",
&format!("name={name}"),
"--format",
"{{.Names}}|{{.Status}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let mut parts = line.splitn(2, '|');
let container_name = parts.next().unwrap_or_default();
if container_name != name {
continue;
}
let status = parts.next().unwrap_or_default();
let state = state_from_podman_status(status);
let health = parse_health_from_status(status).unwrap_or("none");
return Some(serde_json::json!({
"name": name,
"status": state,
"state": state,
"running": state.eq_ignore_ascii_case("running"),
"health": health,
}));
}
None
}
fn state_from_podman_status(status: &str) -> &str {
if status.starts_with("Up ") {
"running"
} else if status.starts_with("Exited ") {
"exited"
} else if status.starts_with("Created") {
"created"
} else if status.starts_with("Stopping") {
"stopping"
} else if status.starts_with("Removing") {
"removing"
} else {
"unknown"
}
}
fn parse_health_from_status(status: &str) -> Option<&str> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| &status[start + 1..end])
}
async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?;
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
if health != "none" {
return Some(health.to_string());
}
}
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()),
"stopping" => Some("unhealthy".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")),
}
+7 -5
View File
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::info;
use super::LND_REST_BASE_URL;
#[derive(Debug, Serialize)]
struct ChannelInfo {
chan_id: String,
@@ -62,7 +64,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let channels_resp: LndListChannelsResponse = client
.get("https://127.0.0.1:8080/v1/channels")
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -72,7 +74,7 @@ impl RpcHandler {
.context("Failed to parse LND channels response")?;
let pending_resp: LndPendingChannelsResponse = match client
.get("https://127.0.0.1:8080/v1/channels/pending")
.get(format!("{LND_REST_BASE_URL}/v1/channels/pending"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -211,7 +213,7 @@ impl RpcHandler {
"perm": true
});
let _ = client
.post("https://127.0.0.1:8080/v1/peers")
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&connect_body)
.send()
@@ -224,7 +226,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/channels")
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&open_body)
.send()
@@ -291,7 +293,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!(
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
+6 -6
View File
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
#[derive(Debug, Serialize)]
struct LndInfo {
@@ -44,7 +44,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client
.get("https://127.0.0.1:8080/v1/getinfo")
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -54,7 +54,7 @@ impl RpcHandler {
.context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/channels")
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -70,7 +70,7 @@ impl RpcHandler {
};
let wallet_balance: LndBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/blockchain")
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -166,7 +166,7 @@ impl RpcHandler {
"cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion,
"rest_port": 8080,
"rest_port": 18080,
"grpc_port": 10009,
}))
}
@@ -186,7 +186,7 @@ impl RpcHandler {
.context("Failed to build HTTP client")?;
let resp = client
.get("https://127.0.0.1:8080/v1/channels/backup")
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
+1
View File
@@ -9,6 +9,7 @@ use anyhow::{anyhow, Context, Result};
/// Canonical on-host path for LND's admin macaroon.
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
// Shared LND response types used by multiple submodules
#[derive(Debug, serde::Deserialize)]
+4 -2
View File
@@ -2,6 +2,8 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::info;
use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Pay a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
@@ -35,7 +37,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/channels/transactions")
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&pay_body)
.send()
@@ -91,7 +93,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get("https://127.0.0.1:8080/v1/transactions")
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
+11 -9
View File
@@ -4,13 +4,15 @@ use base64::Engine;
use tracing::info;
use zeroize::Zeroize;
use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get("https://127.0.0.1:8080/v1/newaddress")
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -69,7 +71,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/transactions")
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&send_body)
.send()
@@ -129,7 +131,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/invoices")
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body)
.send()
@@ -231,7 +233,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body)
.send()
@@ -289,7 +291,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body)
.send()
@@ -322,7 +324,7 @@ impl RpcHandler {
});
let pub_resp = client
.post("https://127.0.0.1:8080/v2/wallet/tx")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/tx"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&publish_body)
.send()
@@ -387,7 +389,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body)
.send()
@@ -416,7 +418,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body)
.send()
@@ -514,7 +516,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/initwallet")
.post(format!("{LND_REST_BASE_URL}/v1/initwallet"))
.json(&init_body)
.send()
.await
+1 -1
View File
@@ -188,7 +188,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
let url = format!("{}/v1/invoice/{r_hash}", super::lnd::LND_REST_BASE_URL);
let paid = match client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
@@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Unauthorized",
"Forbidden",
"Not supported",
"Requires",
"requires",
"must be",
"cannot",
+1 -1
View File
@@ -12,7 +12,7 @@ mod fips;
mod handshake;
mod identity;
mod interfaces;
mod lnd;
pub(in crate::api) mod lnd;
mod marketplace;
mod mesh;
mod middleware;
@@ -54,6 +54,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
+132 -46
View File
@@ -1,6 +1,96 @@
use super::validation::validate_app_id;
use crate::port_allocator::PortAllocator;
use anyhow::{Context, Result};
use std::time::Duration;
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(15);
fn is_platform_managed_app(app_id: &str) -> bool {
matches!(
app_id,
"bitcoin"
| "bitcoin-core"
| "bitcoin-knots"
| "bitcoin-ui"
| "lnd"
| "lnd-ui"
| "electrumx"
| "electrs"
| "mempool-electrs"
| "electrs-ui"
| "mempool"
| "mempool-web"
| "mempool-api"
| "archy-mempool-db"
| "archy-mempool-web"
| "btcpay"
| "btcpay-server"
| "btcpayserver"
| "archy-btcpay-db"
| "archy-nbxplorer"
| "fedimint"
| "fedimint-gateway"
| "indeedhub"
| "immich"
)
}
fn safe_dynamic_arg(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 512
&& !value.chars().any(|c| matches!(c, '\0' | '\n' | '\r'))
}
async fn dynamic_app_config(
app_id: &str,
) -> Option<(
Vec<String>,
Vec<String>,
Vec<String>,
Option<String>,
Option<Vec<String>>,
)> {
if is_platform_managed_app(app_id) {
return None;
}
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
let data = tokio::fs::read_to_string(&config_path).await.ok()?;
let cfg = serde_json::from_str::<serde_json::Value>(&data).ok()?;
let string_array = |key: &str| -> Vec<String> {
cfg.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from)
.collect()
})
.unwrap_or_default()
};
let command = cfg
.get("command")
.and_then(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from);
let args = cfg.get("args").and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from)
.collect::<Vec<_>>()
});
tracing::info!(app_id = %app_id, "loaded catalog runtime config for generic app");
Some((
string_array("ports"),
string_array("volumes"),
string_array("env"),
command,
args.filter(|a| !a.is_empty()),
))
}
/// Trusted Docker registries. Only images from these sources are allowed.
#[allow(dead_code)]
@@ -173,12 +263,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
}
"mempool-api" => (
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
"30s",
"3",
),
"mempool" | "mempool-web" | "archy-mempool-web" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
(http_probe_cmd("http://localhost:8080/"), "30s", "3")
}
"electrumx" | "mempool-electrs" | "electrs" => {
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
@@ -189,7 +279,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"3",
),
"homeassistant" | "home-assistant" => {
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
}
"grafana" => (
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
@@ -209,7 +299,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"searxng" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"),
"searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s",
@@ -229,10 +319,8 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"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"),
"nostr-rs-relay" | "nostr-relay" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
}
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
"nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
"routstr" => (
"curl -sf http://localhost:8000/v1/models || exit 1",
"30s",
@@ -247,10 +335,21 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
format!("--health-cmd={}", cmd),
format!("--health-interval={}", interval),
format!("--health-retries={}", retries),
"--health-timeout=10s".to_string(),
"--health-start-period=60s".to_string(),
]
}
fn http_probe_cmd(url: &'static str) -> &'static str {
match url {
"http://localhost:8999/api/v1/backend-info" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8999/api/v1/backend-info; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8999/api/v1/backend-info; else exit 0; fi",
"http://localhost:8080/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi",
"http://localhost:81/api/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/api/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/api/; else exit 0; fi",
"http://localhost:81/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/; else exit 0; fi",
_ => "exit 0",
}
}
/// Get per-app memory limit.
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
match app_id {
@@ -259,10 +358,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: bumped from 1g to 2g so its CACHE_MB has somewhere
// to live during initial blockchain indexing. CACHE_MB=2048 in
// env vars below requires this much.
"electrumx" | "mempool-electrs" | "electrs" => "2g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@@ -407,12 +506,14 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
/// Find all running/stopped containers that belong to a given app.
/// Uses the canonical name list from all_container_names().
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
validate_app_id(package_id)?;
let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "{{.Names}}"])
.output()
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["ps", "-a", "--format", "{{.Names}}"]);
cmd.kill_on_drop(true);
let output = tokio::time::timeout(PODMAN_LIST_TIMEOUT, cmd.output())
.await
.context("podman ps timed out while listing containers")?
.context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
@@ -540,6 +641,10 @@ pub(super) async fn get_app_config(
Option<String>,
Option<Vec<String>>,
) {
if let Some(config) = dynamic_app_config(app_id).await {
return config;
}
match app_id {
"homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()],
@@ -600,7 +705,7 @@ pub(super) async fn get_app_config(
vec![
"9735:9735".to_string(),
"10009:10009".to_string(),
"8080:8080".to_string(),
"18080:8080".to_string(),
],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec![],
@@ -676,9 +781,10 @@ pub(super) async fn get_app_config(
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB, container now
// gets 2g (config.rs::get_memory_limit) so 2048 fits.
"CACHE_MB=2048".to_string(),
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@@ -822,7 +928,7 @@ pub(super) async fn get_app_config(
vec![
"81:81".to_string(),
"8084:80".to_string(),
"8443:443".to_string(),
"8444:443".to_string(),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
@@ -1049,31 +1155,11 @@ pub(super) async fn get_app_config(
None,
),
_ => {
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
// This allows dynamic apps from the remote catalog to be installed
// without hardcoding their config here.
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
let ports = cfg.get("ports")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let volumes = cfg.get("volumes")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env_vars = cfg.get("env")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
tracing::info!("Loaded dynamic config for app: {}", app_id);
return (ports, volumes, env_vars, None, None);
}
}
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
// No catalog runtime metadata found; use minimal defaults
// (container's own EXPOSE/VOLUME). New generic apps should declare
// containerConfig in the registry catalog instead of adding Rust cases.
tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", app_id);
(vec![], vec![], vec![], None, None)
},
}
}
}
@@ -9,6 +9,7 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
/// Names of container variants that represent a running Electrum indexer
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
matches!(
@@ -17,6 +18,12 @@ fn requires_unpruned_bitcoin(package_id: &str) -> bool {
)
}
fn archival_bitcoin_required_message(package_id: &str) -> String {
format!(
"Requires an archival Bitcoin node while indexing: {package_id}. This node is running pruned Bitcoin because it does not have enough disk for full block history. Add enough storage for an archival node (about 1 TB or more), resync Bitcoin without pruning/with txindex, then install {package_id}."
)
}
/// Snapshot of which dependency services are currently running.
pub(super) struct RunningDeps {
pub has_bitcoin: bool,
@@ -135,27 +142,57 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building Bitcoin RPC client")?;
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("checking Bitcoin pruning status")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC response")?;
if !status.is_success() {
anyhow::bail!(
"Bitcoin RPC returned {} while checking pruning status",
status
);
}
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
anyhow::bail!("Bitcoin RPC error while checking pruning status: {}", error);
let mut last_error = None;
for _ in 0..3 {
match client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(&rpc_user, Some(&rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
match resp.json::<serde_json::Value>().await {
Ok(json) if status.is_success() => {
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
last_error = Some(format!(
"Bitcoin RPC error while checking pruning status: {error}"
));
} else {
return check_blockchain_info_for_pruning(package_id, &json);
}
}
Ok(json) => {
last_error = Some(format!(
"Bitcoin RPC returned {status} while checking pruning status: {json}"
));
}
Err(e) => {
last_error = Some(format!("decode Bitcoin RPC response: {e}"));
}
}
}
Err(e) => {
last_error = Some(format!("checking Bitcoin pruning status: {e}"));
}
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if detect_disk_gb() < ARCHIVAL_BITCOIN_DISK_GB {
anyhow::bail!(archival_bitcoin_required_message(package_id));
}
anyhow::bail!(
"Bitcoin RPC unavailable while checking pruning status: {}",
last_error.unwrap_or_else(|| "unknown error".to_string())
);
}
fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value) -> Result<()> {
let Some(result) = json.get("result") else {
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
};
@@ -164,16 +201,28 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
anyhow::bail!(
"{} requires an unpruned Bitcoin node while indexing. Current Bitcoin is pruned; use a full node with enough disk for txindex/full block history, then reinstall/restart {}.",
package_id,
package_id
);
anyhow::bail!(archival_bitcoin_required_message(package_id));
}
Ok(())
}
fn detect_disk_gb() -> u64 {
let output = std::process::Command::new("df")
.args(["-BG", "/var/lib/archipelago"])
.output();
let Ok(output) = output else {
return u64::MAX;
};
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.nth(1)
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|size| size.trim_end_matches('G').parse::<u64>().ok())
.unwrap_or(u64::MAX)
}
/// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@@ -254,17 +303,23 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
/// order for the given app. Unknown containers sort to the end.
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
return Ok(vec![format!("archy-{}", package_id)]);
}
let order = startup_order(package_id);
// If no special order defined, fall back to mempool order (legacy behavior)
if order.is_empty() && containers.is_empty() {
return Ok(vec![package_id.to_string()]);
}
let mut sorted = containers;
for required in order {
if !sorted.iter().any(|name| name == required) {
sorted.push((*required).to_string());
}
}
// If no special order is defined, fall back to mempool order for legacy
// multi-container names that may still be returned by config lookups.
let effective_order: &[&str] = if order.is_empty() {
startup_order("mempool")
} else {
order
};
let mut sorted = containers;
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
Ok(sorted)
}
@@ -324,7 +379,15 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)]
mod tests {
use super::requires_unpruned_bitcoin;
use super::{requires_unpruned_bitcoin, startup_order};
#[test]
fn btcpay_start_order_includes_required_stack_members() {
assert_eq!(
startup_order("btcpay-server"),
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
);
}
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
+168 -35
View File
@@ -14,6 +14,7 @@ use crate::data_model::InstallPhase;
use crate::update::host_sudo;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log";
@@ -221,6 +222,12 @@ impl RpcHandler {
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
if matches!(package_id, "mempool" | "mempool-web") {
let deps = self.running_deps_for_install(package_id).await?;
check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?;
}
// Multi-container stacks get their own install path
if package_id == "immich" {
return self.install_immich_stack().await;
@@ -239,15 +246,7 @@ impl RpcHandler {
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
// only when the cache does not show the dependency.
let deps = {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
if dependency_cache_satisfies(package_id, &cached) {
cached
} else {
detect_running_deps().await?
}
};
let deps = self.running_deps_for_install(package_id).await?;
check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
@@ -362,31 +361,65 @@ impl RpcHandler {
if !start_output.status.success() {
let stderr = String::from_utf8_lossy(&start_output.stderr);
install_log(&format!(
"INSTALL ADOPT FAIL: {} — start failed: {}",
"INSTALL ADOPT RECREATE: {} — start failed, removing wedged container: {}",
package_id, stderr
))
.await;
return Err(anyhow::anyhow!(
"Container {} exists but failed to start: {}",
package_id,
stderr
));
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", package_id])
.output()
.await;
} else {
wait_for_adopted_container(package_id, package_id).await?;
install_log(&format!(
"INSTALL ADOPT OK: {} — already running",
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
))
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", package_id])
.output()
.await;
} else {
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"message": format!("Package {} already installed and running", package_id)
}));
}
}
// Fall through to the fresh install path. Volume-backed data
// remains under /var/lib/archipelago and is not deleted.
} else {
install_log(&format!(
"INSTALL ADOPT OK: {} — already running",
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
))
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", package_id])
.output()
.await;
} else {
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"message": format!("Package {} already installed and running", package_id)
}));
}
wait_for_adopted_container(package_id, package_id).await?;
}
install_log(&format!(
"INSTALL ADOPT OK: {} — already running",
package_id
))
.await;
ensure_host_port_listener(package_id, package_id).await?;
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"message": format!("Package {} already installed and running", package_id)
}));
}
}
@@ -513,7 +546,20 @@ impl RpcHandler {
let network_alias_flag = format!("--network-alias={}", container_name);
// Network mode
if package_id == "uptime-kuma" || package_id == "gitea" || package_id == "tailscale" {
if matches!(
package_id,
"uptime-kuma"
| "gitea"
| "tailscale"
| "vaultwarden"
| "homeassistant"
| "home-assistant"
| "nextcloud"
| "searxng"
| "jellyfin"
| "nginx-proxy-manager"
| "portainer"
) {
// These standalone web UIs have repeatedly lost host listeners
// under Podman's rootless pasta backend while staying healthy internally.
// Use slirp4netns/rootlessport for this standalone web UI.
@@ -599,6 +645,10 @@ impl RpcHandler {
self.write_lnd_conf(&rpc_user, &rpc_pass).await?;
}
if package_id == "portainer" {
ensure_user_podman_socket().await?;
}
// Pre-install: SearXNG settings.yml (required or container exits immediately)
if package_id == "searxng" {
let searx_dir = "/var/lib/archipelago/searxng";
@@ -705,6 +755,10 @@ impl RpcHandler {
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
if cleanup_start_conflict(package_id, &stderr).await {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
.output()
.await;
run_output = cmd.output().await.context("Failed to rerun container")?;
}
}
@@ -750,11 +804,14 @@ impl RpcHandler {
.await;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let status = timeout(
Duration::from_secs(10),
tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output(),
)
.await;
if let Ok(Ok(o)) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "running" {
container_running = true;
@@ -787,6 +844,8 @@ impl RpcHandler {
log_output.chars().take(500).collect::<String>()
));
}
} else {
warn!("podman inspect {} timed out during install", container_name);
}
if i == 11 {
warn!(
@@ -838,6 +897,16 @@ impl RpcHandler {
}))
}
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
if dependency_cache_satisfies(package_id, &cached) {
Ok(cached)
} else {
detect_running_deps().await
}
}
// -- Private helpers for install --
/// Pull the image from a registry or verify a local image exists.
@@ -1788,6 +1857,7 @@ autopilot.active=false\n",
async fn cleanup_stale_package_ports(package_id: &str) {
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
@@ -1795,11 +1865,21 @@ async fn cleanup_stale_package_ports(package_id: &str) {
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
_ => {}
}
}
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
if stderr.contains("name is already in use") || stderr.contains("name \"") {
return true;
}
match package_id {
"grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
@@ -1807,6 +1887,12 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
cleanup_stale_pasta_port("3000").await;
true
}
"homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
@@ -1825,6 +1911,20 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
cleanup_stale_pasta_port("3000").await;
true
}
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
}
"nextcloud"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8085").await;
true
}
_ => false,
}
}
@@ -1839,6 +1939,11 @@ async fn cleanup_stale_pasta_port(port: &str) {
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args(["fuser", "-k", &format!("{}/tcp", port)])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
@@ -1910,12 +2015,40 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
))
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
return Ok(());
}
let status = tokio::process::Command::new("systemctl")
.args(["--user", "restart", "podman.socket"])
.status()
.await
.context("spawn systemctl --user restart podman.socket")?;
if !status.success() {
anyhow::bail!("systemctl --user restart podman.socket exited {status}");
}
for _ in 0..20 {
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
anyhow::bail!("podman socket {socket_path} did not appear after restart")
}
fn required_host_port(package_id: &str) -> Option<u16> {
match package_id {
"grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
_ => None,
}
}
+142 -109
View File
@@ -5,9 +5,14 @@ use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState;
use anyhow::{Context, Result};
use std::process::Output;
use std::sync::Arc;
use std::time::Duration;
use tracing::warn;
const PODMAN_CONTROL_TIMEOUT: Duration = Duration::from_secs(30);
const PODMAN_LOG_TIMEOUT: Duration = Duration::from_secs(15);
/// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
@@ -292,10 +297,7 @@ impl RpcHandler {
)
.await;
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let stop_out = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
match stop_out {
Ok(o) if o.status.success() => stopped += 1,
Ok(o) => {
@@ -314,10 +316,7 @@ impl RpcHandler {
// Remove container (without -f to respect graceful shutdown above)
tracing::info!("Uninstall {}: removing container {}", package_id, name);
let rm_out = tokio::process::Command::new("podman")
.args(["rm", name])
.output()
.await;
let rm_out = podman_control(&["rm", name]).await;
match rm_out {
Ok(o) if o.status.success() => removed += 1,
Ok(o) => {
@@ -329,10 +328,7 @@ impl RpcHandler {
name,
stderr.trim()
);
let force_rm = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.output()
.await;
let force_rm = podman_control(&["rm", "-f", name]).await;
match force_rm {
Ok(o2) if o2.status.success() => removed += 1,
_ => {
@@ -353,10 +349,7 @@ impl RpcHandler {
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"])
.output()
.await;
let _ = podman_control(&["volume", "prune", "-f"]).await;
// Clean up app-specific networks (only if no other containers use them)
let app_networks: Vec<&str> = match package_id {
@@ -366,10 +359,7 @@ impl RpcHandler {
_ => vec![],
};
for net in &app_networks {
let _ = tokio::process::Command::new("podman")
.args(["network", "rm", net])
.output()
.await;
let _ = podman_control(&["network", "rm", net]).await;
}
// Release port allocation
@@ -485,18 +475,16 @@ impl RpcHandler {
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
let check_output = tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name={}", app_id),
])
.output()
.await
.context("Failed to check container")?;
let check_output = podman_control(&[
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name={}", app_id),
])
.await
.context("Failed to check container")?;
let existing = String::from_utf8_lossy(&check_output.stdout);
@@ -541,16 +529,14 @@ impl RpcHandler {
cmd.arg(image);
let output = cmd.output().await.context("Failed to create container")?;
let output = command_with_timeout(cmd, PODMAN_CONTROL_TIMEOUT, "podman run").await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
}
} else {
let output = tokio::process::Command::new("podman")
.args(["start", app_id])
.output()
let output = podman_control(&["start", app_id])
.await
.context("Failed to start container")?;
@@ -575,9 +561,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let output = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
.output()
let output = podman_control(&["stop", "-t", stop_timeout_secs(app_id), app_id])
.await
.context("Failed to stop container")?;
@@ -611,9 +595,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
}
repair_before_package_start(name).await;
tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
let out = podman_control(&["start", name])
.await
.context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() {
@@ -632,17 +614,12 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
// container exits immediately after).
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for name in to_start {
let status = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output()
.await;
let status = podman_control(&["inspect", name, "--format", "{{.State.Status}}"]).await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "5", name])
.output()
.await;
let logs =
podman_with_timeout(&["logs", "--tail", "5", name], PODMAN_LOG_TIMEOUT).await;
let log_text = logs
.map(|o| {
let combined = format!(
@@ -714,6 +691,39 @@ async fn wait_after_orchestrator_start(name: &str) {
}
}
async fn podman_control(args: &[&str]) -> Result<Output> {
podman_with_timeout(args, podman_control_timeout(args)).await
}
fn podman_control_timeout(args: &[&str]) -> Duration {
args.windows(2)
.find_map(|pair| {
(pair[0] == "-t")
.then(|| pair[1].parse::<u64>().ok())
.flatten()
})
.map(|secs| Duration::from_secs(secs.saturating_add(30)))
.unwrap_or(PODMAN_CONTROL_TIMEOUT)
}
async fn podman_with_timeout(args: &[&str], timeout: Duration) -> Result<Output> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(args);
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
}
async fn command_with_timeout(
mut cmd: tokio::process::Command,
timeout: Duration,
description: &str,
) -> Result<Output> {
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))?
.with_context(|| format!("Failed to exec {}", description))
}
async fn do_orchestrator_package_stop(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
containers: &[String],
@@ -757,9 +767,7 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
name,
stop_timeout_secs(name)
);
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
let out = podman_control(&["stop", "-t", stop_timeout_secs(name), name])
.await
.context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() {
@@ -785,9 +793,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
for name in containers {
tracing::info!("Restarting container: {}", name);
repair_before_package_start(name).await;
let out = tokio::process::Command::new("podman")
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
let out = podman_control(&["restart", "-t", stop_timeout_secs(name), name])
.await
.context(format!("Failed to exec podman restart {}", name))?;
@@ -803,13 +809,8 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
stderr
);
// Fallback: stop then start
let _ = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let start_out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
let _ = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
let start_out = podman_control(&["start", name])
.await
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
@@ -852,6 +853,12 @@ async fn repair_before_package_start(container_name: &str) {
repair_grafana_dirs().await;
cleanup_stale_pasta_port("3000").await;
}
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"nextcloud" => {
repair_nextcloud_dirs().await;
cleanup_stale_pasta_port("8085").await;
}
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
@@ -871,9 +878,7 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
container_name, port
))
.await;
let output = tokio::process::Command::new("podman")
.args(["restart", container_name])
.output()
let output = podman_control(&["restart", container_name])
.await
.context("failed to restart container after missing host port")?;
if !output.status.success() {
@@ -905,9 +910,13 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
match container_name {
"grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"vaultwarden" => Some(8082),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
_ => None,
}
}
@@ -957,16 +966,14 @@ async fn repair_grafana_dirs() {
.args(["mkdir", "-p", "/var/lib/archipelago/grafana"])
.output()
.await;
let podman_chown = tokio::process::Command::new("podman")
.args([
"unshare",
"chown",
"-R",
"472:472",
"/var/lib/archipelago/grafana",
])
.output()
.await;
let podman_chown = podman_control(&[
"unshare",
"chown",
"-R",
"472:472",
"/var/lib/archipelago/grafana",
])
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo")
.args([
@@ -980,6 +987,32 @@ async fn repair_grafana_dirs() {
}
}
async fn repair_nextcloud_dirs() {
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/nextcloud"])
.output()
.await;
let podman_chown = podman_control(&[
"unshare",
"chown",
"-R",
"33:33",
"/var/lib/archipelago/nextcloud",
])
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"100032:100032",
"/var/lib/archipelago/nextcloud",
])
.output()
.await;
}
}
async fn repair_btcpay_database_password() {
let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
@@ -991,39 +1024,32 @@ async fn repair_btcpay_database_password() {
return;
}
let _ = tokio::process::Command::new("podman")
.args(["start", "archy-btcpay-db"])
.output()
.await;
let _ = podman_control(&["start", "archy-btcpay-db"]).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let escaped = db_pass.replace('\'', "''");
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"psql",
"-U",
"btcpay",
"-d",
"btcpay",
"-c",
&sql,
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"createdb",
"-U",
"btcpay",
"nbxplorer",
])
.output()
.await;
let _ = podman_control(&[
"exec",
"archy-btcpay-db",
"psql",
"-U",
"btcpay",
"-d",
"btcpay",
"-c",
&sql,
])
.await;
let _ = podman_control(&[
"exec",
"archy-btcpay-db",
"createdb",
"-U",
"btcpay",
"nbxplorer",
])
.await;
}
async fn repair_indeedhub_network() {
@@ -1040,11 +1066,18 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return;
}
if container_name != "grafana" {
return;
match container_name {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
_ => {}
}
cleanup_stale_pasta_port("3000").await;
}
async fn cleanup_stale_pasta_port(port: &str) {
@@ -775,6 +775,8 @@ impl RpcHandler {
/// Install Mempool stack (mariadb + mempool-api + mempool-web).
pub(super) async fn install_mempool_stack(&self) -> Result<serde_json::Value> {
super::dependencies::check_bitcoin_pruning_compatibility("mempool").await?;
if let Some(adopted) = adopt_stack_if_exists(
"mempool",
"mempool",
@@ -252,7 +252,8 @@ impl DevContainerOrchestrator {
match status.state {
archipelago_container::ContainerState::Running => Ok("healthy".to_string()),
archipelago_container::ContainerState::Stopped
| archipelago_container::ContainerState::Exited => Ok("unhealthy".to_string()),
| archipelago_container::ContainerState::Exited
| archipelago_container::ContainerState::Stopping => Ok("unhealthy".to_string()),
archipelago_container::ContainerState::Created => Ok("starting".to_string()),
archipelago_container::ContainerState::Paused => Ok("paused".to_string()),
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),
@@ -136,11 +136,13 @@ impl DockerPackageScanner {
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
Some(ui_address.clone())
reachable_lan_address(&app_id, Some(ui_address.clone())).await
} else {
// Dynamic: use actual port bindings from container, fall back to static map
extract_lan_address(&container.ports)
.or_else(|| PodmanClient::lan_address_for(&app_id))
// Prefer the known web UI port over arbitrary first binding
// (for example Gitea exposes SSH on 2222 before web on 3001).
let candidate = PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports));
reachable_lan_address(&app_id, candidate).await
};
debug!(
@@ -156,21 +158,8 @@ impl DockerPackageScanner {
// Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image);
// Check for available update by comparing running image vs pinned image
let available_update =
image_versions::pinned_image_for_app(&app_id).and_then(|pinned| {
if pinned != container.image {
let pinned_version = image_versions::extract_version_from_image(&pinned);
// Don't flag if both are "latest" — no meaningful diff
if pinned_version != "latest" || running_version != "latest" {
Some(pinned_version)
} else {
None
}
} else {
None
}
});
image_versions::available_update_for_app(&app_id, &container.image);
let package = PackageDataEntry {
state: package_state.clone(),
@@ -631,6 +620,51 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
let url = candidate?;
if !requires_reachable_launch(app_id) {
return Some(url);
}
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
return None;
};
match tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
{
Ok(Ok(_)) => Some(url),
_ => {
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
None
}
}
}
fn requires_reachable_launch(app_id: &str) -> bool {
matches!(
app_id,
"botfights"
| "btcpay-server"
| "fedimint"
| "filebrowser"
| "grafana"
| "homeassistant"
| "home-assistant"
| "jellyfin"
| "mempool"
| "nginx-proxy-manager"
| "uptime-kuma"
| "gitea"
| "nextcloud"
| "portainer"
| "tailscale"
| "immich"
| "searxng"
)
}
fn companion_lan_address(app_id: &str) -> Option<String> {
match app_id {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
@@ -642,6 +676,7 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
ContainerState::Stopping => (PackageState::Stopping, ServiceStatus::Stopped),
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
@@ -205,6 +205,30 @@ pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
images.get(var).cloned()
}
/// Return the pinned tag only when the running image is genuinely behind.
/// Registry host changes alone are not app updates, and floating tags are not
/// explicit versions we should advertise to users as available updates.
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
let pinned = pinned_image_for_app(app_id)?;
let pinned_version = extract_version_from_image(&pinned);
if is_floating_tag(&pinned_version) {
return None;
}
let running_version = extract_version_from_image(running_image);
if pinned_version == running_version {
return None;
}
let pinned_repo = image_without_registry_or_tag(&pinned);
let running_repo = image_without_registry_or_tag(running_image);
if pinned_repo != running_repo {
return None;
}
Some(pinned_version)
}
/// Extract version tag from a full image reference.
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty.
@@ -223,6 +247,32 @@ pub fn extract_version_from_image(image: &str) -> String {
"latest".to_string()
}
fn is_floating_tag(tag: &str) -> bool {
matches!(tag, "latest" | "stable" | "release" | "main")
}
fn image_without_registry_or_tag(image: &str) -> &str {
let without_tag = strip_tag(image);
match without_tag.split_once('/') {
Some((first, rest))
if first.contains('.') || first.contains(':') || first == "localhost" =>
{
rest
}
_ => without_tag,
}
}
fn strip_tag(image: &str) -> &str {
if let Some(slash_pos) = image.rfind('/') {
let after_slash = &image[slash_pos..];
if let Some(colon_pos) = after_slash.rfind(':') {
return &image[..slash_pos + colon_pos];
}
}
image
}
/// Container names and their image variable names for multi-container stacks.
/// Returns empty vec for single-container apps.
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
@@ -286,6 +336,25 @@ mod tests {
);
}
#[test]
fn strips_registry_and_tag_for_image_identity() {
assert_eq!(
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
"lfg2025/lnd"
);
assert_eq!(
image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
"lfg2025/lnd"
);
}
#[test]
fn floating_tags_are_not_explicit_updates() {
assert!(is_floating_tag("latest"));
assert!(is_floating_tag("stable"));
assert!(!is_floating_tag("v0.18.4-beta"));
}
#[test]
fn test_parse_image_versions() {
let content = r#"
+36 -8
View File
@@ -10,6 +10,7 @@ use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
pub const WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
@@ -42,7 +43,7 @@ pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<Ensure
let existing = fs::read_to_string(&paths.conf_path)
.await
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
if has_required_lnd_flags(&existing) {
if has_required_lnd_flags(&existing, rpc_pass) {
return Ok(EnsureOutcome::Unchanged);
}
}
@@ -121,6 +122,31 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
}
async fn unlock_existing_wallet() -> Result<()> {
unlock_existing_wallet_via_rest().await
}
async fn unlock_existing_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/unlockwallet",
serde_json::json!({ "wallet_password": wallet_password }),
)
.await
.context("unlocking existing LND wallet")?
{
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
}
}
#[allow(dead_code)]
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
@@ -221,7 +247,7 @@ async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let url = format!("{LND_REST_BASE_URL}{path}");
let mut last_err = None;
for _ in 0..60 {
match client.get(&url).send().await {
@@ -244,7 +270,7 @@ async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
path: &str,
body: serde_json::Value,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let url = format!("{LND_REST_BASE_URL}{path}");
let mut last_err = None;
for _ in 0..60 {
match client.post(&url).json(&body).send().await {
@@ -291,7 +317,7 @@ async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
return false;
};
client
.get("https://127.0.0.1:8080/v1/getinfo")
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
.send()
.await
@@ -344,12 +370,14 @@ fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
fn has_required_lnd_flags(conf: &str) -> bool {
fn has_required_lnd_flags(conf: &str, rpc_pass: &str) -> bool {
let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}");
[
"bitcoin.active=true",
"bitcoin.mainnet=true",
"bitcoin.node=bitcoind",
"bitcoind.rpchost=bitcoin-knots:8332",
rpc_pass_line.as_str(),
]
.iter()
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
@@ -378,7 +406,7 @@ mod tests {
}
#[tokio::test]
async fn ensure_config_is_idempotent() {
async fn ensure_config_repairs_rpc_password_drift() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
@@ -391,10 +419,10 @@ mod tests {
);
assert_eq!(
ensure_config(&paths, "second").await.unwrap(),
EnsureOutcome::Unchanged
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoind.rpcpass=first"));
assert!(conf.contains("bitcoind.rpcpass=second"));
}
#[tokio::test]
@@ -47,6 +47,27 @@ use crate::update::host_sudo;
/// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable.
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
fn is_required_baseline_app(app_id: &str) -> bool {
matches!(
app_id,
"bitcoin-knots"
| "electrumx"
| "lnd"
| "mempool-api"
| "mempool"
| "archy-mempool-db"
| "filebrowser"
)
}
fn requires_archival_bitcoin(app_id: &str) -> bool {
matches!(
app_id,
"electrumx" | "mempool-api" | "mempool" | "archy-mempool-db"
)
}
/// Compute the podman container name for a manifest.
///
@@ -129,6 +150,20 @@ async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
}
}
async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64) -> Result<()> {
for port in manifest.app.ports.iter().map(|p| p.host) {
if !wait_for_host_port(port, timeout_secs).await {
return Err(anyhow::anyhow!(
"{} host port {} did not become reachable within {}s",
manifest.app.id,
port,
timeout_secs
));
}
}
Ok(())
}
async fn patch_indeedhub_nostr_provider() {
let _ = tokio::process::Command::new("podman")
.args([
@@ -316,6 +351,8 @@ pub struct ProdContainerOrchestrator {
/// false so the legacy path remains the production path until the
/// 20× lifecycle harness goes green against the new path.
use_quadlet_backends: bool,
#[cfg(test)]
test_disk_gb: Option<u64>,
}
struct FileSecretsProvider {
@@ -363,6 +400,8 @@ impl ProdContainerOrchestrator {
lnd_paths: lnd::EnsurePaths::default(),
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
})
}
@@ -380,6 +419,7 @@ impl ProdContainerOrchestrator {
lnd_paths: lnd::EnsurePaths::default(),
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: false,
test_disk_gb: None,
}
}
@@ -411,6 +451,11 @@ impl ProdContainerOrchestrator {
self.lnd_paths = paths;
}
#[cfg(test)]
pub fn set_disk_gb_for_test(&mut self, disk_gb: u64) {
self.test_disk_gb = Some(disk_gb);
}
/// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each,
/// validates it, and stores it in the in-memory state.
///
@@ -587,8 +632,19 @@ impl ProdContainerOrchestrator {
.collect()
};
let mut report = ReconcileReport::default();
let disk_gb = self.disk_gb();
for lm in manifests {
let app_id = lm.manifest.app.id.clone();
if mode == ReconcileMode::ExistingOnly
&& requires_archival_bitcoin(&app_id)
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
{
report.record(
&app_id,
ReconcileAction::Left("requires-archival-bitcoin".into()),
);
continue;
}
match self.ensure_running_with_mode(&lm, mode).await {
Ok(action) => report.record(&app_id, action),
Err(e) => {
@@ -691,8 +747,20 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Installed);
}
self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(ReconcileAction::Started)
}
ContainerState::Stopping => {
tracing::warn!(
app_id = %app_id,
container = %name,
"container stuck in stopping state; force-recreating container record"
);
self.prepare_for_start(&resolved_manifest).await?;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
Ok(ReconcileAction::Installed)
}
ContainerState::Created => {
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
@@ -714,6 +782,7 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Installed);
}
self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(ReconcileAction::Started)
}
ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())),
@@ -721,7 +790,36 @@ impl ProdContainerOrchestrator {
}
}
Err(_) => {
// Container missing entirely → install fresh.
// Container missing entirely. With Quadlet backends enabled, an
// existing .container file is installed state even if Podman
// lost the container record after a crash/reboot. Sync the unit
// bytes first (clears stale Notify=healthy/nc probes), then ask
// user systemd to start the generated service.
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? {
self.prepare_for_start(&resolved_manifest).await?;
self.sync_quadlet_unit(lm, &name).await?;
quadlet::enable_now(&format!("{name}.service"))
.await
.with_context(|| {
format!("start existing quadlet service {name}.service")
})?;
self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
return Ok(ReconcileAction::Started);
}
// Required baseline services must self-heal even if both the
// podman record and Quadlet unit are gone. These are installed
// by first boot and are prerequisites for dependent apps; an
// "absent" result leaves the node permanently degraded after
// crash cleanup.
if mode == ReconcileMode::ExistingOnly && is_required_baseline_app(&app_id) {
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
// Optional container missing entirely → leave absent during
// boot reconcile; explicit install/start can create it.
if mode == ReconcileMode::ExistingOnly {
return Ok(ReconcileAction::Left("absent".to_string()));
}
@@ -806,6 +904,7 @@ impl ProdContainerOrchestrator {
.with_context(|| format!("start_container {name}"))?;
}
self.run_post_start_hooks(&lm.manifest.app.id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(())
}
@@ -942,6 +1041,16 @@ impl ProdContainerOrchestrator {
Ok(Some(ReconcileAction::Installed))
}
async fn quadlet_unit_exists(&self, name: &str) -> Result<bool> {
let unit_dir = quadlet::unit_dir()
.await
.context("locate user quadlet unit dir for existing unit check")?;
let unit_path = unit_dir.join(format!("{name}.container"));
tokio::fs::try_exists(&unit_path)
.await
.with_context(|| format!("check existing quadlet unit {}", unit_path.display()))
}
/// Drift-sync an existing Quadlet unit file's bytes against what the
/// current renderer produces. No-op when the flag is off, when the
/// app is a companion (companion.rs owns those units), or when no
@@ -971,9 +1080,20 @@ impl ProdContainerOrchestrator {
if !tokio::fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(());
}
let old_body = tokio::fs::read_to_string(&unit_path)
.await
.unwrap_or_default();
let restart_required = quadlet::contains_stale_health_gate(&old_body);
let mut resolved = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved)?;
let unit = quadlet::QuadletUnit::from_manifest(&resolved, name);
let new_body = unit.render();
let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body);
let restart_for_network_alias_change =
quadlet::network_aliases_changed(&old_body, &new_body);
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
let changed = quadlet::write_if_changed(&unit, &unit_dir)
.await
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
@@ -987,6 +1107,36 @@ impl ProdContainerOrchestrator {
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
);
}
if changed
&& (restart_required
|| restart_for_port_change
|| restart_for_network_alias_change
|| restart_for_exec_change
|| restart_for_health_change)
{
let service = unit.service_name();
let reason = if restart_required {
"stale health gate"
} else if restart_for_port_change {
"port binding drift"
} else if restart_for_network_alias_change {
"network alias drift"
} else if restart_for_health_change {
"health command drift"
} else {
"exec drift"
};
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
service = %service,
reason = reason,
"Quadlet unit rewrite requires service restart"
);
quadlet::restart_service(&service)
.await
.with_context(|| format!("restart drifted quadlet service {service}"))?;
}
Ok(())
}
@@ -1283,7 +1433,10 @@ impl ProdContainerOrchestrator {
let mut started = false;
match frontend_status.state {
ContainerState::Running => {}
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created => {
ContainerState::Stopped
| ContainerState::Exited
| ContainerState::Created
| ContainerState::Stopping => {
self.runtime
.start_container("indeedhub")
.await
@@ -1366,7 +1519,7 @@ impl ProdContainerOrchestrator {
fn detect_host_facts(&self) -> HostFacts {
let host_ip = Self::detect_host_ip().unwrap_or_else(|| "127.0.0.1".to_string());
let host_mdns = Self::detect_host_mdns();
let disk_gb = Self::detect_disk_gb();
let disk_gb = self.disk_gb();
HostFacts {
host_ip,
host_mdns,
@@ -1429,6 +1582,14 @@ impl ProdContainerOrchestrator {
kb / 1_000_000
}
fn disk_gb(&self) -> u64 {
#[cfg(test)]
if let Some(disk_gb) = self.test_disk_gb {
return disk_gb;
}
Self::detect_disk_gb()
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut env = manifest.app.environment.clone();
@@ -1555,13 +1716,17 @@ impl ProdContainerOrchestrator {
.flatten()
.unwrap_or_default();
let expected_entry = manifest
.app
.container
.entrypoint
.clone()
.unwrap_or_default();
current_entry != expected_entry || current_cmd != manifest.app.container.custom_args
if let Some(expected_entry) = &manifest.app.container.entrypoint {
if current_entry != *expected_entry {
return true;
}
}
if !manifest.app.container.custom_args.is_empty()
&& current_cmd != manifest.app.container.custom_args
{
return true;
}
false
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
@@ -1691,10 +1856,20 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let action = self.ensure_running(&lm).await?;
match action {
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => Ok(()),
ReconcileAction::Left(state) => Err(anyhow::anyhow!(
"container {} left in {state}",
compute_container_name(&lm.manifest)
)),
ReconcileAction::Left(state) => {
let name = compute_container_name(&lm.manifest);
tracing::warn!(
app_id = %app_id,
container = %name,
state = %state,
"start: container in wedged state, force-recreating"
);
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(&lm).await
}
}
}
@@ -1707,6 +1882,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
// Quadlet-owned containers are restarted by systemd if only `podman stop`
// is used. Stop the user service first, then stop the container as a
// defensive fallback for legacy/non-Quadlet installs.
if let Err(err) = quadlet::stop_service(&format!("{name}.service")).await {
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
}
self.runtime
.stop_container(&name)
.await
@@ -1718,6 +1899,24 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
let service = format!("{name}.service");
if self.quadlet_unit_exists(&name).await? {
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
self.prepare_for_start(&resolved_manifest).await?;
self.sync_quadlet_unit(&lm, &name).await?;
if let Err(err) = quadlet::restart_service(&service).await {
tracing::warn!(container = %name, error = %err, "quadlet restart failed; trying start");
quadlet::enable_now(&service)
.await
.with_context(|| format!("restart start quadlet service {service}"))?;
}
self.run_post_start_hooks(app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
return Ok(());
}
// Best-effort stop (ignored if already stopped), then start.
let _ = self.runtime.stop_container(&name).await;
self.prepare_for_start(&lm.manifest).await?;
@@ -1819,8 +2018,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn health(&self, app_id: &str) -> Result<String> {
let status = <Self as ContainerOrchestrator>::status(self, app_id).await?;
Ok(match status.state {
ContainerState::Running => "healthy".to_string(),
ContainerState::Stopped | ContainerState::Exited => "unhealthy".to_string(),
ContainerState::Running => status.health.unwrap_or_else(|| "healthy".to_string()),
ContainerState::Stopped | ContainerState::Exited | ContainerState::Stopping => {
"unhealthy".to_string()
}
ContainerState::Created => "starting".to_string(),
ContainerState::Paused => "paused".to_string(),
ContainerState::Unknown(s) => format!("unknown:{s}"),
@@ -1846,6 +2047,8 @@ mod tests {
calls: StdMutex<Vec<String>>,
/// container_name -> ContainerState. Absence = "doesn't exist".
containers: StdMutex<HashMap<String, ContainerState>>,
/// container_name -> Podman health status.
health: StdMutex<HashMap<String, String>>,
/// image_ref -> present. Absence = "not present in local storage".
images: StdMutex<HashMap<String, bool>>,
/// container_name -> env that create_container received.
@@ -1869,6 +2072,12 @@ mod tests {
.unwrap()
.insert(name.to_string(), state);
}
fn set_health(&self, name: &str, health: &str) {
self.health
.lock()
.unwrap()
.insert(name.to_string(), health.to_string());
}
fn mark_image_present(&self, tag: &str) {
self.images.lock().unwrap().insert(tag.to_string(), true);
}
@@ -1929,11 +2138,12 @@ mod tests {
.get(name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("not found: {name}"))?;
let health = self.health.lock().unwrap().get(name).cloned();
Ok(ContainerStatus {
id: format!("id-{name}"),
name: name.to_string(),
state,
health: None,
health,
exit_code: None,
started_at: None,
image: "test-image".to_string(),
@@ -2436,6 +2646,82 @@ app:
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots"));
}
#[tokio::test]
async fn reconcile_existing_installs_missing_required_baseline_app() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
orch.insert_manifest_for_test(
pull_manifest("filebrowser", "docker.io/filebrowser/filebrowser:latest"),
PathBuf::from("/tmp/filebrowser"),
)
.await;
let report = orch.reconcile_existing().await;
assert_eq!(
report.actions,
vec![("filebrowser".to_string(), ReconcileAction::Installed)]
);
assert!(report.failures.is_empty());
let calls = rt.calls();
assert!(calls.iter().any(|c| c.starts_with("pull_image:")));
assert!(calls
.iter()
.any(|c| c == "create_container:filebrowser:offset=0"));
assert!(calls.iter().any(|c| c == "start_container:filebrowser"));
}
#[tokio::test]
async fn reconcile_existing_skips_archival_baseline_apps_on_pruned_hosts() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
orch.insert_manifest_for_test(
pull_manifest("electrumx", "docker.io/spesmilo/electrumx:latest"),
PathBuf::from("/tmp/electrumx"),
)
.await;
let report = orch.reconcile_existing().await;
assert_eq!(
report.actions,
vec![(
"electrumx".to_string(),
ReconcileAction::Left("requires-archival-bitcoin".into())
)]
);
assert!(report.failures.is_empty());
let calls = rt.calls();
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
}
#[tokio::test]
async fn reconcile_existing_leaves_missing_optional_app_absent() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest("gitea", "docker.io/gitea/gitea:latest"),
PathBuf::from("/tmp/gitea"),
)
.await;
let report = orch.reconcile_existing().await;
assert_eq!(
report.actions,
vec![("gitea".to_string(), ReconcileAction::Left("absent".into()))]
);
assert!(report.failures.is_empty());
let calls = rt.calls();
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
}
#[tokio::test]
async fn reconcile_collects_per_app_failures_without_short_circuiting() {
let rt = Arc::new(MockRuntime::default());
@@ -2611,6 +2897,32 @@ app:
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots"));
}
#[tokio::test]
async fn reconcile_force_recreates_stopping_container() {
let rt = Arc::new(MockRuntime::default());
rt.set_state("nostr-rs-relay", ContainerState::Stopping);
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest("nostr-rs-relay", "docker.io/scsibug/nostr-rs-relay:0.8.9"),
PathBuf::from("/tmp/nostr-rs-relay"),
)
.await;
let report = orch.reconcile_all().await;
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(
report.actions,
vec![("nostr-rs-relay".to_string(), ReconcileAction::Installed)]
);
let calls = rt.calls();
assert!(calls.iter().any(|c| c == "remove_container:nostr-rs-relay"));
assert!(calls
.iter()
.any(|c| c == "create_container:nostr-rs-relay:offset=0"));
assert!(calls.iter().any(|c| c == "start_container:nostr-rs-relay"));
}
#[tokio::test]
async fn health_maps_states_to_strings() {
let rt = Arc::new(MockRuntime::default());
@@ -2620,6 +2932,9 @@ app:
.await;
assert_eq!(orch.health("lnd").await.unwrap(), "healthy");
rt.set_health("lnd", "unhealthy");
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
rt.set_state("lnd", ContainerState::Exited);
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
@@ -2628,6 +2943,9 @@ app:
rt.set_state("lnd", ContainerState::Created);
assert_eq!(orch.health("lnd").await.unwrap(), "starting");
rt.set_state("lnd", ContainerState::Stopping);
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
}
#[tokio::test]
+194 -68
View File
@@ -52,6 +52,10 @@ pub struct BindMount {
#[allow(dead_code)] // Bridge is reserved for Phase 5 per-app network isolation.
pub enum NetworkMode {
#[default]
Default,
/// Host networking is only for companion/proxy containers that need to
/// reach node-local daemons directly. It cannot be combined with
/// PublishPort because Podman discards port mappings in host mode.
Host,
/// A user-defined podman network — quadlet creates the container
/// attached to it. The network must already exist (orchestrator's
@@ -86,11 +90,11 @@ impl RestartPolicy {
}
}
/// Container healthcheck wired through to systemd via `Notify=healthy`.
/// When set, `systemctl start <name>.service` blocks until the container's
/// own healthcheck reports green — eliminating the "container up but RPC
/// not ready" race that the orchestrator currently papers over with
/// post-start polling.
/// Container healthcheck wired through to Podman.
/// Systemd should consider the unit started once the container process is
/// running; health probes are app status, not boot ordering. Blocking
/// `systemctl start` on health made boot reconciliation hang when an image
/// lacked the probe helper binary, even though the service itself was live.
///
/// Ranges roughly mirror the manifest's HealthCheck struct: `cmd` is the
/// shell form (`/usr/bin/curl -fsS http://localhost:8332/health` etc.),
@@ -120,8 +124,8 @@ pub struct QuadletUnit {
pub extra_podman_args: Vec<String>,
pub depends_on: Vec<String>,
/// Phase 3.4: when present the rendered unit emits HealthCmd=,
/// HealthInterval=, HealthTimeout=, HealthRetries=, AND Notify=healthy
/// so systemctl start blocks on a green health probe.
/// HealthInterval=, HealthTimeout=, and HealthRetries= for Podman's
/// health state without blocking systemd's start job.
pub health: Option<HealthSpec>,
// Backend-manifest extensions (Phase 3.1). Companion units leave
// these defaulted; the renderer skips empty/false directives so a
@@ -130,6 +134,7 @@ pub struct QuadletUnit {
pub environment: Vec<String>,
pub devices: Vec<String>,
pub add_hosts: Vec<(String, String)>,
pub network_aliases: Vec<String>,
pub entrypoint: Option<Vec<String>>,
pub command: Vec<String>,
pub read_only_root: bool,
@@ -172,11 +177,16 @@ impl QuadletUnit {
// must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never");
match &self.network {
NetworkMode::Default => {}
NetworkMode::Host => {
let _ = writeln!(s, "Network=host");
}
NetworkMode::Bridge(net) => {
let _ = writeln!(s, "Network={net}");
for alias in &self.network_aliases {
let _ = writeln!(s, "NetworkAlias={alias}");
let _ = writeln!(s, "PodmanArgs=--network-alias={alias}");
}
}
}
if let Some(user) = &self.user {
@@ -234,11 +244,6 @@ impl QuadletUnit {
let _ = writeln!(s, "HealthInterval={}", h.interval);
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
let _ = writeln!(s, "HealthRetries={}", h.retries);
// Notify=healthy: systemd treats the unit as "started" only
// after the first green health probe. Start ordering
// (Requires=/After=) downstream of this unit therefore
// doesn't fire until the app is actually serving requests.
let _ = writeln!(s, "Notify=healthy");
}
if let Some(ep) = &self.entrypoint {
// Quadlet's Exec= replaces the image entrypoint+cmd. When
@@ -261,20 +266,6 @@ impl QuadletUnit {
// OnFailure (clean stops stay stopped).
let _ = writeln!(s, "Restart={}", self.restart_policy.as_systemd());
let _ = writeln!(s, "RestartSec=10");
if self.health.is_some() {
// Notify=healthy makes systemd block the unit's "started"
// state on the first green health probe. systemd's default
// TimeoutStartSec is 90s — but `HealthInterval=30s` ×
// `HealthRetries=3` is itself 90s, so the timeout fires the
// moment the third probe MIGHT succeed. On .228 every backend
// (lnd, electrumx, fedimint, btcpay-server, mempool-api,
// bitcoin-knots) timed out at 90s and systemd terminated the
// container while it was still warming up. Bump to 600s — long
// enough for slow-starting backends (electrumx replays its
// index, lnd unlocks its wallet) without being so long that a
// truly stuck unit hangs forever.
let _ = writeln!(s, "TimeoutStartSec=600");
}
let _ = writeln!(s);
let _ = writeln!(s, "[Install]");
let _ = writeln!(s, "WantedBy=default.target");
@@ -290,11 +281,15 @@ fn shell_join(parts: &[String]) -> String {
parts
.iter()
.map(|p| {
let p = p.replace(['\r', '\n'], " ");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
let escaped = p.replace('\\', "\\\\").replace('"', "\\\"");
let escaped = p
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "$$");
format!("\"{escaped}\"")
} else {
p.clone()
p
}
})
.collect::<Vec<_>>()
@@ -323,7 +318,7 @@ impl QuadletUnit {
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
_ => match app.container.network.as_deref() {
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
_ => NetworkMode::Host,
_ => NetworkMode::Default,
},
};
@@ -366,6 +361,7 @@ impl QuadletUnit {
environment: app.environment.clone(),
devices: app.devices.clone(),
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
network_aliases: vec![name.to_string()],
entrypoint: app.container.entrypoint.clone(),
command: app.container.custom_args.clone(),
read_only_root: app.security.readonly_root,
@@ -378,12 +374,11 @@ impl QuadletUnit {
/// Translate the manifest's HealthCheck shape into a HealthSpec the
/// renderer understands. Returns None when the manifest's health spec
/// is malformed or unsupported — we'd rather skip Notify=healthy than
/// emit a broken HealthCmd that fails the unit start forever.
/// is malformed or unsupported rather than emitting a broken HealthCmd.
///
/// Supported shapes:
/// - type: tcp, endpoint: "host:port" → `nc -z host port`
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → curl
/// - type: tcp, endpoint: "host:port" → skipped for Quadlet units
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → wget/curl
/// - type: cmd, endpoint: "<shell command>" → `<shell command>` verbatim
///
/// For type=http we accept the endpoint with or without scheme; manifests
@@ -393,13 +388,11 @@ impl QuadletUnit {
/// that pasted on .228 2026-05-02 and failed every probe.
fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<HealthSpec> {
let cmd = match hc.check_type.as_str() {
"tcp" => {
let endpoint = hc.endpoint.as_deref()?;
let (host, port) = endpoint.rsplit_once(':')?;
// nc is in busybox/coreutils on every base image we ship.
// The -z flag does a "scan" that exits 0 on connect, 1 otherwise.
format!("nc -z {host} {port}")
}
// A generic TCP probe inside arbitrary app images is not reliable:
// some images lack nc, some lack bash /dev/tcp, and failures leave
// Podman/systemd health in a false-negative state. Keep TCP readiness
// checks in the host-side lifecycle/status layer instead.
"tcp" => return None,
"http" => {
let endpoint = hc.endpoint.as_deref()?.trim();
// Accept either bare host:port or a full URL. If endpoint
@@ -426,9 +419,14 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
let path = hc.path.as_deref().unwrap_or("/");
format!("{url}{path}")
};
// -fsS: fail on non-2xx, silent except on error, show errors.
// -m 5: per-request timeout matches the default manifest timeout.
format!("curl -fsS -m 5 {final_url}")
// Images vary wildly: SearXNG ships wget but no curl, while some
// Node images ship neither. Use whichever probe helper exists and
// skip Podman health if the image has none; host-side lifecycle
// probes still verify reachability.
format!(
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 {0}; else exit 0; fi",
final_url
)
}
"cmd" => hc.endpoint.as_deref()?.to_string(),
_ => return None,
@@ -528,6 +526,70 @@ pub async fn enable_now(service: &str) -> Result<()> {
Ok(())
}
/// Restart a generated Quadlet service after rewriting a known-bad unit.
pub async fn restart_service(service: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["--user", "restart", service])
.status()
.await
.with_context(|| format!("spawn systemctl --user restart {service}"))?;
if !status.success() {
return Err(anyhow!(
"systemctl --user restart {service} exited {status}"
));
}
Ok(())
}
/// Stop a generated Quadlet service without removing its unit file.
pub async fn stop_service(service: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["--user", "stop", service])
.status()
.await
.with_context(|| format!("spawn systemctl --user stop {service}"))?;
if !status.success() {
return Err(anyhow!("systemctl --user stop {service} exited {status}"));
}
Ok(())
}
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
unit_body.contains("Notify=healthy")
|| unit_body.contains("TimeoutStartSec=600")
|| unit_body.contains("HealthCmd=nc -z")
}
pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
directive_values(old_body, "HealthCmd=") != directive_values(new_body, "HealthCmd=")
}
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
let old_ports = directive_values(old_body, "PublishPort=");
let new_ports = directive_values(new_body, "PublishPort=");
old_ports != new_ports
}
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
let old_aliases = directive_values(old_body, "NetworkAlias=");
let new_aliases = directive_values(new_body, "NetworkAlias=");
old_aliases != new_aliases
}
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
let old_exec = directive_values(old_body, "Exec=");
let new_exec = directive_values(new_body, "Exec=");
old_exec != new_exec
}
fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
unit_body
.lines()
.filter_map(|line| line.trim().strip_prefix(prefix))
.map(str::to_string)
.collect()
}
/// Stop + remove a quadlet unit and its on-disk file. Best-effort:
/// errors stop only the destructive write at the failing step so a
/// partial removal doesn't leave a quadlet file pointing at a service
@@ -706,6 +768,14 @@ mod tests {
);
// Embedded quotes must escape:
assert_eq!(shell_join(&[r#"say "hi""#.into()]), r#""say \"hi\"""#);
assert_eq!(
shell_join(&[
"sh".into(),
"-lc".into(),
"if true; then\n exec app;\nfi".into()
]),
"sh -lc \"if true; then exec app; fi\""
);
}
#[test]
@@ -829,6 +899,29 @@ app:
assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
}
#[test]
fn from_manifest_uses_default_network_for_isolated_ports() {
let yaml = r#"
app:
id: searxng
name: SearXNG
version: 1.0.0
container:
image: searxng:latest
ports:
- host: 8888
container: 8080
protocol: tcp
security:
network_policy: isolated
"#;
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "searxng").render();
assert!(s.contains("PublishPort=8888:8080/tcp"));
assert!(!s.contains("Network=host"));
}
#[test]
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
let yaml = r#"
@@ -916,28 +1009,25 @@ app:
u.name = "lnd".into();
u.image = "x:1".into();
u.health = Some(HealthSpec {
cmd: "nc -z localhost 10009".into(),
cmd: "probe-ready".into(),
interval: "30s".into(),
timeout: "5s".into(),
retries: 3,
});
let s = u.render();
assert!(s.contains("HealthCmd=nc -z localhost 10009"));
assert!(s.contains("HealthCmd=probe-ready"));
assert!(s.contains("HealthInterval=30s"));
assert!(s.contains("HealthTimeout=5s"));
assert!(s.contains("HealthRetries=3"));
assert!(s.contains("Notify=healthy"));
// Notify=healthy needs a long-enough TimeoutStartSec or systemd
// kills the unit before the first probe can pass — observed live
// on .228 2026-05-02 across all six backends.
assert!(s.contains("TimeoutStartSec=600"), "got: {s}");
assert!(!s.contains("Notify=healthy"));
assert!(!s.contains("TimeoutStartSec=600"));
}
#[test]
fn render_skips_health_directives_when_absent() {
// No health spec → no Notify=healthy, no HealthCmd, no
// TimeoutStartSec override (default 90s applies). Companions rely
// on this so their rendered bytes stay unchanged.
// No health spec → no Notify=healthy, no HealthCmd, no TimeoutStartSec
// override. Companions rely on this so their rendered bytes stay
// unchanged.
let s = sample_unit().render();
assert!(!s.contains("HealthCmd="));
assert!(!s.contains("Notify=healthy"));
@@ -956,9 +1046,7 @@ app:
timeout: "5s".into(),
retries: 3,
};
let h = translate_health_check(&tcp).expect("tcp must translate");
assert_eq!(h.cmd, "nc -z localhost 10009");
assert_eq!(h.retries, 3);
assert!(translate_health_check(&tcp).is_none());
let http = HealthCheck {
check_type: "http".into(),
@@ -969,7 +1057,10 @@ app:
retries: 5,
};
let h = translate_health_check(&http).expect("http must translate");
assert_eq!(h.cmd, "curl -fsS -m 5 http://localhost:8080/health");
assert_eq!(
h.cmd,
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/health; else exit 0; fi"
);
let cmdck = HealthCheck {
check_type: "cmd".into(),
@@ -982,8 +1073,7 @@ app:
let h = translate_health_check(&cmdck).expect("cmd must translate");
assert_eq!(h.cmd, "/usr/local/bin/probe.sh");
// Unknown type → None (renderer skips Notify=healthy entirely
// rather than emit a broken HealthCmd that hangs the unit start).
// Unknown type → None rather than emit a broken HealthCmd.
let bad = HealthCheck {
check_type: "exec".into(),
endpoint: Some("foo".into()),
@@ -994,7 +1084,7 @@ app:
};
assert!(translate_health_check(&bad).is_none());
// Malformed tcp endpoint → None (no port separator).
// TCP is skipped entirely for Quadlet units.
let badtcp = HealthCheck {
check_type: "tcp".into(),
endpoint: Some("hostonly".into()),
@@ -1022,7 +1112,7 @@ app:
retries: 3,
};
let h = translate_health_check(&with_scheme).expect("with-scheme must translate");
assert_eq!(h.cmd, "curl -fsS -m 5 http://localhost:8175/");
assert!(h.cmd.contains("http://localhost:8175/"));
assert!(!h.cmd.contains("http://http://"), "got: {}", h.cmd);
let with_https = HealthCheck {
@@ -1035,7 +1125,7 @@ app:
};
let h = translate_health_check(&with_https).expect("https must translate");
// Endpoint already has /health → don't append the default "/".
assert_eq!(h.cmd, "curl -fsS -m 5 https://example.local/health");
assert!(h.cmd.contains("https://example.local/health"));
}
#[test]
@@ -1056,12 +1146,46 @@ app:
"#;
let m = AppManifest::parse(yaml).unwrap();
let u = QuadletUnit::from_manifest(&m, "lnd");
let h = u.health.as_ref().expect("health should be populated");
assert_eq!(h.cmd, "nc -z localhost 10009");
assert_eq!(h.interval, "15s");
assert_eq!(h.timeout, "4s");
assert_eq!(h.retries, 5);
assert!(u.render().contains("Notify=healthy"));
assert!(u.health.is_none());
assert!(!u.render().contains("Notify=healthy"));
}
#[test]
fn publish_ports_changed_detects_port_binding_drift() {
let old = "[Container]\nPublishPort=9735:9735/tcp\nPublishPort=8080:8080/tcp\n";
let new = "[Container]\nPublishPort=9735:9735/tcp\nPublishPort=18080:8080/tcp\n";
assert!(publish_ports_changed(old, new));
assert!(!publish_ports_changed(new, new));
}
#[test]
fn network_aliases_changed_detects_service_discovery_drift() {
let old = "[Container]\nNetwork=archy-net\n";
let new = "[Container]\nNetwork=archy-net\nNetworkAlias=bitcoin-knots\n";
assert!(network_aliases_changed(old, new));
assert!(!network_aliases_changed(new, new));
}
#[test]
fn shell_join_escapes_dollars_for_container_runtime_expansion() {
let rendered = shell_join(&["sh".into(), "-lc".into(), "echo ${BITCOIN_RPC_PASS}".into()]);
assert!(rendered.contains("$${BITCOIN_RPC_PASS}"));
}
#[test]
fn exec_changed_detects_command_drift() {
let old = "[Container]\nExec=sh -lc \"echo ${BITCOIN_RPC_PASS}\"\n";
let new = "[Container]\nExec=sh -lc \"echo $${BITCOIN_RPC_PASS}\"\n";
assert!(exec_changed(old, new));
assert!(!exec_changed(new, new));
}
#[test]
fn health_cmd_changed_detects_probe_drift() {
let old = "[Container]\nHealthCmd=curl -fsS http://localhost:8080/\n";
let new = "[Container]\nHealthCmd=if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi\n";
assert!(health_cmd_changed(old, new));
assert!(!health_cmd_changed(new, new));
}
#[test]
@@ -1098,6 +1222,8 @@ app:
assert!(body.contains("ContainerName=lnd"));
assert!(body.contains("Image=registry/lnd:latest"));
assert!(body.contains("Network=archy-net"));
assert!(body.contains("NetworkAlias=lnd"));
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
assert!(body.contains("PublishPort=10009:10009/tcp"));
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
+54 -60
View File
@@ -11,8 +11,9 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Output;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use std::time::{Duration, Instant};
use tokio::fs;
use tracing::{info, warn};
@@ -189,15 +190,9 @@ pub async fn remove_pid_marker(data_dir: &Path) {
/// Save a snapshot of currently running containers to disk.
/// Called periodically so we know what to restart after a crash.
pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
let output = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("podman")
.args(["ps", "--format", "json"])
.output(),
)
.await
.context("podman ps timed out (30s)")?
.context("Failed to run podman ps")?;
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["ps", "--format", "json"]);
let output = command_with_timeout(cmd, Duration::from_secs(30), "podman ps").await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -277,22 +272,23 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
);
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
tokio::process::Command::new("podman")
.args(["start", &record.name])
.output(),
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["start", &record.name]);
let result = command_with_timeout(
cmd,
Duration::from_secs(timeout_secs),
&format!("podman start {}", record.name),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
Ok(output) if output.status.success() => {
info!("Successfully restarted container: {}", record.name);
report.recovered += 1;
started = true;
break;
}
Ok(Ok(output)) => {
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"Failed to restart container {} (attempt {}): {}",
@@ -301,20 +297,13 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
stderr.trim()
);
}
Ok(Err(e)) => {
Err(e) => {
warn!(
"Failed to execute podman start for {} (attempt {}): {}",
record.name,
attempt + 1,
e
);
}
Err(_) => {
warn!(
"Timeout starting container {} ({}s, attempt {})",
"Failed to start container {} ({}s, attempt {}): {}",
record.name,
timeout_secs,
attempt + 1
attempt + 1,
e
);
}
}
@@ -345,26 +334,22 @@ fn is_process_running(pid: u32) -> bool {
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
/// Skips containers that the user intentionally stopped via the UI.
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
let output = match tokio::time::timeout(
std::time::Duration::from_secs(60),
tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--filter",
"status=exited",
"--filter",
"status=created",
"--format",
"{{.Names}}",
])
.output(),
)
.await
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
"-a",
"--filter",
"status=exited",
"--filter",
"status=created",
"--format",
"{{.Names}}",
]);
let output = match command_with_timeout(cmd, Duration::from_secs(60), "podman ps stopped").await
{
Ok(result) => result,
Err(_) => {
warn!("Timeout listing stopped containers (60s)");
Ok(output) => output,
Err(e) => {
warn!("Failed listing stopped containers: {}", e);
return RecoveryReport {
total: 0,
recovered: 0,
@@ -373,13 +358,14 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
}
};
let all_names: Vec<String> = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
let all_names: Vec<String> = if output.status.success() {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect(),
_ => Vec::new(),
.collect()
} else {
Vec::new()
};
if all_names.is_empty() {
@@ -481,27 +467,35 @@ pub async fn run_boot_reconciliation() {
return;
}
info!("Running boot reconciliation...");
let result = tokio::time::timeout(
std::time::Duration::from_secs(300),
tokio::process::Command::new(script).output(),
)
.await;
let cmd = tokio::process::Command::new(script);
let result = command_with_timeout(cmd, Duration::from_secs(300), script).await;
match result {
Ok(Ok(output)) if output.status.success() => {
Ok(output) if output.status.success() => {
info!("Boot reconciliation complete");
}
Ok(Ok(output)) => {
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"Boot reconciliation had failures: {}",
stderr.chars().take(500).collect::<String>()
);
}
Ok(Err(e)) => warn!("Boot reconciliation failed to run: {}", e),
Err(_) => warn!("Boot reconciliation timed out (300s)"),
Err(e) => warn!("Boot reconciliation failed: {}", e),
}
}
async fn command_with_timeout(
mut cmd: tokio::process::Command,
timeout: Duration,
description: &str,
) -> Result<Output> {
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))?
.with_context(|| format!("Failed to run {}", description))
}
/// Spawn a background task that periodically saves the container snapshot.
pub fn spawn_snapshot_task(data_dir: PathBuf) {
tokio::spawn(async move {
+55 -3
View File
@@ -216,6 +216,7 @@ struct ContainerHealth {
name: String,
app_id: String,
state: String,
podman_health: Option<String>,
healthy: bool,
}
@@ -447,18 +448,40 @@ async fn check_containers() -> Vec<ContainerHealth> {
.unwrap_or("unknown")
.to_lowercase();
let healthy = state == "running";
let podman_health = parse_podman_health(c, &state);
let healthy = state == "running" && podman_health.as_deref() != Some("unhealthy");
Some(ContainerHealth {
name,
app_id,
state,
podman_health,
healthy,
})
})
.collect()
}
fn parse_podman_health(c: &serde_json::Value, state: &str) -> Option<String> {
c.get("Status")
.and_then(|v| v.as_str())
.and_then(parse_health_from_status)
.or_else(|| {
c.get("State")
.and_then(|v| v.get("Health"))
.and_then(|v| v.get("Status"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| state.contains("unhealthy").then(|| "unhealthy".to_string()))
}
fn parse_health_from_status(status: &str) -> Option<String> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| status[start + 1..end].to_string())
}
/// Try to restart a container.
async fn restart_container(name: &str) -> bool {
info!("Auto-restarting unhealthy container: {}", name);
@@ -590,6 +613,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
"Skipping orphan container (not in package_data): {}",
container.name
);
let before = data.notifications.len();
data.notifications.retain(|n| {
n.app_id.as_deref() != Some(&container.app_id)
&& !n.title.contains(&container.app_id)
});
if data.notifications.len() != before {
state_changed = true;
}
continue;
}
@@ -628,8 +659,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
}
continue;
}
// Handle exited, stopped, AND created state containers
if container.state == "exited"
// Handle exited, stopped, created, and Podman-unhealthy running containers.
if container.podman_health.as_deref() == Some("unhealthy")
|| container.state == "exited"
|| container.state == "stopped"
|| container.state == "created"
{
@@ -874,6 +906,7 @@ mod tests {
name: "archy-bitcoin-knots".to_string(),
app_id: "bitcoin-knots".to_string(),
state: "running".to_string(),
podman_health: Some("healthy".to_string()),
healthy: true,
};
assert!(health.healthy);
@@ -888,6 +921,7 @@ mod tests {
name: "archy-mempool-web".to_string(),
app_id: "mempool-web".to_string(),
state: "exited".to_string(),
podman_health: None,
healthy: false,
};
assert!(!health.healthy);
@@ -977,18 +1011,21 @@ mod tests {
name: "indeedhub-postgres".into(),
app_id: "indeedhub-postgres".into(),
state: "running".into(),
podman_health: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-api".into(),
app_id: "indeedhub-api".into(),
state: "exited".into(),
podman_health: None,
healthy: false,
},
];
@@ -998,6 +1035,7 @@ mod tests {
name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
assert!(!deps_are_running("indeedhub-api", &partial));
@@ -1009,6 +1047,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
assert!(deps_are_running("lnd", &core));
@@ -1017,6 +1056,7 @@ mod tests {
name: "bitcoin-knots".into(),
app_id: "bitcoin-knots".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
assert!(deps_are_running("fedimint", &knots));
@@ -1025,6 +1065,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
healthy: false,
}];
assert!(!deps_are_running("electrumx", &stopped));
@@ -1036,6 +1077,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
@@ -1050,6 +1092,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
healthy: false,
}];
@@ -1121,4 +1164,13 @@ mod tests {
tracker.record("test", 100_000_000);
assert!(tracker.check_leak("test").is_none());
}
#[test]
fn podman_unhealthy_makes_running_container_unhealthy() {
let c = serde_json::json!({ "Status": "Up 5 minutes (unhealthy)" });
assert_eq!(
parse_podman_health(&c, "running").as_deref(),
Some("unhealthy")
);
}
}
+2 -1
View File
@@ -27,7 +27,8 @@ const RESERVED_PORTS: &[u16] = &[
3001, 3002, // Gitea, Uptime Kuma
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, 8084, // NPM
8443, // FIPS TCP fallback
8444, 8084, // NPM
];
/// Start of range for allocating web app ports when preferred is taken.
+11 -1
View File
@@ -838,7 +838,7 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
/// 600s. 2× that gives the spawned task ample margin before we assume it
/// died (panic, OOM, process restart mid-stop) and fall back to the
/// scanner's authoritative view. Applies to all transitional variants.
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(1200);
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
/// Returns true if `state` is one of the transitional variants that a
/// `spawn_transitional`-style background task owns. While such a state is
@@ -1029,6 +1029,16 @@ async fn scan_and_update_packages(
absence_tracker.remove(&id);
continue;
}
// Quadlet-generated units run containers with `--rm`, so a
// clean user stop removes the Podman record. Keep the package
// visible as Stopped while the user-stopped marker exists so
// package.start can recreate it via systemd/Quadlet.
if entry.state == crate::data_model::PackageState::Stopped
&& user_stopped.contains(&id)
{
absence_tracker.remove(&id);
continue;
}
}
let count = absence_tracker.entry(id.clone()).or_insert(0);
*count += 1;
+5 -2
View File
@@ -713,11 +713,14 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// verified over the complete file at the end of each component, so a
/// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let state = load_state(data_dir).await?;
let mut state = load_state(data_dir).await?;
if state.available_update.is_none() {
state = check_for_updates(data_dir).await?;
}
let manifest = state
.available_update
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No update available to download"))?;
.ok_or_else(|| anyhow::anyhow!("No update is available to download"))?;
let staging_dir = data_dir.join("update-staging");
fs::create_dir_all(&staging_dir)