chore(release): stage v1.7.55-alpha
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user