chore(release): stage v1.7.55-alpha

This commit is contained in:
Dorian 2026-05-13 15:09:22 -04:00
parent 3202b79e41
commit 835c525218
65 changed files with 2322 additions and 566 deletions

View File

@ -1,5 +1,11 @@
# Changelog # Changelog
## v1.7.55-alpha (2026-05-13)
- Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
- `.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses.
- Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired.
## v1.7.54-alpha (2026-05-06) ## v1.7.54-alpha (2026-05-06)
- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink. - Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.
@ -37,7 +43,7 @@
## v1.7.47-alpha (2026-04-29) ## v1.7.47-alpha (2026-04-29)
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed. - Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
- ElectrumX initial indexing is faster too. Its container memory bumped from 1GB to 2GB and its internal cache is now 2GB (default was 1.2GB). - ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
## v1.7.46-alpha (2026-04-29) ## v1.7.46-alpha (2026-04-29)

View File

@ -115,7 +115,12 @@
"author": "BotFights", "author": "BotFights",
"category": "community", "category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0", "dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net" "repoUrl": "https://botfights.net",
"containerConfig": {
"ports": ["9100:9100"],
"volumes": ["/var/lib/archipelago/botfights:/app/server/data"],
"env": ["NODE_ENV=production", "PORT=9100", "FIGHT_LOOP_ENABLED=true", "ARCHY_EMBEDDED=1"]
}
}, },
{ {
"id": "gitea", "id": "gitea",
@ -126,7 +131,12 @@
"author": "Gitea", "author": "Gitea",
"category": "development", "category": "development",
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23", "dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
"repoUrl": "https://gitea.com" "repoUrl": "https://gitea.com",
"containerConfig": {
"ports": ["3001:3000", "2222:22"],
"volumes": ["/var/lib/archipelago/gitea/data:/data", "/var/lib/archipelago/gitea/config:/etc/gitea"],
"env": ["GITEA__database__DB_TYPE=sqlite3", "GITEA__server__SSH_PORT=2222", "GITEA__server__SSH_LISTEN_PORT=22", "GITEA__server__LFS_START_SERVER=true", "GITEA__packages__ENABLED=true", "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", "GITEA__security__X_FRAME_OPTIONS="]
}
}, },
{ {
"id": "filebrowser", "id": "filebrowser",
@ -138,7 +148,12 @@
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0", "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser" "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": ["8083:80"],
"volumes": ["/var/lib/archipelago/filebrowser:/srv", "/var/lib/archipelago/filebrowser-data:/data"],
"args": ["--database=/data/database.db", "--root=/srv", "--address=0.0.0.0", "--port=80"]
}
}, },
{ {
"id": "vaultwarden", "id": "vaultwarden",
@ -150,7 +165,11 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine", "dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden" "repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": ["8082:80"],
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
}
}, },
{ {
"id": "searxng", "id": "searxng",
@ -162,7 +181,11 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest", "dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng" "repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": ["8888:8080"],
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
}
}, },
{ {
"id": "fedimint", "id": "fedimint",
@ -184,7 +207,11 @@
"author": "Jellyfin", "author": "Jellyfin",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13", "dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin" "repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": ["8096:8096"],
"volumes": ["/var/lib/archipelago/jellyfin/config:/config", "/var/lib/archipelago/jellyfin/cache:/cache"]
}
}, },
{ {
"id": "immich", "id": "immich",
@ -206,7 +233,12 @@
"author": "Home Assistant", "author": "Home Assistant",
"category": "home", "category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1", "dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core" "repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": ["8123:8123"],
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
"env": ["TZ=UTC"]
}
}, },
{ {
"id": "grafana", "id": "grafana",
@ -218,7 +250,12 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0", "dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana" "repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": ["3000:3000"],
"volumes": ["/var/lib/archipelago/grafana:/var/lib/grafana"],
"env": ["GF_PATHS_DATA=/var/lib/grafana", "GF_USERS_ALLOW_SIGN_UP=false"]
}
}, },
{ {
"id": "tailscale", "id": "tailscale",
@ -230,7 +267,13 @@
"category": "networking", "category": "networking",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable", "dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale" "repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": ["8240:8240"],
"volumes": ["/var/lib/archipelago/tailscale:/var/lib/tailscale"],
"env": ["TS_STATE_DIR=/var/lib/tailscale"],
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
}
}, },
{ {
"id": "uptime-kuma", "id": "uptime-kuma",
@ -242,7 +285,13 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1", "dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma" "repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": ["3002:3001"],
"volumes": ["/var/lib/archipelago/uptime-kuma:/app/data"],
"env": ["TZ=UTC"],
"args": ["--", "node", "server/server.js"]
}
}, },
{ {
"id": "photoprism", "id": "photoprism",
@ -253,7 +302,12 @@
"author": "PhotoPrism", "author": "PhotoPrism",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915", "dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism" "repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": ["2342:2342"],
"volumes": ["/var/lib/archipelago/photoprism:/photoprism/storage"],
"env": ["PHOTOPRISM_ADMIN_PASSWORD=archipelago", "PHOTOPRISM_DEFAULT_LOCALE=en"]
}
}, },
{ {
"id": "nextcloud", "id": "nextcloud",
@ -264,7 +318,11 @@
"author": "Nextcloud", "author": "Nextcloud",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28", "dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server" "repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": ["8085:80"],
"volumes": ["/var/lib/archipelago/nextcloud:/var/www/html"]
}
} }
] ]
} }

View File

@ -19,7 +19,7 @@ This document lists all port assignments for Archipelago apps.
| searxng | 8888 | TCP | Web UI | 18888 | | searxng | 8888 | TCP | Web UI | 18888 |
| onlyoffice | 8088 | TCP | Web UI | 18088 | | onlyoffice | 8088 | TCP | Web UI | 18088 |
| penpot | 8089 | TCP | Web UI | 18089 | | penpot | 8089 | TCP | Web UI | 18089 |
| lnd | 9735, 10009, 8080 | TCP | P2P, gRPC, REST | 19735, 20009, 18080 | | lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 | | core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 | | nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 | | strfry | 8082 | TCP | HTTP/WebSocket | 18082 |

View File

@ -26,10 +26,13 @@ app:
echo "bitcoind not found in image" >&2; echo "bitcoind not found in image" >&2;
exit 127; exit 127;
fi; fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then RPC_USER="$(printenv BITCOIN_RPC_USER)";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}"; RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}"; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi fi
derived_env: derived_env:
- key: DISK_GB - key: DISK_GB
@ -44,7 +47,7 @@ app:
resources: resources:
cpu_limit: 0 cpu_limit: 0
memory_limit: 4Gi memory_limit: 8Gi
disk_limit: 500Gi disk_limit: 500Gi
security: security:

View File

@ -12,7 +12,7 @@ app:
entrypoint: ["sh", "-lc"] entrypoint: ["sh", "-lc"]
custom_args: custom_args:
- >- - >-
export DAEMON_URL="http://archipelago:${BITCOIN_RPC_PASS}@bitcoin-knots:8332/"; export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
exec electrumx_server exec electrumx_server
secret_env: secret_env:
- key: BITCOIN_RPC_PASS - key: BITCOIN_RPC_PASS
@ -24,8 +24,8 @@ app:
- storage: 50Gi - storage: 50Gi
resources: resources:
cpu_limit: 2 cpu_limit: 0
memory_limit: 2Gi memory_limit: 4Gi
disk_limit: 50Gi disk_limit: 50Gi
security: security:
@ -48,6 +48,8 @@ app:
- COIN=Bitcoin - COIN=Bitcoin
- DB_DIRECTORY=/data - DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000 - SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- MAX_SEND=10000000
health_check: health_check:
type: tcp type: tcp

View File

@ -53,7 +53,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8123 endpoint: http://localhost:8123
path: /api/ path: /
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -34,7 +34,7 @@ app:
- host: 10009 - host: 10009
container: 10009 container: 10009
protocol: tcp protocol: tcp
- host: 8080 - host: 18080
container: 8080 container: 8080
protocol: tcp protocol: tcp

View File

@ -58,7 +58,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8999 endpoint: http://localhost:8999
path: / path: /api/v1/backend-info
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -46,8 +46,8 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8081 endpoint: http://localhost:8080
path: /health path: /
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -1,7 +1,7 @@
app: app:
id: searxng id: searxng
name: SearXNG name: SearXNG
version: latest version: 1.0.0
description: Privacy-respecting metasearch engine. Search the web without tracking. description: Privacy-respecting metasearch engine. Search the web without tracking.
container: container:
@ -42,7 +42,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://localhost:8888 endpoint: http://localhost:8080
path: / path: /
interval: 30s interval: 30s
timeout: 5s timeout: 5s

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.54-alpha" version = "1.7.55-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.54-alpha" version = "1.7.55-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -1,4 +1,5 @@
use super::build_response; use super::build_response;
use crate::api::rpc::lnd::LND_REST_BASE_URL;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
use crate::bitcoin_status; use crate::bitcoin_status;
use crate::electrs_status; use crate::electrs_status;
@ -123,7 +124,7 @@ impl ApiHandler {
cors_origin: &str, cors_origin: &str,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); 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 { match reqwest::get(&url).await {
Ok(resp) => { Ok(resp) => {
let status = resp.status().as_u16(); let status = resp.status().as_u16();

View File

@ -2,6 +2,10 @@ use super::package::validate_app_id;
use super::transitional::Op; use super::transitional::Op;
use super::RpcHandler; use super::RpcHandler;
use anyhow::{Context, Result}; 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 { impl RpcHandler {
pub(super) async fn handle_container_install( pub(super) async fn handle_container_install(
@ -379,6 +383,10 @@ impl RpcHandler {
// If app_id is provided, get health for that app. // If app_id is provided, get health for that app.
if let Some(params) = params { if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) { 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; let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) { for candidate in status_app_id_candidates(app_id) {
match orchestrator.health(&candidate).await { match orchestrator.health(&candidate).await {
@ -434,6 +442,78 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map)) 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> { 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> { async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
let out = tokio::process::Command::new("podman") if let Some(v) = ps_container_state_value(name).await {
.args([ return Some(v);
"inspect", }
name,
"--format", let mut cmd = tokio::process::Command::new("podman");
"{{.State.Status}} {{.State.Running}}", cmd.args([
]) "inspect",
.output() 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 .await
.ok()?
.ok()?; .ok()?;
if !out.status.success() { if !out.status.success() {
return None; 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 mut parts = line.split_whitespace();
let status = parts.next().unwrap_or("unknown"); let status = parts.next().unwrap_or("unknown");
let running = parts.next().unwrap_or("false") == "true"; let running = parts.next().unwrap_or("false") == "true";
let health = parts.next().unwrap_or("none");
Some(serde_json::json!({ Some(serde_json::json!({
"name": name, "name": name,
"status": status, "status": status,
"state": status, "state": status,
"running": running, "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> { async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?; 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") { match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()), "running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()), "created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()), "paused" => Some("paused".to_string()),
"stopping" => Some("unhealthy".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()), "exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")), other => Some(format!("unknown:{other}")),
} }

View File

@ -3,6 +3,8 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::info; use tracing::info;
use super::LND_REST_BASE_URL;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ChannelInfo { struct ChannelInfo {
chan_id: String, chan_id: String,
@ -62,7 +64,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
let channels_resp: LndListChannelsResponse = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@ -72,7 +74,7 @@ impl RpcHandler {
.context("Failed to parse LND channels response")?; .context("Failed to parse LND channels response")?;
let pending_resp: LndPendingChannelsResponse = match client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@ -211,7 +213,7 @@ impl RpcHandler {
"perm": true "perm": true
}); });
let _ = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&connect_body) .json(&connect_body)
.send() .send()
@ -224,7 +226,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&open_body) .json(&open_body)
.send() .send()
@ -291,7 +293,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!( let url = format!(
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}", "{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force parts[0], parts[1], force
); );

View File

@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use base64::Engine; use base64::Engine;
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Serialize)]
struct LndInfo { struct LndInfo {
@ -44,7 +44,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?; .context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@ -54,7 +54,7 @@ impl RpcHandler {
.context("Failed to parse LND getinfo response")?; .context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@ -70,7 +70,7 @@ impl RpcHandler {
}; };
let wallet_balance: LndBalanceResponse = match client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@ -166,7 +166,7 @@ impl RpcHandler {
"cert_base64url": cert_b64url, "cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url, "macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion, "tor_onion": tor_onion,
"rest_port": 8080, "rest_port": 18080,
"grpc_port": 10009, "grpc_port": 10009,
})) }))
} }
@ -186,7 +186,7 @@ impl RpcHandler {
.context("Failed to build HTTP client")?; .context("Failed to build HTTP client")?;
let resp = 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await

View File

@ -9,6 +9,7 @@ use anyhow::{anyhow, Context, Result};
/// Canonical on-host path for LND's admin macaroon. /// Canonical on-host path for LND's admin macaroon.
pub(crate) const LND_ADMIN_MACAROON_PATH: &str = pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; "/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 // Shared LND response types used by multiple submodules
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]

View File

@ -2,6 +2,8 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tracing::info; use tracing::info;
use super::LND_REST_BASE_URL;
impl RpcHandler { impl RpcHandler {
/// Pay a Lightning invoice. /// Pay a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_payinvoice( pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
@ -35,7 +37,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&pay_body) .json(&pay_body)
.send() .send()
@ -91,7 +93,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await

View File

@ -4,13 +4,15 @@ use base64::Engine;
use tracing::info; use tracing::info;
use zeroize::Zeroize; use zeroize::Zeroize;
use super::LND_REST_BASE_URL;
impl RpcHandler { impl RpcHandler {
/// Generate a new on-chain Bitcoin address. /// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> { pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@ -69,7 +71,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&send_body) .json(&send_body)
.send() .send()
@ -129,7 +131,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body) .json(&invoice_body)
.send() .send()
@ -231,7 +233,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body) .json(&fund_body)
.send() .send()
@ -289,7 +291,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body) .json(&finalize_body)
.send() .send()
@ -322,7 +324,7 @@ impl RpcHandler {
}); });
let pub_resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&publish_body) .json(&publish_body)
.send() .send()
@ -387,7 +389,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body) .json(&fund_body)
.send() .send()
@ -416,7 +418,7 @@ impl RpcHandler {
}); });
let resp = client 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) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body) .json(&finalize_body)
.send() .send()
@ -514,7 +516,7 @@ impl RpcHandler {
}); });
let resp = client let resp = client
.post("https://127.0.0.1:8080/v1/initwallet") .post(format!("{LND_REST_BASE_URL}/v1/initwallet"))
.json(&init_body) .json(&init_body)
.send() .send()
.await .await

View File

@ -188,7 +188,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?; 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 let paid = match client
.get(&url) .get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)

View File

@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Unauthorized", "Unauthorized",
"Forbidden", "Forbidden",
"Not supported", "Not supported",
"Requires",
"requires", "requires",
"must be", "must be",
"cannot", "cannot",

View File

@ -12,7 +12,7 @@ mod fips;
mod handshake; mod handshake;
mod identity; mod identity;
mod interfaces; mod interfaces;
mod lnd; pub(in crate::api) mod lnd;
mod marketplace; mod marketplace;
mod mesh; mod mesh;
mod middleware; mod middleware;

View File

@ -54,6 +54,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))? .ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string(); .to_string();
super::validation::validate_app_id(&package_id)?; 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 // Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package). // queuing two installs on the same package).

View File

@ -1,6 +1,96 @@
use super::validation::validate_app_id; use super::validation::validate_app_id;
use crate::port_allocator::PortAllocator; use crate::port_allocator::PortAllocator;
use anyhow::{Context, Result}; 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. /// Trusted Docker registries. Only images from these sources are allowed.
#[allow(dead_code)] #[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") ("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
} }
"mempool-api" => ( "mempool-api" => (
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1", http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
"30s", "30s",
"3", "3",
), ),
"mempool" | "mempool-web" | "archy-mempool-web" => { "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" => { "electrumx" | "mempool-electrs" | "electrs" => {
("curl -sf http://localhost:8000/ || exit 1", "60s", "3") ("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", "3",
), ),
"homeassistant" | "home-assistant" => { "homeassistant" | "home-assistant" => {
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3") ("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
} }
"grafana" => ( "grafana" => (
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1", "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", "30s",
"3", "3",
), ),
"searxng" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"), "searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"photoprism" => ( "photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1", "curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s", "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"), "ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"), "fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"), "fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
"nostr-rs-relay" | "nostr-relay" => { "nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
("curl -sf http://localhost:8080/ || exit 1", "30s", "3") "nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
}
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
"routstr" => ( "routstr" => (
"curl -sf http://localhost:8000/v1/models || exit 1", "curl -sf http://localhost:8000/v1/models || exit 1",
"30s", "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-cmd={}", cmd),
format!("--health-interval={}", interval), format!("--health-interval={}", interval),
format!("--health-retries={}", retries), format!("--health-retries={}", retries),
"--health-timeout=10s".to_string(),
"--health-start-period=60s".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. /// Get per-app memory limit.
pub(super) fn get_memory_limit(app_id: &str) -> &'static str { pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
match app_id { 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 // memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass). // floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g", "bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: bumped from 1g to 2g so its CACHE_MB has somewhere // ElectrumX: large cache materially speeds initial history indexing.
// to live during initial blockchain indexing. CACHE_MB=2048 in // CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// env vars below requires this much. // socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "2g", "electrumx" | "mempool-electrs" | "electrs" => "4g",
"cryptpad" => "512m", "cryptpad" => "512m",
"ollama" => "4g", "ollama" => "4g",
// Medium apps // 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. /// Find all running/stopped containers that belong to a given app.
/// Uses the canonical name list from all_container_names(). /// 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)?; validate_app_id(package_id)?;
let output = tokio::process::Command::new("podman") let mut cmd = tokio::process::Command::new("podman");
.args(["ps", "-a", "--format", "{{.Names}}"]) cmd.args(["ps", "-a", "--format", "{{.Names}}"]);
.output() cmd.kill_on_drop(true);
let output = tokio::time::timeout(PODMAN_LIST_TIMEOUT, cmd.output())
.await .await
.context("podman ps timed out while listing containers")?
.context("Failed to list containers")?; .context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect(); 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<String>,
Option<Vec<String>>, Option<Vec<String>>,
) { ) {
if let Some(config) = dynamic_app_config(app_id).await {
return config;
}
match app_id { match app_id {
"homeassistant" | "home-assistant" => ( "homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()], vec!["8123:8123".to_string()],
@ -600,7 +705,7 @@ pub(super) async fn get_app_config(
vec![ vec![
"9735:9735".to_string(), "9735:9735".to_string(),
"10009:10009".to_string(), "10009:10009".to_string(),
"8080:8080".to_string(), "18080:8080".to_string(),
], ],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()], vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec![], vec![],
@ -676,9 +781,10 @@ pub(super) async fn get_app_config(
"DB_DIRECTORY=/data".to_string(), "DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(), "SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial // Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB, container now // history index. Default is 1200MB; the container gets
// gets 2g (config.rs::get_memory_limit) so 2048 fits. // 4g (config.rs::get_memory_limit) so 3072 fits with
"CACHE_MB=2048".to_string(), // headroom.
"CACHE_MB=3072".to_string(),
// Block-fetcher concurrency — defaults are conservative // Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend. // for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(), "MAX_SEND=10000000".to_string(),
@ -822,7 +928,7 @@ pub(super) async fn get_app_config(
vec![ vec![
"81:81".to_string(), "81:81".to_string(),
"8084:80".to_string(), "8084:80".to_string(),
"8443:443".to_string(), "8444:443".to_string(),
], ],
vec![ vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(), "/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
@ -1049,31 +1155,11 @@ pub(super) async fn get_app_config(
None, None,
), ),
_ => { _ => {
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json // No catalog runtime metadata found; use minimal defaults
// This allows dynamic apps from the remote catalog to be installed // (container's own EXPOSE/VOLUME). New generic apps should declare
// without hardcoding their config here. // containerConfig in the registry catalog instead of adding Rust cases.
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id); tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", 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);
(vec![], vec![], vec![], None, None) (vec![], vec![], vec![], None, None)
}, }
} }
} }

View File

@ -9,6 +9,7 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
/// Names of container variants that represent a running Electrum indexer /// Names of container variants that represent a running Electrum indexer
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"]; const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
fn requires_unpruned_bitcoin(package_id: &str) -> bool { fn requires_unpruned_bitcoin(package_id: &str) -> bool {
matches!( 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. /// Snapshot of which dependency services are currently running.
pub(super) struct RunningDeps { pub(super) struct RunningDeps {
pub has_bitcoin: bool, 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)) .timeout(std::time::Duration::from_secs(10))
.build() .build()
.context("building Bitcoin RPC client")?; .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 mut last_error = None;
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC response")?; for _ in 0..3 {
if !status.is_success() { match client
anyhow::bail!( .post(crate::constants::BITCOIN_RPC_URL)
"Bitcoin RPC returned {} while checking pruning status", .basic_auth(&rpc_user, Some(&rpc_pass))
status .header("Content-Type", "application/json")
); .json(&body)
} .send()
if let Some(error) = json.get("error").filter(|e| !e.is_null()) { .await
anyhow::bail!("Bitcoin RPC error while checking pruning status: {}", error); {
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 { let Some(result) = json.get("result") else {
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status"); 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()) .and_then(|v| v.as_bool())
.unwrap_or(false) .unwrap_or(false)
{ {
anyhow::bail!( anyhow::bail!(archival_bitcoin_required_message(package_id));
"{} 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
);
} }
Ok(()) 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. /// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) { pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd { 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. /// 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>> { pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
let containers = get_containers_for_app(package_id).await?; 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); 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() { let effective_order: &[&str] = if order.is_empty() {
startup_order("mempool") startup_order("mempool")
} else { } else {
order order
}; };
let mut sorted = containers;
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99)); sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
Ok(sorted) Ok(sorted)
} }
@ -324,7 +379,15 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() { fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {

View File

@ -14,6 +14,7 @@ use crate::data_model::InstallPhase;
use crate::update::host_sudo; use crate::update::host_sudo;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log"; const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log";
@ -221,6 +222,12 @@ impl RpcHandler {
self.set_install_phase(package_id, InstallPhase::Preparing) self.set_install_phase(package_id, InstallPhase::Preparing)
.await; .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 // Multi-container stacks get their own install path
if package_id == "immich" { if package_id == "immich" {
return self.install_immich_stack().await; return self.install_immich_stack().await;
@ -239,15 +246,7 @@ impl RpcHandler {
// congested Podman API does not turn an already-running dependency into // congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe // a false install failure. Fall back to a bounded direct Podman probe
// only when the cache does not show the dependency. // only when the cache does not show the dependency.
let deps = { let deps = self.running_deps_for_install(package_id).await?;
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?
}
};
check_install_deps(package_id, &deps)?; check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps); log_optional_dep_info(package_id, &deps);
@ -362,31 +361,65 @@ impl RpcHandler {
if !start_output.status.success() { if !start_output.status.success() {
let stderr = String::from_utf8_lossy(&start_output.stderr); let stderr = String::from_utf8_lossy(&start_output.stderr);
install_log(&format!( install_log(&format!(
"INSTALL ADOPT FAIL: {} — start failed: {}", "INSTALL ADOPT RECREATE: {} — start failed, removing wedged container: {}",
package_id, stderr package_id, stderr
)) ))
.await; .await;
return Err(anyhow::anyhow!( let _ = tokio::process::Command::new("podman")
"Container {} exists but failed to start: {}", .args(["rm", "-f", package_id])
package_id, .output()
stderr .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); let network_alias_flag = format!("--network-alias={}", container_name);
// Network mode // 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 // These standalone web UIs have repeatedly lost host listeners
// under Podman's rootless pasta backend while staying healthy internally. // under Podman's rootless pasta backend while staying healthy internally.
// Use slirp4netns/rootlessport for this standalone web UI. // Use slirp4netns/rootlessport for this standalone web UI.
@ -599,6 +645,10 @@ impl RpcHandler {
self.write_lnd_conf(&rpc_user, &rpc_pass).await?; 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) // Pre-install: SearXNG settings.yml (required or container exits immediately)
if package_id == "searxng" { if package_id == "searxng" {
let searx_dir = "/var/lib/archipelago/searxng"; let searx_dir = "/var/lib/archipelago/searxng";
@ -705,6 +755,10 @@ impl RpcHandler {
if !run_output.status.success() { if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string(); let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
if cleanup_start_conflict(package_id, &stderr).await { 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")?; run_output = cmd.output().await.context("Failed to rerun container")?;
} }
} }
@ -750,11 +804,14 @@ impl RpcHandler {
.await; .await;
} }
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman") let status = timeout(
.args(["inspect", container_name, "--format", "{{.State.Status}}"]) Duration::from_secs(10),
.output() tokio::process::Command::new("podman")
.await; .args(["inspect", container_name, "--format", "{{.State.Status}}"])
if let Ok(o) = status { .output(),
)
.await;
if let Ok(Ok(o)) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string(); let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "running" { if state == "running" {
container_running = true; container_running = true;
@ -787,6 +844,8 @@ impl RpcHandler {
log_output.chars().take(500).collect::<String>() log_output.chars().take(500).collect::<String>()
)); ));
} }
} else {
warn!("podman inspect {} timed out during install", container_name);
} }
if i == 11 { if i == 11 {
warn!( 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 -- // -- Private helpers for install --
/// Pull the image from a registry or verify a local image exists. /// 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) { async fn cleanup_stale_package_ports(package_id: &str) {
match package_id { match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await, "grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await, "searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await, "uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => { "gitea" => {
@ -1795,11 +1865,21 @@ async fn cleanup_stale_package_ports(package_id: &str) {
cleanup_stale_pasta_port("2222").await; cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").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 { 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 { match package_id {
"grafana" "grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") => 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; cleanup_stale_pasta_port("3000").await;
true true
} }
"homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng" "searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") => 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; cleanup_stale_pasta_port("3000").await;
true 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, _ => false,
} }
} }
@ -1839,6 +1939,11 @@ async fn cleanup_stale_pasta_port(port: &str) {
.output() .output()
.await; .await;
let _ = tokio::process::Command::new("sudo")
.args(["fuser", "-k", &format!("{}/tcp", port)])
.output()
.await;
let pattern = format!("pasta.*{}", port); let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill") let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern]) .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> { fn required_host_port(package_id: &str) -> Option<u16> {
match package_id { match package_id {
"grafana" => Some(3000), "grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888), "searxng" => Some(8888),
"uptime-kuma" => Some(3002), "uptime-kuma" => Some(3002),
"gitea" => Some(3001), "gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
_ => None, _ => None,
} }
} }

View File

@ -5,9 +5,14 @@ use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState; use crate::data_model::PackageState;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::process::Output;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tracing::warn; 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. /// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state, /// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit. /// indexers 300s for index flush, databases 120s for WAL/transaction commit.
@ -292,10 +297,7 @@ impl RpcHandler {
) )
.await; .await;
tracing::info!("Uninstall {}: stopping container {}", package_id, name); tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman") let stop_out = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
match stop_out { match stop_out {
Ok(o) if o.status.success() => stopped += 1, Ok(o) if o.status.success() => stopped += 1,
Ok(o) => { Ok(o) => {
@ -314,10 +316,7 @@ impl RpcHandler {
// Remove container (without -f to respect graceful shutdown above) // Remove container (without -f to respect graceful shutdown above)
tracing::info!("Uninstall {}: removing container {}", package_id, name); tracing::info!("Uninstall {}: removing container {}", package_id, name);
let rm_out = tokio::process::Command::new("podman") let rm_out = podman_control(&["rm", name]).await;
.args(["rm", name])
.output()
.await;
match rm_out { match rm_out {
Ok(o) if o.status.success() => removed += 1, Ok(o) if o.status.success() => removed += 1,
Ok(o) => { Ok(o) => {
@ -329,10 +328,7 @@ impl RpcHandler {
name, name,
stderr.trim() stderr.trim()
); );
let force_rm = tokio::process::Command::new("podman") let force_rm = podman_control(&["rm", "-f", name]).await;
.args(["rm", "-f", name])
.output()
.await;
match force_rm { match force_rm {
Ok(o2) if o2.status.success() => removed += 1, Ok(o2) if o2.status.success() => removed += 1,
_ => { _ => {
@ -353,10 +349,7 @@ impl RpcHandler {
self.set_uninstall_stage(package_id, "Cleaning up volumes") self.set_uninstall_stage(package_id, "Cleaning up volumes")
.await; .await;
// Clean up dangling volumes associated with removed containers // Clean up dangling volumes associated with removed containers
let _ = tokio::process::Command::new("podman") let _ = podman_control(&["volume", "prune", "-f"]).await;
.args(["volume", "prune", "-f"])
.output()
.await;
// Clean up app-specific networks (only if no other containers use them) // Clean up app-specific networks (only if no other containers use them)
let app_networks: Vec<&str> = match package_id { let app_networks: Vec<&str> = match package_id {
@ -366,10 +359,7 @@ impl RpcHandler {
_ => vec![], _ => vec![],
}; };
for net in &app_networks { for net in &app_networks {
let _ = tokio::process::Command::new("podman") let _ = podman_control(&["network", "rm", net]).await;
.args(["network", "rm", net])
.output()
.await;
} }
// Release port allocation // Release port allocation
@ -485,18 +475,16 @@ impl RpcHandler {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?; .ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
let check_output = tokio::process::Command::new("podman") let check_output = podman_control(&[
.args([ "ps",
"ps", "-a",
"-a", "--format",
"--format", "{{.Names}}",
"{{.Names}}", "--filter",
"--filter", &format!("name={}", app_id),
&format!("name={}", app_id), ])
]) .await
.output() .context("Failed to check container")?;
.await
.context("Failed to check container")?;
let existing = String::from_utf8_lossy(&check_output.stdout); let existing = String::from_utf8_lossy(&check_output.stdout);
@ -541,16 +529,14 @@ impl RpcHandler {
cmd.arg(image); 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() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to create container: {}", stderr)); return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
} }
} else { } else {
let output = tokio::process::Command::new("podman") let output = podman_control(&["start", app_id])
.args(["start", app_id])
.output()
.await .await
.context("Failed to start container")?; .context("Failed to start container")?;
@ -575,9 +561,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?; validate_app_id(app_id)?;
let output = tokio::process::Command::new("podman") let output = podman_control(&["stop", "-t", stop_timeout_secs(app_id), app_id])
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
.output()
.await .await
.context("Failed to stop container")?; .context("Failed to stop container")?;
@ -611,9 +595,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
} }
repair_before_package_start(name).await; repair_before_package_start(name).await;
tracing::info!("Starting container: {}", name); tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman") let out = podman_control(&["start", name])
.args(["start", name])
.output()
.await .await
.context(format!("Failed to exec podman start {}", name))?; .context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() { if !out.status.success() {
@ -632,17 +614,12 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
// container exits immediately after). // container exits immediately after).
tokio::time::sleep(std::time::Duration::from_secs(3)).await; tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for name in to_start { for name in to_start {
let status = tokio::process::Command::new("podman") let status = podman_control(&["inspect", name, "--format", "{{.State.Status}}"]).await;
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status { if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string(); let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" { if state == "exited" {
let logs = tokio::process::Command::new("podman") let logs =
.args(["logs", "--tail", "5", name]) podman_with_timeout(&["logs", "--tail", "5", name], PODMAN_LOG_TIMEOUT).await;
.output()
.await;
let log_text = logs let log_text = logs
.map(|o| { .map(|o| {
let combined = format!( 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( async fn do_orchestrator_package_stop(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator, orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
containers: &[String], containers: &[String],
@ -757,9 +767,7 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
name, name,
stop_timeout_secs(name) stop_timeout_secs(name)
); );
let out = tokio::process::Command::new("podman") let out = podman_control(&["stop", "-t", stop_timeout_secs(name), name])
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await .await
.context(format!("Failed to exec podman stop {}", name))?; .context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() { if !out.status.success() {
@ -785,9 +793,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
for name in containers { for name in containers {
tracing::info!("Restarting container: {}", name); tracing::info!("Restarting container: {}", name);
repair_before_package_start(name).await; repair_before_package_start(name).await;
let out = tokio::process::Command::new("podman") let out = podman_control(&["restart", "-t", stop_timeout_secs(name), name])
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
.await .await
.context(format!("Failed to exec podman restart {}", name))?; .context(format!("Failed to exec podman restart {}", name))?;
@ -803,13 +809,8 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
stderr stderr
); );
// Fallback: stop then start // Fallback: stop then start
let _ = tokio::process::Command::new("podman") let _ = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
.args(["stop", "-t", stop_timeout_secs(name), name]) let start_out = podman_control(&["start", name])
.output()
.await;
let start_out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await .await
.context(format!("Failed to exec podman start {}", name))?; .context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() { if !start_out.status.success() {
@ -852,6 +853,12 @@ async fn repair_before_package_start(container_name: &str) {
repair_grafana_dirs().await; repair_grafana_dirs().await;
cleanup_stale_pasta_port("3000").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, "gitea" => cleanup_gitea_stale_ports().await,
_ => {} _ => {}
} }
@ -871,9 +878,7 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
container_name, port container_name, port
)) ))
.await; .await;
let output = tokio::process::Command::new("podman") let output = podman_control(&["restart", container_name])
.args(["restart", container_name])
.output()
.await .await
.context("failed to restart container after missing host port")?; .context("failed to restart container after missing host port")?;
if !output.status.success() { 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> { fn runtime_required_host_port(container_name: &str) -> Option<u16> {
match container_name { match container_name {
"grafana" => Some(3000), "grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888), "searxng" => Some(8888),
"uptime-kuma" => Some(3002), "uptime-kuma" => Some(3002),
"vaultwarden" => Some(8082),
"gitea" => Some(3001), "gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
_ => None, _ => None,
} }
} }
@ -957,16 +966,14 @@ async fn repair_grafana_dirs() {
.args(["mkdir", "-p", "/var/lib/archipelago/grafana"]) .args(["mkdir", "-p", "/var/lib/archipelago/grafana"])
.output() .output()
.await; .await;
let podman_chown = tokio::process::Command::new("podman") let podman_chown = podman_control(&[
.args([ "unshare",
"unshare", "chown",
"chown", "-R",
"-R", "472:472",
"472:472", "/var/lib/archipelago/grafana",
"/var/lib/archipelago/grafana", ])
]) .await;
.output()
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) { if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo") let _ = tokio::process::Command::new("sudo")
.args([ .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() { async fn repair_btcpay_database_password() {
let Ok(db_pass) = let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
@ -991,39 +1024,32 @@ async fn repair_btcpay_database_password() {
return; return;
} }
let _ = tokio::process::Command::new("podman") let _ = podman_control(&["start", "archy-btcpay-db"]).await;
.args(["start", "archy-btcpay-db"])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let escaped = db_pass.replace('\'', "''"); let escaped = db_pass.replace('\'', "''");
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped); let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
let _ = tokio::process::Command::new("podman") let _ = podman_control(&[
.args([ "exec",
"exec", "archy-btcpay-db",
"archy-btcpay-db", "psql",
"psql", "-U",
"-U", "btcpay",
"btcpay", "-d",
"-d", "btcpay",
"btcpay", "-c",
"-c", &sql,
&sql, ])
]) .await;
.output() let _ = podman_control(&[
.await; "exec",
let _ = tokio::process::Command::new("podman") "archy-btcpay-db",
.args([ "createdb",
"exec", "-U",
"archy-btcpay-db", "btcpay",
"createdb", "nbxplorer",
"-U", ])
"btcpay", .await;
"nbxplorer",
])
.output()
.await;
} }
async fn repair_indeedhub_network() { async fn repair_indeedhub_network() {
@ -1040,11 +1066,18 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return; return;
} }
if container_name != "grafana" { match container_name {
return; "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) { async fn cleanup_stale_pasta_port(port: &str) {

View File

@ -775,6 +775,8 @@ impl RpcHandler {
/// Install Mempool stack (mariadb + mempool-api + mempool-web). /// Install Mempool stack (mariadb + mempool-api + mempool-web).
pub(super) async fn install_mempool_stack(&self) -> Result<serde_json::Value> { 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( if let Some(adopted) = adopt_stack_if_exists(
"mempool", "mempool",
"mempool", "mempool",

View File

@ -252,7 +252,8 @@ impl DevContainerOrchestrator {
match status.state { match status.state {
archipelago_container::ContainerState::Running => Ok("healthy".to_string()), archipelago_container::ContainerState::Running => Ok("healthy".to_string()),
archipelago_container::ContainerState::Stopped 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::Created => Ok("starting".to_string()),
archipelago_container::ContainerState::Paused => Ok("paused".to_string()), archipelago_container::ContainerState::Paused => Ok("paused".to_string()),
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()), archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),

View File

@ -136,11 +136,13 @@ impl DockerPackageScanner {
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) { 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) // Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address); debug!("Using UI container for {}: {}", app_id, ui_address);
Some(ui_address.clone()) reachable_lan_address(&app_id, Some(ui_address.clone())).await
} else { } else {
// Dynamic: use actual port bindings from container, fall back to static map // Prefer the known web UI port over arbitrary first binding
extract_lan_address(&container.ports) // (for example Gitea exposes SSH on 2222 before web on 3001).
.or_else(|| PodmanClient::lan_address_for(&app_id)) let candidate = PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports));
reachable_lan_address(&app_id, candidate).await
}; };
debug!( debug!(
@ -156,21 +158,8 @@ impl DockerPackageScanner {
// Extract actual version from container image tag // Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image); 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 = let available_update =
image_versions::pinned_image_for_app(&app_id).and_then(|pinned| { image_versions::available_update_for_app(&app_id, &container.image);
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
}
});
let package = PackageDataEntry { let package = PackageDataEntry {
state: package_state.clone(), state: package_state.clone(),
@ -631,6 +620,51 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None 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> { fn companion_lan_address(app_id: &str) -> Option<String> {
match app_id { match app_id {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()), "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) { fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state { match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running), ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
ContainerState::Stopping => (PackageState::Stopping, ServiceStatus::Stopped),
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped), ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped), ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),

View File

@ -205,6 +205,30 @@ pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
images.get(var).cloned() 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. /// Extract version tag from a full image reference.
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta" /// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty. /// 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() "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. /// Container names and their image variable names for multi-container stacks.
/// Returns empty vec for single-container apps. /// Returns empty vec for single-container apps.
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> { 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] #[test]
fn test_parse_image_versions() { fn test_parse_image_versions() {
let content = r#" let content = r#"

View File

@ -10,6 +10,7 @@ use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd"; pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf"; 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"; pub const WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)] #[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) let existing = fs::read_to_string(&paths.conf_path)
.await .await
.with_context(|| format!("reading {}", paths.conf_path.display()))?; .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); 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<()> { 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; let mut last_err = None;
for _ in 0..60 { for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman"); 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, client: &reqwest::Client,
path: &str, path: &str,
) -> Result<UnlockerResponse<T>> { ) -> 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; let mut last_err = None;
for _ in 0..60 { for _ in 0..60 {
match client.get(&url).send().await { match client.get(&url).send().await {
@ -244,7 +270,7 @@ async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
path: &str, path: &str,
body: serde_json::Value, body: serde_json::Value,
) -> Result<UnlockerResponse<T>> { ) -> 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; let mut last_err = None;
for _ in 0..60 { for _ in 0..60 {
match client.post(&url).json(&body).send().await { match client.post(&url).json(&body).send().await {
@ -291,7 +317,7 @@ async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
return false; return false;
}; };
client 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)) .header("Grpc-Metadata-macaroon", hex::encode(macaroon))
.send() .send()
.await .await
@ -344,12 +370,14 @@ fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''") 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.active=true",
"bitcoin.mainnet=true", "bitcoin.mainnet=true",
"bitcoin.node=bitcoind", "bitcoin.node=bitcoind",
"bitcoind.rpchost=bitcoin-knots:8332", "bitcoind.rpchost=bitcoin-knots:8332",
rpc_pass_line.as_str(),
] ]
.iter() .iter()
.all(|needle| conf.lines().any(|line| line.trim() == *needle)) .all(|needle| conf.lines().any(|line| line.trim() == *needle))
@ -378,7 +406,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn ensure_config_is_idempotent() { async fn ensure_config_repairs_rpc_password_drift() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths { let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"), data_dir: tmp.path().join("lnd"),
@ -391,10 +419,10 @@ mod tests {
); );
assert_eq!( assert_eq!(
ensure_config(&paths, "second").await.unwrap(), ensure_config(&paths, "second").await.unwrap(),
EnsureOutcome::Unchanged EnsureOutcome::Written
); );
let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); 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] #[tokio::test]

View File

@ -47,6 +47,27 @@ use crate::update::host_sudo;
/// Keep in sync with the running fixture on .116. Centralized as a constant /// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable. /// so the rule is visible in one place and unit-testable.
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"]; 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. /// 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() { async fn patch_indeedhub_nostr_provider() {
let _ = tokio::process::Command::new("podman") let _ = tokio::process::Command::new("podman")
.args([ .args([
@ -316,6 +351,8 @@ pub struct ProdContainerOrchestrator {
/// false so the legacy path remains the production path until the /// false so the legacy path remains the production path until the
/// 20× lifecycle harness goes green against the new path. /// 20× lifecycle harness goes green against the new path.
use_quadlet_backends: bool, use_quadlet_backends: bool,
#[cfg(test)]
test_disk_gb: Option<u64>,
} }
struct FileSecretsProvider { struct FileSecretsProvider {
@ -363,6 +400,8 @@ impl ProdContainerOrchestrator {
lnd_paths: lnd::EnsurePaths::default(), lnd_paths: lnd::EnsurePaths::default(),
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: config.use_quadlet_backends, use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
}) })
} }
@ -380,6 +419,7 @@ impl ProdContainerOrchestrator {
lnd_paths: lnd::EnsurePaths::default(), lnd_paths: lnd::EnsurePaths::default(),
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: false, use_quadlet_backends: false,
test_disk_gb: None,
} }
} }
@ -411,6 +451,11 @@ impl ProdContainerOrchestrator {
self.lnd_paths = paths; 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, /// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each,
/// validates it, and stores it in the in-memory state. /// validates it, and stores it in the in-memory state.
/// ///
@ -587,8 +632,19 @@ impl ProdContainerOrchestrator {
.collect() .collect()
}; };
let mut report = ReconcileReport::default(); let mut report = ReconcileReport::default();
let disk_gb = self.disk_gb();
for lm in manifests { for lm in manifests {
let app_id = lm.manifest.app.id.clone(); 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 { match self.ensure_running_with_mode(&lm, mode).await {
Ok(action) => report.record(&app_id, action), Ok(action) => report.record(&app_id, action),
Err(e) => { Err(e) => {
@ -691,8 +747,20 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Installed); return Ok(ReconcileAction::Installed);
} }
self.run_post_start_hooks(&app_id).await?; self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(ReconcileAction::Started) 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 => { ContainerState::Created => {
self.prepare_for_start(&resolved_manifest).await?; self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await { if self.container_env_drifted(&name, &resolved_manifest).await {
@ -714,6 +782,7 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Installed); return Ok(ReconcileAction::Installed);
} }
self.run_post_start_hooks(&app_id).await?; self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(ReconcileAction::Started) Ok(ReconcileAction::Started)
} }
ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())), ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())),
@ -721,7 +790,36 @@ impl ProdContainerOrchestrator {
} }
} }
Err(_) => { 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 { if mode == ReconcileMode::ExistingOnly {
return Ok(ReconcileAction::Left("absent".to_string())); return Ok(ReconcileAction::Left("absent".to_string()));
} }
@ -806,6 +904,7 @@ impl ProdContainerOrchestrator {
.with_context(|| format!("start_container {name}"))?; .with_context(|| format!("start_container {name}"))?;
} }
self.run_post_start_hooks(&lm.manifest.app.id).await?; self.run_post_start_hooks(&lm.manifest.app.id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(()) Ok(())
} }
@ -942,6 +1041,16 @@ impl ProdContainerOrchestrator {
Ok(Some(ReconcileAction::Installed)) 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 /// Drift-sync an existing Quadlet unit file's bytes against what the
/// current renderer produces. No-op when the flag is off, when 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 /// 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) { if !tokio::fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(()); 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(); let mut resolved = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved)?; self.resolve_dynamic_env(&mut resolved)?;
let unit = quadlet::QuadletUnit::from_manifest(&resolved, name); 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) let changed = quadlet::write_if_changed(&unit, &unit_dir)
.await .await
.with_context(|| format!("drift-sync quadlet unit for {name}"))?; .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)" "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(()) Ok(())
} }
@ -1283,7 +1433,10 @@ impl ProdContainerOrchestrator {
let mut started = false; let mut started = false;
match frontend_status.state { match frontend_status.state {
ContainerState::Running => {} ContainerState::Running => {}
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created => { ContainerState::Stopped
| ContainerState::Exited
| ContainerState::Created
| ContainerState::Stopping => {
self.runtime self.runtime
.start_container("indeedhub") .start_container("indeedhub")
.await .await
@ -1366,7 +1519,7 @@ impl ProdContainerOrchestrator {
fn detect_host_facts(&self) -> HostFacts { fn detect_host_facts(&self) -> HostFacts {
let host_ip = Self::detect_host_ip().unwrap_or_else(|| "127.0.0.1".to_string()); let host_ip = Self::detect_host_ip().unwrap_or_else(|| "127.0.0.1".to_string());
let host_mdns = Self::detect_host_mdns(); let host_mdns = Self::detect_host_mdns();
let disk_gb = Self::detect_disk_gb(); let disk_gb = self.disk_gb();
HostFacts { HostFacts {
host_ip, host_ip,
host_mdns, host_mdns,
@ -1429,6 +1582,14 @@ impl ProdContainerOrchestrator {
kb / 1_000_000 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<()> { fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts(); let facts = self.detect_host_facts();
let mut env = manifest.app.environment.clone(); let mut env = manifest.app.environment.clone();
@ -1555,13 +1716,17 @@ impl ProdContainerOrchestrator {
.flatten() .flatten()
.unwrap_or_default(); .unwrap_or_default();
let expected_entry = manifest if let Some(expected_entry) = &manifest.app.container.entrypoint {
.app if current_entry != *expected_entry {
.container return true;
.entrypoint }
.clone() }
.unwrap_or_default(); if !manifest.app.container.custom_args.is_empty()
current_entry != expected_entry || current_cmd != manifest.app.container.custom_args && current_cmd != manifest.app.container.custom_args
{
return true;
}
false
} }
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> { async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
@ -1691,10 +1856,20 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let action = self.ensure_running(&lm).await?; let action = self.ensure_running(&lm).await?;
match action { match action {
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => Ok(()), ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => Ok(()),
ReconcileAction::Left(state) => Err(anyhow::anyhow!( ReconcileAction::Left(state) => {
"container {} left in {state}", let name = compute_container_name(&lm.manifest);
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 lock = self.app_lock(app_id).await;
let _guard = lock.lock().await; let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest); 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 self.runtime
.stop_container(&name) .stop_container(&name)
.await .await
@ -1718,6 +1899,24 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await; let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await; let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest); 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. // Best-effort stop (ignored if already stopped), then start.
let _ = self.runtime.stop_container(&name).await; let _ = self.runtime.stop_container(&name).await;
self.prepare_for_start(&lm.manifest).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> { async fn health(&self, app_id: &str) -> Result<String> {
let status = <Self as ContainerOrchestrator>::status(self, app_id).await?; let status = <Self as ContainerOrchestrator>::status(self, app_id).await?;
Ok(match status.state { Ok(match status.state {
ContainerState::Running => "healthy".to_string(), ContainerState::Running => status.health.unwrap_or_else(|| "healthy".to_string()),
ContainerState::Stopped | ContainerState::Exited => "unhealthy".to_string(), ContainerState::Stopped | ContainerState::Exited | ContainerState::Stopping => {
"unhealthy".to_string()
}
ContainerState::Created => "starting".to_string(), ContainerState::Created => "starting".to_string(),
ContainerState::Paused => "paused".to_string(), ContainerState::Paused => "paused".to_string(),
ContainerState::Unknown(s) => format!("unknown:{s}"), ContainerState::Unknown(s) => format!("unknown:{s}"),
@ -1846,6 +2047,8 @@ mod tests {
calls: StdMutex<Vec<String>>, calls: StdMutex<Vec<String>>,
/// container_name -> ContainerState. Absence = "doesn't exist". /// container_name -> ContainerState. Absence = "doesn't exist".
containers: StdMutex<HashMap<String, ContainerState>>, containers: StdMutex<HashMap<String, ContainerState>>,
/// container_name -> Podman health status.
health: StdMutex<HashMap<String, String>>,
/// image_ref -> present. Absence = "not present in local storage". /// image_ref -> present. Absence = "not present in local storage".
images: StdMutex<HashMap<String, bool>>, images: StdMutex<HashMap<String, bool>>,
/// container_name -> env that create_container received. /// container_name -> env that create_container received.
@ -1869,6 +2072,12 @@ mod tests {
.unwrap() .unwrap()
.insert(name.to_string(), state); .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) { fn mark_image_present(&self, tag: &str) {
self.images.lock().unwrap().insert(tag.to_string(), true); self.images.lock().unwrap().insert(tag.to_string(), true);
} }
@ -1929,11 +2138,12 @@ mod tests {
.get(name) .get(name)
.cloned() .cloned()
.ok_or_else(|| anyhow::anyhow!("not found: {name}"))?; .ok_or_else(|| anyhow::anyhow!("not found: {name}"))?;
let health = self.health.lock().unwrap().get(name).cloned();
Ok(ContainerStatus { Ok(ContainerStatus {
id: format!("id-{name}"), id: format!("id-{name}"),
name: name.to_string(), name: name.to_string(),
state, state,
health: None, health,
exit_code: None, exit_code: None,
started_at: None, started_at: None,
image: "test-image".to_string(), image: "test-image".to_string(),
@ -2436,6 +2646,82 @@ app:
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots")); 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] #[tokio::test]
async fn reconcile_collects_per_app_failures_without_short_circuiting() { async fn reconcile_collects_per_app_failures_without_short_circuiting() {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
@ -2611,6 +2897,32 @@ app:
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots")); 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] #[tokio::test]
async fn health_maps_states_to_strings() { async fn health_maps_states_to_strings() {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
@ -2620,6 +2932,9 @@ app:
.await; .await;
assert_eq!(orch.health("lnd").await.unwrap(), "healthy"); 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); rt.set_state("lnd", ContainerState::Exited);
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy"); assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
@ -2628,6 +2943,9 @@ app:
rt.set_state("lnd", ContainerState::Created); rt.set_state("lnd", ContainerState::Created);
assert_eq!(orch.health("lnd").await.unwrap(), "starting"); assert_eq!(orch.health("lnd").await.unwrap(), "starting");
rt.set_state("lnd", ContainerState::Stopping);
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
} }
#[tokio::test] #[tokio::test]

View File

@ -52,6 +52,10 @@ pub struct BindMount {
#[allow(dead_code)] // Bridge is reserved for Phase 5 per-app network isolation. #[allow(dead_code)] // Bridge is reserved for Phase 5 per-app network isolation.
pub enum NetworkMode { pub enum NetworkMode {
#[default] #[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, Host,
/// A user-defined podman network — quadlet creates the container /// A user-defined podman network — quadlet creates the container
/// attached to it. The network must already exist (orchestrator's /// 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`. /// Container healthcheck wired through to Podman.
/// When set, `systemctl start <name>.service` blocks until the container's /// Systemd should consider the unit started once the container process is
/// own healthcheck reports green — eliminating the "container up but RPC /// running; health probes are app status, not boot ordering. Blocking
/// not ready" race that the orchestrator currently papers over with /// `systemctl start` on health made boot reconciliation hang when an image
/// post-start polling. /// lacked the probe helper binary, even though the service itself was live.
/// ///
/// Ranges roughly mirror the manifest's HealthCheck struct: `cmd` is the /// Ranges roughly mirror the manifest's HealthCheck struct: `cmd` is the
/// shell form (`/usr/bin/curl -fsS http://localhost:8332/health` etc.), /// 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 extra_podman_args: Vec<String>,
pub depends_on: Vec<String>, pub depends_on: Vec<String>,
/// Phase 3.4: when present the rendered unit emits HealthCmd=, /// Phase 3.4: when present the rendered unit emits HealthCmd=,
/// HealthInterval=, HealthTimeout=, HealthRetries=, AND Notify=healthy /// HealthInterval=, HealthTimeout=, and HealthRetries= for Podman's
/// so systemctl start blocks on a green health probe. /// health state without blocking systemd's start job.
pub health: Option<HealthSpec>, pub health: Option<HealthSpec>,
// Backend-manifest extensions (Phase 3.1). Companion units leave // Backend-manifest extensions (Phase 3.1). Companion units leave
// these defaulted; the renderer skips empty/false directives so a // these defaulted; the renderer skips empty/false directives so a
@ -130,6 +134,7 @@ pub struct QuadletUnit {
pub environment: Vec<String>, pub environment: Vec<String>,
pub devices: Vec<String>, pub devices: Vec<String>,
pub add_hosts: Vec<(String, String)>, pub add_hosts: Vec<(String, String)>,
pub network_aliases: Vec<String>,
pub entrypoint: Option<Vec<String>>, pub entrypoint: Option<Vec<String>>,
pub command: Vec<String>, pub command: Vec<String>,
pub read_only_root: bool, pub read_only_root: bool,
@ -172,11 +177,16 @@ impl QuadletUnit {
// must surface as a unit start failure, not a silent retry storm. // must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never"); let _ = writeln!(s, "Pull=never");
match &self.network { match &self.network {
NetworkMode::Default => {}
NetworkMode::Host => { NetworkMode::Host => {
let _ = writeln!(s, "Network=host"); let _ = writeln!(s, "Network=host");
} }
NetworkMode::Bridge(net) => { NetworkMode::Bridge(net) => {
let _ = writeln!(s, "Network={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 { if let Some(user) = &self.user {
@ -234,11 +244,6 @@ impl QuadletUnit {
let _ = writeln!(s, "HealthInterval={}", h.interval); let _ = writeln!(s, "HealthInterval={}", h.interval);
let _ = writeln!(s, "HealthTimeout={}", h.timeout); let _ = writeln!(s, "HealthTimeout={}", h.timeout);
let _ = writeln!(s, "HealthRetries={}", h.retries); 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 { if let Some(ep) = &self.entrypoint {
// Quadlet's Exec= replaces the image entrypoint+cmd. When // Quadlet's Exec= replaces the image entrypoint+cmd. When
@ -261,20 +266,6 @@ impl QuadletUnit {
// OnFailure (clean stops stay stopped). // OnFailure (clean stops stay stopped).
let _ = writeln!(s, "Restart={}", self.restart_policy.as_systemd()); let _ = writeln!(s, "Restart={}", self.restart_policy.as_systemd());
let _ = writeln!(s, "RestartSec=10"); 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);
let _ = writeln!(s, "[Install]"); let _ = writeln!(s, "[Install]");
let _ = writeln!(s, "WantedBy=default.target"); let _ = writeln!(s, "WantedBy=default.target");
@ -290,11 +281,15 @@ fn shell_join(parts: &[String]) -> String {
parts parts
.iter() .iter()
.map(|p| { .map(|p| {
let p = p.replace(['\r', '\n'], " ");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) { 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}\"") format!("\"{escaped}\"")
} else { } else {
p.clone() p
} }
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -323,7 +318,7 @@ impl QuadletUnit {
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()), other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
_ => match app.container.network.as_deref() { _ => match app.container.network.as_deref() {
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()), 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(), environment: app.environment.clone(),
devices: app.devices.clone(), devices: app.devices.clone(),
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())], add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
network_aliases: vec![name.to_string()],
entrypoint: app.container.entrypoint.clone(), entrypoint: app.container.entrypoint.clone(),
command: app.container.custom_args.clone(), command: app.container.custom_args.clone(),
read_only_root: app.security.readonly_root, read_only_root: app.security.readonly_root,
@ -378,12 +374,11 @@ impl QuadletUnit {
/// Translate the manifest's HealthCheck shape into a HealthSpec the /// Translate the manifest's HealthCheck shape into a HealthSpec the
/// renderer understands. Returns None when the manifest's health spec /// renderer understands. Returns None when the manifest's health spec
/// is malformed or unsupported — we'd rather skip Notify=healthy than /// is malformed or unsupported rather than emitting a broken HealthCmd.
/// emit a broken HealthCmd that fails the unit start forever.
/// ///
/// Supported shapes: /// Supported shapes:
/// - type: tcp, endpoint: "host:port" → `nc -z host port` /// - type: tcp, endpoint: "host:port" → skipped for Quadlet units
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → curl /// - type: http, endpoint: "host:port" or "http(s)://host:port", path → wget/curl
/// - type: cmd, endpoint: "<shell command>" → `<shell command>` verbatim /// - type: cmd, endpoint: "<shell command>" → `<shell command>` verbatim
/// ///
/// For type=http we accept the endpoint with or without scheme; manifests /// 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. /// that pasted on .228 2026-05-02 and failed every probe.
fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<HealthSpec> { fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<HealthSpec> {
let cmd = match hc.check_type.as_str() { let cmd = match hc.check_type.as_str() {
"tcp" => { // A generic TCP probe inside arbitrary app images is not reliable:
let endpoint = hc.endpoint.as_deref()?; // some images lack nc, some lack bash /dev/tcp, and failures leave
let (host, port) = endpoint.rsplit_once(':')?; // Podman/systemd health in a false-negative state. Keep TCP readiness
// nc is in busybox/coreutils on every base image we ship. // checks in the host-side lifecycle/status layer instead.
// The -z flag does a "scan" that exits 0 on connect, 1 otherwise. "tcp" => return None,
format!("nc -z {host} {port}")
}
"http" => { "http" => {
let endpoint = hc.endpoint.as_deref()?.trim(); let endpoint = hc.endpoint.as_deref()?.trim();
// Accept either bare host:port or a full URL. If endpoint // 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("/"); let path = hc.path.as_deref().unwrap_or("/");
format!("{url}{path}") format!("{url}{path}")
}; };
// -fsS: fail on non-2xx, silent except on error, show errors. // Images vary wildly: SearXNG ships wget but no curl, while some
// -m 5: per-request timeout matches the default manifest timeout. // Node images ship neither. Use whichever probe helper exists and
format!("curl -fsS -m 5 {final_url}") // 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(), "cmd" => hc.endpoint.as_deref()?.to_string(),
_ => return None, _ => return None,
@ -528,6 +526,70 @@ pub async fn enable_now(service: &str) -> Result<()> {
Ok(()) 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: /// Stop + remove a quadlet unit and its on-disk file. Best-effort:
/// errors stop only the destructive write at the failing step so a /// errors stop only the destructive write at the failing step so a
/// partial removal doesn't leave a quadlet file pointing at a service /// partial removal doesn't leave a quadlet file pointing at a service
@ -706,6 +768,14 @@ mod tests {
); );
// Embedded quotes must escape: // Embedded quotes must escape:
assert_eq!(shell_join(&[r#"say "hi""#.into()]), r#""say \"hi\"""#); 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] #[test]
@ -829,6 +899,29 @@ app:
assert_eq!(u.restart_policy, RestartPolicy::OnFailure); 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] #[test]
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() { fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
let yaml = r#" let yaml = r#"
@ -916,28 +1009,25 @@ app:
u.name = "lnd".into(); u.name = "lnd".into();
u.image = "x:1".into(); u.image = "x:1".into();
u.health = Some(HealthSpec { u.health = Some(HealthSpec {
cmd: "nc -z localhost 10009".into(), cmd: "probe-ready".into(),
interval: "30s".into(), interval: "30s".into(),
timeout: "5s".into(), timeout: "5s".into(),
retries: 3, retries: 3,
}); });
let s = u.render(); 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("HealthInterval=30s"));
assert!(s.contains("HealthTimeout=5s")); assert!(s.contains("HealthTimeout=5s"));
assert!(s.contains("HealthRetries=3")); assert!(s.contains("HealthRetries=3"));
assert!(s.contains("Notify=healthy")); assert!(!s.contains("Notify=healthy"));
// Notify=healthy needs a long-enough TimeoutStartSec or systemd assert!(!s.contains("TimeoutStartSec=600"));
// 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}");
} }
#[test] #[test]
fn render_skips_health_directives_when_absent() { fn render_skips_health_directives_when_absent() {
// No health spec → no Notify=healthy, no HealthCmd, no // No health spec → no Notify=healthy, no HealthCmd, no TimeoutStartSec
// TimeoutStartSec override (default 90s applies). Companions rely // override. Companions rely on this so their rendered bytes stay
// on this so their rendered bytes stay unchanged. // unchanged.
let s = sample_unit().render(); let s = sample_unit().render();
assert!(!s.contains("HealthCmd=")); assert!(!s.contains("HealthCmd="));
assert!(!s.contains("Notify=healthy")); assert!(!s.contains("Notify=healthy"));
@ -956,9 +1046,7 @@ app:
timeout: "5s".into(), timeout: "5s".into(),
retries: 3, retries: 3,
}; };
let h = translate_health_check(&tcp).expect("tcp must translate"); assert!(translate_health_check(&tcp).is_none());
assert_eq!(h.cmd, "nc -z localhost 10009");
assert_eq!(h.retries, 3);
let http = HealthCheck { let http = HealthCheck {
check_type: "http".into(), check_type: "http".into(),
@ -969,7 +1057,10 @@ app:
retries: 5, retries: 5,
}; };
let h = translate_health_check(&http).expect("http must translate"); 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 { let cmdck = HealthCheck {
check_type: "cmd".into(), check_type: "cmd".into(),
@ -982,8 +1073,7 @@ app:
let h = translate_health_check(&cmdck).expect("cmd must translate"); let h = translate_health_check(&cmdck).expect("cmd must translate");
assert_eq!(h.cmd, "/usr/local/bin/probe.sh"); assert_eq!(h.cmd, "/usr/local/bin/probe.sh");
// Unknown type → None (renderer skips Notify=healthy entirely // Unknown type → None rather than emit a broken HealthCmd.
// rather than emit a broken HealthCmd that hangs the unit start).
let bad = HealthCheck { let bad = HealthCheck {
check_type: "exec".into(), check_type: "exec".into(),
endpoint: Some("foo".into()), endpoint: Some("foo".into()),
@ -994,7 +1084,7 @@ app:
}; };
assert!(translate_health_check(&bad).is_none()); assert!(translate_health_check(&bad).is_none());
// Malformed tcp endpoint → None (no port separator). // TCP is skipped entirely for Quadlet units.
let badtcp = HealthCheck { let badtcp = HealthCheck {
check_type: "tcp".into(), check_type: "tcp".into(),
endpoint: Some("hostonly".into()), endpoint: Some("hostonly".into()),
@ -1022,7 +1112,7 @@ app:
retries: 3, retries: 3,
}; };
let h = translate_health_check(&with_scheme).expect("with-scheme must translate"); 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); assert!(!h.cmd.contains("http://http://"), "got: {}", h.cmd);
let with_https = HealthCheck { let with_https = HealthCheck {
@ -1035,7 +1125,7 @@ app:
}; };
let h = translate_health_check(&with_https).expect("https must translate"); let h = translate_health_check(&with_https).expect("https must translate");
// Endpoint already has /health → don't append the default "/". // 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] #[test]
@ -1056,12 +1146,46 @@ app:
"#; "#;
let m = AppManifest::parse(yaml).unwrap(); let m = AppManifest::parse(yaml).unwrap();
let u = QuadletUnit::from_manifest(&m, "lnd"); let u = QuadletUnit::from_manifest(&m, "lnd");
let h = u.health.as_ref().expect("health should be populated"); assert!(u.health.is_none());
assert_eq!(h.cmd, "nc -z localhost 10009"); assert!(!u.render().contains("Notify=healthy"));
assert_eq!(h.interval, "15s"); }
assert_eq!(h.timeout, "4s");
assert_eq!(h.retries, 5); #[test]
assert!(u.render().contains("Notify=healthy")); 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] #[test]
@ -1098,6 +1222,8 @@ app:
assert!(body.contains("ContainerName=lnd")); assert!(body.contains("ContainerName=lnd"));
assert!(body.contains("Image=registry/lnd:latest")); assert!(body.contains("Image=registry/lnd:latest"));
assert!(body.contains("Network=archy-net")); 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("PublishPort=10009:10009/tcp"));
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z")); assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
assert!(body.contains("Environment=LND_NETWORK=mainnet")); assert!(body.contains("Environment=LND_NETWORK=mainnet"));

View File

@ -11,8 +11,9 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Output;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant; use std::time::{Duration, Instant};
use tokio::fs; use tokio::fs;
use tracing::{info, warn}; 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. /// Save a snapshot of currently running containers to disk.
/// Called periodically so we know what to restart after a crash. /// Called periodically so we know what to restart after a crash.
pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> { pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
let output = tokio::time::timeout( let mut cmd = tokio::process::Command::new("podman");
std::time::Duration::from_secs(30), cmd.args(["ps", "--format", "json"]);
tokio::process::Command::new("podman") let output = command_with_timeout(cmd, Duration::from_secs(30), "podman ps").await?;
.args(["ps", "--format", "json"])
.output(),
)
.await
.context("podman ps timed out (30s)")?
.context("Failed to run podman ps")?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); 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; tokio::time::sleep(std::time::Duration::from_secs(10)).await;
} }
let result = tokio::time::timeout( let mut cmd = tokio::process::Command::new("podman");
std::time::Duration::from_secs(timeout_secs), cmd.args(["start", &record.name]);
tokio::process::Command::new("podman") let result = command_with_timeout(
.args(["start", &record.name]) cmd,
.output(), Duration::from_secs(timeout_secs),
&format!("podman start {}", record.name),
) )
.await; .await;
match result { match result {
Ok(Ok(output)) if output.status.success() => { Ok(output) if output.status.success() => {
info!("Successfully restarted container: {}", record.name); info!("Successfully restarted container: {}", record.name);
report.recovered += 1; report.recovered += 1;
started = true; started = true;
break; break;
} }
Ok(Ok(output)) => { Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
warn!( warn!(
"Failed to restart container {} (attempt {}): {}", "Failed to restart container {} (attempt {}): {}",
@ -301,20 +297,13 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
stderr.trim() stderr.trim()
); );
} }
Ok(Err(e)) => { Err(e) => {
warn!( warn!(
"Failed to execute podman start for {} (attempt {}): {}", "Failed to start container {} ({}s, attempt {}): {}",
record.name,
attempt + 1,
e
);
}
Err(_) => {
warn!(
"Timeout starting container {} ({}s, attempt {})",
record.name, record.name,
timeout_secs, 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. /// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
/// Skips containers that the user intentionally stopped via the UI. /// Skips containers that the user intentionally stopped via the UI.
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport { pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
let output = match tokio::time::timeout( let mut cmd = tokio::process::Command::new("podman");
std::time::Duration::from_secs(60), cmd.args([
tokio::process::Command::new("podman") "ps",
.args([ "-a",
"ps", "--filter",
"-a", "status=exited",
"--filter", "--filter",
"status=exited", "status=created",
"--filter", "--format",
"status=created", "{{.Names}}",
"--format", ]);
"{{.Names}}", let output = match command_with_timeout(cmd, Duration::from_secs(60), "podman ps stopped").await
])
.output(),
)
.await
{ {
Ok(result) => result, Ok(output) => output,
Err(_) => { Err(e) => {
warn!("Timeout listing stopped containers (60s)"); warn!("Failed listing stopped containers: {}", e);
return RecoveryReport { return RecoveryReport {
total: 0, total: 0,
recovered: 0, recovered: 0,
@ -373,13 +358,14 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
} }
}; };
let all_names: Vec<String> = match output { let all_names: Vec<String> = if output.status.success() {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout) String::from_utf8_lossy(&output.stdout)
.lines() .lines()
.filter(|l| !l.is_empty()) .filter(|l| !l.is_empty())
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect(), .collect()
_ => Vec::new(), } else {
Vec::new()
}; };
if all_names.is_empty() { if all_names.is_empty() {
@ -481,27 +467,35 @@ pub async fn run_boot_reconciliation() {
return; return;
} }
info!("Running boot reconciliation..."); info!("Running boot reconciliation...");
let result = tokio::time::timeout( let cmd = tokio::process::Command::new(script);
std::time::Duration::from_secs(300), let result = command_with_timeout(cmd, Duration::from_secs(300), script).await;
tokio::process::Command::new(script).output(),
)
.await;
match result { match result {
Ok(Ok(output)) if output.status.success() => { Ok(output) if output.status.success() => {
info!("Boot reconciliation complete"); info!("Boot reconciliation complete");
} }
Ok(Ok(output)) => { Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
warn!( warn!(
"Boot reconciliation had failures: {}", "Boot reconciliation had failures: {}",
stderr.chars().take(500).collect::<String>() stderr.chars().take(500).collect::<String>()
); );
} }
Ok(Err(e)) => warn!("Boot reconciliation failed to run: {}", e), Err(e) => warn!("Boot reconciliation failed: {}", e),
Err(_) => warn!("Boot reconciliation timed out (300s)"),
} }
} }
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. /// Spawn a background task that periodically saves the container snapshot.
pub fn spawn_snapshot_task(data_dir: PathBuf) { pub fn spawn_snapshot_task(data_dir: PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {

View File

@ -216,6 +216,7 @@ struct ContainerHealth {
name: String, name: String,
app_id: String, app_id: String,
state: String, state: String,
podman_health: Option<String>,
healthy: bool, healthy: bool,
} }
@ -447,18 +448,40 @@ async fn check_containers() -> Vec<ContainerHealth> {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_lowercase(); .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 { Some(ContainerHealth {
name, name,
app_id, app_id,
state, state,
podman_health,
healthy, healthy,
}) })
}) })
.collect() .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. /// Try to restart a container.
async fn restart_container(name: &str) -> bool { async fn restart_container(name: &str) -> bool {
info!("Auto-restarting unhealthy container: {}", name); 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): {}", "Skipping orphan container (not in package_data): {}",
container.name 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; continue;
} }
@ -628,8 +659,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
} }
continue; continue;
} }
// Handle exited, stopped, AND created state containers // Handle exited, stopped, created, and Podman-unhealthy running containers.
if container.state == "exited" if container.podman_health.as_deref() == Some("unhealthy")
|| container.state == "exited"
|| container.state == "stopped" || container.state == "stopped"
|| container.state == "created" || container.state == "created"
{ {
@ -874,6 +906,7 @@ mod tests {
name: "archy-bitcoin-knots".to_string(), name: "archy-bitcoin-knots".to_string(),
app_id: "bitcoin-knots".to_string(), app_id: "bitcoin-knots".to_string(),
state: "running".to_string(), state: "running".to_string(),
podman_health: Some("healthy".to_string()),
healthy: true, healthy: true,
}; };
assert!(health.healthy); assert!(health.healthy);
@ -888,6 +921,7 @@ mod tests {
name: "archy-mempool-web".to_string(), name: "archy-mempool-web".to_string(),
app_id: "mempool-web".to_string(), app_id: "mempool-web".to_string(),
state: "exited".to_string(), state: "exited".to_string(),
podman_health: None,
healthy: false, healthy: false,
}; };
assert!(!health.healthy); assert!(!health.healthy);
@ -977,18 +1011,21 @@ mod tests {
name: "indeedhub-postgres".into(), name: "indeedhub-postgres".into(),
app_id: "indeedhub-postgres".into(), app_id: "indeedhub-postgres".into(),
state: "running".into(), state: "running".into(),
podman_health: None,
healthy: true, healthy: true,
}, },
ContainerHealth { ContainerHealth {
name: "indeedhub-redis".into(), name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(), app_id: "indeedhub-redis".into(),
state: "running".into(), state: "running".into(),
podman_health: None,
healthy: true, healthy: true,
}, },
ContainerHealth { ContainerHealth {
name: "indeedhub-api".into(), name: "indeedhub-api".into(),
app_id: "indeedhub-api".into(), app_id: "indeedhub-api".into(),
state: "exited".into(), state: "exited".into(),
podman_health: None,
healthy: false, healthy: false,
}, },
]; ];
@ -998,6 +1035,7 @@ mod tests {
name: "indeedhub-redis".into(), name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(), app_id: "indeedhub-redis".into(),
state: "running".into(), state: "running".into(),
podman_health: None,
healthy: true, healthy: true,
}]; }];
assert!(!deps_are_running("indeedhub-api", &partial)); assert!(!deps_are_running("indeedhub-api", &partial));
@ -1009,6 +1047,7 @@ mod tests {
name: "bitcoin-core".into(), name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(), app_id: "bitcoin-core".into(),
state: "running".into(), state: "running".into(),
podman_health: None,
healthy: true, healthy: true,
}]; }];
assert!(deps_are_running("lnd", &core)); assert!(deps_are_running("lnd", &core));
@ -1017,6 +1056,7 @@ mod tests {
name: "bitcoin-knots".into(), name: "bitcoin-knots".into(),
app_id: "bitcoin-knots".into(), app_id: "bitcoin-knots".into(),
state: "running".into(), state: "running".into(),
podman_health: None,
healthy: true, healthy: true,
}]; }];
assert!(deps_are_running("fedimint", &knots)); assert!(deps_are_running("fedimint", &knots));
@ -1025,6 +1065,7 @@ mod tests {
name: "bitcoin-core".into(), name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(), app_id: "bitcoin-core".into(),
state: "stopped".into(), state: "stopped".into(),
podman_health: None,
healthy: false, healthy: false,
}]; }];
assert!(!deps_are_running("electrumx", &stopped)); assert!(!deps_are_running("electrumx", &stopped));
@ -1036,6 +1077,7 @@ mod tests {
name: "bitcoin-core".into(), name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(), app_id: "bitcoin-core".into(),
state: "running".into(), state: "running".into(),
podman_health: None,
healthy: true, healthy: true,
}]; }];
@ -1050,6 +1092,7 @@ mod tests {
name: "bitcoin-core".into(), name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(), app_id: "bitcoin-core".into(),
state: "stopped".into(), state: "stopped".into(),
podman_health: None,
healthy: false, healthy: false,
}]; }];
@ -1121,4 +1164,13 @@ mod tests {
tracker.record("test", 100_000_000); tracker.record("test", 100_000_000);
assert!(tracker.check_leak("test").is_none()); 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")
);
}
} }

View File

@ -27,7 +27,8 @@ const RESERVED_PORTS: &[u16] = &[
3001, 3002, // Gitea, Uptime Kuma 3001, 3002, // Gitea, Uptime Kuma
8888, // SearXNG 8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich 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. /// Start of range for allocating web app ports when preferred is taken.

View File

@ -838,7 +838,7 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
/// 600s. 2× that gives the spawned task ample margin before we assume it /// 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 /// died (panic, OOM, process restart mid-stop) and fall back to the
/// scanner's authoritative view. Applies to all transitional variants. /// 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 /// Returns true if `state` is one of the transitional variants that a
/// `spawn_transitional`-style background task owns. While such a state is /// `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); absence_tracker.remove(&id);
continue; 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); let count = absence_tracker.entry(id.clone()).or_insert(0);
*count += 1; *count += 1;

View File

@ -713,11 +713,14 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// verified over the complete file at the end of each component, so a /// verified over the complete file at the end of each component, so a
/// partially-corrupt resume still fails cleanly. /// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> { 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 let manifest = state
.available_update .available_update
.as_ref() .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"); let staging_dir = data_dir.join("update-staging");
fs::create_dir_all(&staging_dir) fs::create_dir_all(&staging_dir)

View File

@ -46,6 +46,7 @@ pub struct ContainerStatus {
pub enum ContainerState { pub enum ContainerState {
Created, Created,
Running, Running,
Stopping,
Stopped, Stopped,
Exited, Exited,
Paused, Paused,
@ -57,6 +58,7 @@ impl From<&str> for ContainerState {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"created" => ContainerState::Created, "created" => ContainerState::Created,
"running" => ContainerState::Running, "running" => ContainerState::Running,
"stopping" => ContainerState::Stopping,
"stopped" => ContainerState::Stopped, "stopped" => ContainerState::Stopped,
"exited" => ContainerState::Exited, "exited" => ContainerState::Exited,
"paused" => ContainerState::Paused, "paused" => ContainerState::Paused,
@ -120,6 +122,7 @@ impl PodmanClient {
"penpot" => "http://localhost:9001", "penpot" => "http://localhost:9001",
"nextcloud" => "http://localhost:8085", "nextcloud" => "http://localhost:8085",
"vaultwarden" => "http://localhost:8082", "vaultwarden" => "http://localhost:8082",
"gitea" => "http://localhost:3001",
"jellyfin" => "http://localhost:8096", "jellyfin" => "http://localhost:8096",
"photoprism" => "http://localhost:2342", "photoprism" => "http://localhost:2342",
"immich_server" | "immich" => "http://localhost:2283", "immich_server" | "immich" => "http://localhost:2283",
@ -130,7 +133,7 @@ impl PodmanClient {
"fedimint" | "fedimintd" => "http://localhost:8175", "fedimint" | "fedimintd" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176", "fedimint-gateway" => "http://localhost:8176",
"nostr-rs-relay" => "http://localhost:18081", "nostr-rs-relay" => "http://localhost:18081",
"indeedhub" => "http://localhost:7777", "indeedhub" => "http://localhost:7778",
"dwn" => "http://localhost:3100", "dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080", "endurain" => "http://localhost:8080",
"electrs" | "archy-electrs-ui" => "http://localhost:50002", "electrs" | "archy-electrs-ui" => "http://localhost:50002",

View File

@ -3,8 +3,12 @@ use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use std::process::Command; use std::process::Command;
use std::time::Duration;
use tokio::process::Command as TokioCommand; use tokio::process::Command as TokioCommand;
const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
#[async_trait] #[async_trait]
pub trait ContainerRuntime: Send + Sync { pub trait ContainerRuntime: Send + Sync {
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>; async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
@ -52,13 +56,31 @@ impl PodmanRuntime {
/// Run `podman <args>`, returning an error with captured stderr on non-zero /// Run `podman <args>`, returning an error with captured stderr on non-zero
/// exit. Used for operations (build, image inspect) that are awkward over the /// exit. Used for operations (build, image inspect) that are awkward over the
/// HTTP API. The daemon runs as the target user already, so no sudo hop. /// HTTP API. The daemon runs as the target user already, so no sudo hop.
async fn podman_cli(&self, args: &[&str]) -> Result<std::process::Output> { async fn podman_cli_timeout(
&self,
args: &[&str],
timeout: Duration,
) -> Result<std::process::Output> {
let mut cmd = TokioCommand::new("podman"); let mut cmd = TokioCommand::new("podman");
cmd.args(args); cmd.args(args);
cmd.output() cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await .await
.with_context(|| {
format!(
"podman {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.with_context(|| format!("failed to execute podman {}", args.join(" "))) .with_context(|| format!("failed to execute podman {}", args.join(" ")))
} }
/// Run `podman <args>` with a short timeout for control-plane operations.
async fn podman_cli(&self, args: &[&str]) -> Result<std::process::Output> {
self.podman_cli_timeout(args, PODMAN_CLI_DEFAULT_TIMEOUT)
.await
}
} }
#[async_trait] #[async_trait]
@ -84,19 +106,72 @@ impl ContainerRuntime for PodmanRuntime {
} }
async fn start_container(&self, name: &str) -> Result<()> { async fn start_container(&self, name: &str) -> Result<()> {
self.client.start_container(name).await match self.client.start_container(name).await {
Ok(()) => Ok(()),
Err(api_err) => {
let output = self.podman_cli(&["start", name]).await?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(api_err.context(format!("podman start fallback failed: {}", stderr.trim())))
}
}
}
} }
async fn stop_container(&self, name: &str) -> Result<()> { async fn stop_container(&self, name: &str) -> Result<()> {
self.client.stop_container(name).await match self.client.stop_container(name).await {
Ok(()) => Ok(()),
Err(api_err) => {
let output = self.podman_cli(&["stop", "-t", "30", name]).await?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_missing_container_error(&stderr) {
return Ok(());
}
Err(api_err.context(format!("podman stop fallback failed: {}", stderr.trim())))
}
}
}
} }
async fn remove_container(&self, name: &str) -> Result<()> { async fn remove_container(&self, name: &str) -> Result<()> {
self.client.remove_container(name).await match self.client.remove_container(name).await {
Ok(()) => Ok(()),
Err(api_err) => {
let output = self.podman_cli(&["rm", "-f", name]).await?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_missing_container_error(&stderr) {
return Ok(());
}
Err(api_err.context(format!("podman rm fallback failed: {}", stderr.trim())))
}
}
}
} }
async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> { async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
self.client.get_container_status(name).await match self.client.get_container_status(name).await {
Ok(status) => Ok(status),
Err(api_err) => {
let output = self
.podman_cli(&["container", "inspect", "--format", "json", name])
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(api_err
.context(format!("podman inspect fallback failed: {}", stderr.trim())));
}
parse_podman_inspect_json(&output.stdout, name)
.with_context(|| format!("podman API inspect failed: {api_err}"))
}
}
} }
async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> { async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
@ -142,7 +217,9 @@ impl ContainerRuntime for PodmanRuntime {
async fn build_image(&self, config: &BuildConfig) -> Result<()> { async fn build_image(&self, config: &BuildConfig) -> Result<()> {
let args = build_args_for_podman(config); let args = build_args_for_podman(config);
let borrowed: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); let borrowed: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = self.podman_cli(&borrowed).await?; let output = self
.podman_cli_timeout(&borrowed, PODMAN_CLI_BUILD_TIMEOUT)
.await?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
@ -211,6 +288,103 @@ fn parse_podman_ps_json(stdout: &[u8]) -> Result<Vec<ContainerStatus>> {
.collect()) .collect())
} }
fn parse_podman_inspect_json(stdout: &[u8], requested_name: &str) -> Result<ContainerStatus> {
let text = String::from_utf8_lossy(stdout);
let containers: Vec<serde_json::Value> = serde_json::from_str(&text)?;
let c = containers
.first()
.ok_or_else(|| anyhow::anyhow!("podman inspect returned no containers"))?;
if c.get("State").is_none() {
return Err(anyhow::anyhow!(
"podman inspect returned non-container object for {requested_name}"
));
}
let name = c
.get("Name")
.and_then(|v| v.as_str())
.map(|s| s.trim_start_matches('/'))
.unwrap_or(requested_name)
.to_string();
let state = c
.get("State")
.and_then(|v| v.get("Status"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(ContainerStatus {
id: c
.get("Id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
name: name.clone(),
state: ContainerState::from(state),
health: 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()),
exit_code: c
.get("State")
.and_then(|v| v.get("ExitCode"))
.and_then(|v| v.as_i64())
.map(|c| c as i32),
started_at: c
.get("State")
.and_then(|v| v.get("StartedAt"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
image: c
.get("ImageName")
.and_then(|v| v.as_str())
.or_else(|| {
c.get("Config")
.and_then(|v| v.get("Image"))
.and_then(|v| v.as_str())
})
.unwrap_or("")
.to_string(),
created: c
.get("Created")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
ports: parse_inspect_ports(c),
lan_address: PodmanClient::lan_address_for(&name),
})
}
fn parse_inspect_ports(c: &serde_json::Value) -> Vec<String> {
let Some(bindings) = c
.get("HostConfig")
.and_then(|v| v.get("PortBindings"))
.and_then(|v| v.as_object())
else {
return Vec::new();
};
let mut ports = Vec::new();
for (container_port, host_bindings) in bindings {
let Some(host_bindings) = host_bindings.as_array() else {
continue;
};
for binding in host_bindings {
let host_ip = binding
.get("HostIp")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0.0");
let host_port = binding
.get("HostPort")
.and_then(|v| v.as_str())
.unwrap_or("");
if !host_port.is_empty() {
ports.push(format!("{host_ip}:{host_port}->{container_port}"));
}
}
}
ports
}
fn parse_podman_ps_ports(ports: Option<&serde_json::Value>) -> Vec<String> { fn parse_podman_ps_ports(ports: Option<&serde_json::Value>) -> Vec<String> {
ports ports
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
@ -237,6 +411,14 @@ fn parse_health_from_status(status: &str) -> Option<String> {
(start < end).then(|| status[start + 1..end].to_string()) (start < end).then(|| status[start + 1..end].to_string())
} }
fn is_missing_container_error(stderr: &str) -> bool {
let stderr = stderr.to_ascii_lowercase();
stderr.contains("no container with name or id")
|| stderr.contains("no such container")
|| stderr.contains("does not exist")
|| stderr.contains("not found")
}
/// Build the argv for `podman build` from a BuildConfig. /// Build the argv for `podman build` from a BuildConfig.
/// ///
/// Extracted so it can be unit-tested without actually invoking podman. /// Extracted so it can be unit-tested without actually invoking podman.

View File

@ -1,17 +1,56 @@
# Container Lifecycle Handoff # Container Lifecycle Handoff
Last updated: 2026-05-06 Last updated: 2026-05-11
## 2026-05-13 `.198` Stopping-State Repair Checkpoint
- User directive confirmed: testing target is `.198` until all containers work and the container layer is bulletproof/perfected.
- `.198` service state after this pass:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `5d3777d928ae6ee7627e9401faf932442806020ab7ad7a439eb7384d8eb7b8e6`.
- Live blocker found and repaired:
- `nostr-rs-relay` was stuck in raw Podman state `Stopping (healthy)`; focused lifecycle audit failed with `bad state: nostr-rs-relay is stopping`.
- Removed only the wedged container record with `podman rm -f nostr-rs-relay`; bind-mounted relay data under `/var/lib/archipelago/nostr-relay` was preserved.
- Archipelago/runtime recreated the relay and it returned `Up ... (healthy)`.
- Durable local fix added and deployed:
- `core/archipelago/src/container/prod_orchestrator.rs` now treats `ContainerState::Stopping` as a wedged container record during reconcile and force-recreates it from the manifest instead of trying a normal start.
- Added unit coverage intent: `reconcile_force_recreates_stopping_container`.
- `cargo check -p archipelago --bin archipelago` passed locally.
- `cargo build -p archipelago --bin archipelago --release` passed locally and was deployed to `.198`.
- Rust test binary build for the targeted unit test timed out during compilation in this environment before emitting compiler errors; use `cargo check` plus live `.198` audit as the validated gate for this pass.
- Post-deploy validation on `.198`:
- Focused audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=mempool,searxng,nginx-proxy-manager,nostr-rs-relay,grafana,btcpay-server ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh`.
- Broad audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=240 tests/lifecycle/remote-lifecycle.sh`.
- Raw Podman sweep found no `unhealthy`, `Stopping`, `Removing`, `Exited`, or `Created` containers after post-restart startup settled.
- Direct HTTP probes returned healthy responses (`200` or expected `302`) for dashboard, bitcoin-ui, lnd-ui, btcpay, indeedhub, botfights, gitea, filebrowser, vaultwarden, searxng, fedimint, jellyfin, immich, homeassistant, grafana, tailscale, uptime-kuma, nextcloud, nginx-proxy-manager, and nostr-rs-relay.
- Current `.198` broad audit state:
- Running: `bitcoin-knots`, `lnd`, `btcpay-server`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `grafana`, `tailscale`, `uptime-kuma`, `nextcloud`.
- Absent/expected in this audit: `bitcoin-core`, `mempool`, `electrumx`, `photoprism`.
- Important observation:
- Immediately after backend restart, `bitcoin-knots` briefly appeared `Exited (137)` during startup/recovery, then self-recovered and was running by inspection. Final broad audit and raw sweep were clean.
- Next recommended gate:
- Run destructive/full lifecycle on `.198` only when ready to intentionally cycle app containers; non-destructive broad audit and raw health are green after the stopping-state fix.
## 2026-05-13 Resume Correction
- User directive: "we're testing on .198 right, until all containers are working and we achieve our goal of bulletproof containers".
- Active target remains `.198`; do not drift back to older `.116`/`.228` release threads except for cross-node context.
- Continue lifecycle hardening until every intended `.198` container/app is working, recoverable, and aligned with the bulletproof-container goal.
## Resume Prompt ## Resume Prompt
```text ```text
Resume Archipelago lifecycle testing from /home/archipelago/Projects/archy. Read docs/CONTAINER_LIFECYCLE_HANDOFF.md first. Preserve data unless explicitly told otherwise. Do not revert unrelated dirty worktree changes. Keep untracked docs/CONTAINER_LIFECYCLE_HANDOFF.md and docs/CHAT_TRANSCRIPT_2026-05-02.md. Resume Archipelago lifecycle hardening from /home/archipelago/Projects/archy. Read docs/CONTAINER_LIFECYCLE_HANDOFF.md first. Active mission is node `192.168.1.198`, not the older `.116/.228/.67` release thread. SSH key is `/home/archipelago/.ssh/id_ed25519`; lifecycle password is `password123`. Preserve data unless explicitly told otherwise. Keep `archipelago-doctor.timer` and `archipelago-reconcile.timer` inactive during deterministic testing. Do not revert unrelated dirty worktree changes because another agent/user may be working too.
Current focus: multi-node non-destructive hardening across .228, .116, and .67. .228 was live-repaired and verified for dashboard, Bitcoin UI, LND UI, Immich, and authenticated Bitcoin RPC. .116 was live-repaired for stale Bitcoin Knots command drift, Grafana rootless ownership, nginx /bitcoin-status proxying, and stale LND UI companion image/unit drift; focused non-destructive lifecycle audit now passes for bitcoin-knots,lnd,btcpay-server,mempool,grafana. v1.7.54-alpha release artifacts were regenerated from current source and verified to carry runtime payload fixes. .67 remains unreachable from this workspace despite confirmed credentials archipelago/archipelago. Mission: make every Archipelago app/container on `.198` lifecycle-safe and power-loss/reboot resilient. Containers should not randomly go down; app state must recover through daemon restarts, reboots, stale Podman/Quadlet state, missing host listeners, stuck installs, stopped/exited state drift, and stale stack/container records. Release is blocked until strict lifecycle plus app-specific reachability/launch probes agree with raw Podman health and actual app behavior.
Durable fixes implemented locally: Bitcoin container entrypoint/cmd drift recreation, Grafana data_uid/rootless ownership repair, Immich Postgres 2g memory, IndeedHub boot/start Nostr provider reapply, Apps loading/launch readiness UI fixes, nginx /bitcoin-status backend proxy repair, and LND UI 18083 companion/spec drift repair. Latest live `.198` status from 2026-05-11: `archipelago.service` active; `archipelago-doctor.timer` inactive; `archipelago-reconcile.timer` inactive; deployed `/usr/local/bin/archipelago` sha256 `ed4df8e4c3c0a12a481ea41f8246da4b5f9e9ad931d0f3f58084b0057c330af0`. Broad audit passed with `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh`, but this is not enough for release because raw Podman still reports health/state mismatches.
Latest deployed backend checksum on `.116` after live deploy: `c6c7830f14dc80b0e22d803997ad3df31c9ab3d4b08829b3bddc1b03ce77bd0a`. Latest live verification: nginx `/bitcoin-status` returns JSON, LND UI `http://127.0.0.1:18083/` returns HTTP 200 from `localhost/lnd-ui:local`, runtime payload scripts and promoted `/opt/archipelago/scripts` both carry `18083`, and focused non-destructive lifecycle audit passed for `bitcoin-knots,lnd,btcpay-server,mempool,grafana`. Next action: publish/tag v1.7.54-alpha if approved, then continue to `.228` deploy or `.67` reachability. Current suspected release blocker: reconcile the broad-audit pass with raw Podman health. On `.198`, `mempool-api` is `Up ... (unhealthy)`, `searxng` is `Up ... (unhealthy)`, `botfights` is `Up ... (unhealthy)`, and `nostr-rs-relay` is `Stopping (unhealthy)`. RPC/package state reports the installed audit set as running, so next work is to diagnose these health/state mismatches, decide whether each is a false-negative healthcheck or real app failure, fix the manifest/runtime/reconcile behavior, then rerun focused full lifecycle and browser/direct launch probes for affected apps.
Known `.198` package state from latest broad audit: running `lnd`, `mempool`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `tailscale`, `uptime-kuma`, `nextcloud`; absent `bitcoin-knots`, `bitcoin-core`, `btcpay-server`, `electrumx`, `grafana`, `photoprism`. Some absences are expected/blockers from earlier qualification, but `btcpay-server` and `grafana` had previously passed focused checks, so verify whether their absence is intentional before release.
Regenerated release artifacts: Regenerated release artifacts:
- `releases/v1.7.54-alpha/archipelago`: `77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632` - `releases/v1.7.54-alpha/archipelago`: `77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632`
@ -20,6 +59,97 @@ Regenerated release artifacts:
- Unbundled ISO: `image-recipe/results/archipelago-installer-1.7.54-alpha-unbundled-x86_64.iso`, sha256 `9828b244e6ffdd5f1b1d5184c1b22bef7474b32078b1ceb4ec3584d9bdb6775b`, size `2.3G`. - Unbundled ISO: `image-recipe/results/archipelago-installer-1.7.54-alpha-unbundled-x86_64.iso`, sha256 `9828b244e6ffdd5f1b1d5184c1b22bef7474b32078b1ceb4ec3584d9bdb6775b`, size `2.3G`.
``` ```
## 2026-05-11 `.198` Active Mission Checkpoint
## 2026-05-11 Resume Session Update
- Latest user directive: "please resume our work".
- Reconfirmed active mission is `.198` lifecycle hardening, not the older `.116/.228/.67` thread.
- Live `.198` state at resume:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `494cd64f77cbecb95c08552237cb8fd3c11c2b2b76d5d39854e6cf92b5900b68`.
- Raw Podman still showed release blockers:
- `mempool-api`: `Up ... (unhealthy)`.
- `nginx-proxy-manager`: `Up ... (unhealthy)`.
- `nostr-rs-relay`: `Stopping (healthy)`.
- `searxng` was healthy by the time of recheck and served `http://127.0.0.1:8888/` with HTTP 200.
- Diagnosed `mempool-api` as a real app failure, not a false-negative healthcheck: logs repeatedly show `getaddrinfo ENOTFOUND electrumx`, and `.198` has no `electrumx` container present. `mempool-api` is configured with `ELECTRUM_HOST=electrumx`, so the broad audit was masking a broken stack member.
- Found and fixed a local backend masking bug: `ProdContainerOrchestrator::health` returned `healthy` for every running container and ignored Podman's actual health status. It now returns Podman's health value for running containers, maps `Stopping` to unhealthy, and `ContainerState` now parses Podman's `stopping` state explicitly.
- Local verification:
- `cargo fmt` passed.
- `cargo test -p archipelago-container parse_podman_ps_json_handles_cli_output` passed.
- `cargo check -p archipelago --bin archipelago` passed.
- `cargo test -p archipelago health_maps_states_to_strings` did not finish within 3 minutes during crate compilation; no compiler error was emitted before timeout.
- Focused live audit command attempted: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=mempool,searxng,nginx-proxy-manager ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=60 tests/lifecycle/remote-lifecycle.sh`. It timed out because the deployed `.198` backend still has the old health behavior and Podman operations on the node are intermittently hanging.
- Next continuation point:
- Decide whether to deploy a freshly built backend to `.198`. Do not deploy the current dirty worktree blindly unless the existing unrelated changes are intended for this release, because the workspace contains many modified files from prior work.
- After deploy, rerun focused audit for `mempool,searxng,nginx-proxy-manager` and verify `container-health` reports `mempool` or stack health as unhealthy while `mempool-api` cannot resolve `electrumx`.
- Fix the mempool stack qualification: on a pruned/under-disk node, `mempool` must not install/start into a half-running state that leaves `mempool-api` unhealthy because `electrumx` is absent.
## 2026-05-12 Lifecycle Hardening Completion Checkpoint
- User directive: continue until the work is done.
- Deployed fixed backend to `.198`; final `/usr/local/bin/archipelago` sha256: `616e50ba8a83654e4a7656f931e5c9d1340a92cfa0ba22906edc0d374560df02`.
- `archipelago.service` active; `archipelago-doctor.timer` inactive; `archipelago-reconcile.timer` inactive.
- Local durable fixes made:
- `ProdContainerOrchestrator::health` now respects Podman's health status instead of mapping all running containers to healthy.
- Podman `stopping` state is parsed explicitly and maps to unhealthy/stopping instead of unknown/running.
- `container-health` aggregates stack health for multi-container apps, so stack apps cannot hide unhealthy members like `mempool-api`.
- Health fallback now uses bounded exact-container Podman checks to avoid broad `podman ps` hangs poisoning unrelated app health.
- `mempool` install now runs dependency and archival-Bitcoin checks before dispatching to the stack installer, preventing half-running mempool stacks on pruned/under-disk nodes.
- Nginx Proxy Manager healthcheck now probes `http://localhost:81/`; `/api/` returns 502 on the deployed image while the UI is healthy.
- Runtime start repair now covers Vaultwarden and Nextcloud missing host listeners.
- Nextcloud runtime repair fixes bind-mounted data ownership before start/restart.
- Stale transitional state timeout lowered from 20 minutes to 2 minutes so dead lifecycle tasks clear promptly.
- Live `.198` repairs performed with data preserved:
- Removed broken `mempool` stack via `package.uninstall preserve_data=true`; `mempool` is now absent and full lifecycle correctly reports archival-blocked install.
- Recreated Nginx Proxy Manager container after stale Podman `Removing` state; data under `/var/lib/archipelago/nginx-proxy-manager` preserved.
- Recreated Vaultwarden container after stale conmon/host-listener failure; `/var/lib/archipelago/vaultwarden` preserved.
- Recreated Home Assistant and Nextcloud container records after stale conmon/host-listener failures; data directories preserved.
- Repaired Nextcloud ownership (`/var/lib/archipelago/nextcloud`) so Apache/PHP can write `config.php` and `data/nextcloud.log`.
- Verification passed:
- `cargo fmt`.
- `cargo check -p archipelago --bin archipelago`.
- `cargo build -p archipelago --bin archipelago --release`.
- `cargo test -p archipelago-container parse_podman_ps_json_handles_cli_output` passed earlier in this session.
- `cargo test -p archipelago health_maps_states_to_strings` still fails during local test binary linking with rust-lld undefined hidden symbols; `cargo check` and release build pass.
- Focused audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=mempool,searxng,nginx-proxy-manager ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=120 tests/lifecycle/remote-lifecycle.sh`.
- Broad audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh`.
- Final raw Podman sweep found no `unhealthy`, `Stopping`, or `Removing` containers.
- Final direct probes returned HTTP 200 for LND UI, IndeedHub, Botfights, Gitea, File Browser, Vaultwarden, SearXNG, Fedimint, Jellyfin, Immich, Home Assistant, Tailscale, Uptime Kuma, Nextcloud, Nginx Proxy Manager, and Nostr Relay.
- Final broad audit state:
- Running: `lnd`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `tailscale`, `uptime-kuma`, `nextcloud`.
- Absent/expected for this node or archival-gated: `bitcoin-knots`, `bitcoin-core`, `btcpay-server`, `mempool`, `electrumx`, `grafana`, `photoprism`.
- Remaining release consideration: `.198` is green for the non-destructive broad audit and raw Podman health. Destructive/full lifecycle should still be run only when you are ready to intentionally cycle app containers.
- User corrected the active mission after disconnect: continue `.198` container lifecycle hardening, not the older `.116/.228/.67` thread.
- Mission: build "perfect containers" that do not go down unexpectedly and recover through daemon restarts, server reboots, power loss, stale Podman/Quadlet state, missing rootless host listeners, stuck installs, stopped/exited state drift, and stale stack/container records.
- Preserve app data unless explicitly told otherwise.
- Keep deterministic-test timers paused: `archipelago-doctor.timer` and `archipelago-reconcile.timer` should remain inactive.
- Latest verified `.198` service state:
- `archipelago.service`: active.
- `archipelago-doctor.timer`: inactive.
- `archipelago-reconcile.timer`: inactive.
- `/usr/local/bin/archipelago` sha256: `ed4df8e4c3c0a12a481ea41f8246da4b5f9e9ad931d0f3f58084b0057c330af0`.
- Latest broad audit command passed:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh
```
- Latest broad audit states:
- Running: `lnd`, `mempool`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `tailscale`, `uptime-kuma`, `nextcloud`.
- Absent: `bitcoin-knots`, `bitcoin-core`, `btcpay-server`, `electrumx`, `grafana`, `photoprism`.
- Do not treat the broad audit pass as release-ready yet. Raw Podman still showed these concerning health/state mismatches:
- `mempool-api`: `Up ... (unhealthy)`.
- `searxng`: `Up ... (unhealthy)`.
- `botfights`: `Up ... (unhealthy)`.
- `nostr-rs-relay`: `Stopping (unhealthy)`.
- Current suspected release blocker: Archipelago package state and broad audit say apps are running, but raw Podman health/state still reports unhealthy/stopping containers. Next agent should diagnose whether each mismatch is a false-negative healthcheck, stale Podman state, or a real app failure; then fix manifest/runtime/reconcile behavior and rerun focused full lifecycle plus browser/direct launch probes for affected apps.
- Also verify whether `btcpay-server` and `grafana` being absent is intentional, because both had previously passed focused lifecycle checks on `.198`.
## 2026-05-06 Resume Checkpoint ## 2026-05-06 Resume Checkpoint
- Goal: make container lifecycle and health recovery durable for every install and existing Archipelago server, while preserving app data. - Goal: make container lifecycle and health recovery durable for every install and existing Archipelago server, while preserving app data.

View File

@ -648,3 +648,6 @@ Go/no-go verdict:
> please do not miss AIUI in the release build or remove it from the nodes whatever you do > please do not miss AIUI in the release build or remove it from the nodes whatever you do
- Critical release constraint: AIUI must remain bundled in release artifacts and must never be removed from existing nodes during update/deploy. - Critical release constraint: AIUI must remain bundled in release artifacts and must never be removed from existing nodes during update/deploy.
> please check the resume files for our latest plan and resume the work.
- Current directive: read the resume/plan files, resume the latest active work, and continue from the recorded release/ISO lane while preserving the AIUI release constraint above.

View File

@ -110,7 +110,7 @@ ExecStart=/usr/bin/podman run --name lnd \\
-v ${LND_DATA}:/data/.lnd:Z \\ -v ${LND_DATA}:/data/.lnd:Z \\
-p 9735:9735 \\ -p 9735:9735 \\
-p 10009:10009 \\ -p 10009:10009 \\
-p 8080:8080 \\ -p 18080:8080 \\
docker.io/lightninglabs/lnd:v0.18.0-beta \\ docker.io/lightninglabs/lnd:v0.18.0-beta \\
--configfile=/data/.lnd/lnd.conf --configfile=/data/.lnd/lnd.conf
ExecStop=/usr/bin/podman stop lnd ExecStop=/usr/bin/podman stop lnd

View File

@ -7,10 +7,11 @@ Wants=network-online.target
Type=notify Type=notify
User=archipelago User=archipelago
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678" Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
Environment="ARCHIPELAGO_USE_QUADLET_BACKENDS=true"
# DEV_MODE disabled in production — enabled via override.conf on dev servers # DEV_MODE disabled in production — enabled via override.conf on dev servers
Environment="XDG_RUNTIME_DIR=/run/user/1000" Environment="XDG_RUNTIME_DIR=/run/user/1000"
# + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths) # + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths)
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /run/containers /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000' ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env' ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
ExecStart=/usr/local/bin/archipelago ExecStart=/usr/local/bin/archipelago
Restart=on-failure Restart=on-failure
@ -26,13 +27,13 @@ ProtectSystem=strict
ProtectHome=no ProtectHome=no
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/ # PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
# and must be shared between the service and SSH-created containers # and must be shared between the service and SSH-created containers
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc
# Privilege restriction — NoNewPrivileges=no required for sudo archipelago-wg # Privilege restriction — NoNewPrivileges=no required for sudo archipelago-wg
# (WireGuard peer management). Scoped via sudoers to only archipelago-wg. # (WireGuard peer management). Scoped via sudoers to only archipelago-wg.
NoNewPrivileges=no NoNewPrivileges=no
PrivateDevices=no PrivateDevices=no
SupplementaryGroups=dialout debian-tor SupplementaryGroups=dialout debian-tor fips
# Syscall and network restrictions — safe on Debian 13 (systemd 256+) # Syscall and network restrictions — safe on Debian 13 (systemd 256+)
# which respects NoNewPrivileges=no as an explicit override for seccomp filters # which respects NoNewPrivileges=no as an explicit override for seccomp filters

View File

@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.54-alpha", "version": "1.7.55-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.54-alpha", "version": "1.7.55-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.54-alpha", "version": "1.7.55-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",

View File

@ -115,7 +115,12 @@
"author": "BotFights", "author": "BotFights",
"category": "community", "category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0", "dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net" "repoUrl": "https://botfights.net",
"containerConfig": {
"ports": ["9100:9100"],
"volumes": ["/var/lib/archipelago/botfights:/app/server/data"],
"env": ["NODE_ENV=production", "PORT=9100", "FIGHT_LOOP_ENABLED=true", "ARCHY_EMBEDDED=1"]
}
}, },
{ {
"id": "gitea", "id": "gitea",
@ -126,7 +131,12 @@
"author": "Gitea", "author": "Gitea",
"category": "development", "category": "development",
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23", "dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
"repoUrl": "https://gitea.com" "repoUrl": "https://gitea.com",
"containerConfig": {
"ports": ["3001:3000", "2222:22"],
"volumes": ["/var/lib/archipelago/gitea/data:/data", "/var/lib/archipelago/gitea/config:/etc/gitea"],
"env": ["GITEA__database__DB_TYPE=sqlite3", "GITEA__server__SSH_PORT=2222", "GITEA__server__SSH_LISTEN_PORT=22", "GITEA__server__LFS_START_SERVER=true", "GITEA__packages__ENABLED=true", "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", "GITEA__security__X_FRAME_OPTIONS="]
}
}, },
{ {
"id": "filebrowser", "id": "filebrowser",
@ -138,7 +148,12 @@
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0", "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser" "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": ["8083:80"],
"volumes": ["/var/lib/archipelago/filebrowser:/srv", "/var/lib/archipelago/filebrowser-data:/data"],
"args": ["--database=/data/database.db", "--root=/srv", "--address=0.0.0.0", "--port=80"]
}
}, },
{ {
"id": "vaultwarden", "id": "vaultwarden",
@ -150,7 +165,11 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine", "dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden" "repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": ["8082:80"],
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
}
}, },
{ {
"id": "searxng", "id": "searxng",
@ -162,7 +181,11 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest", "dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng" "repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": ["8888:8080"],
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
}
}, },
{ {
"id": "fedimint", "id": "fedimint",
@ -184,7 +207,11 @@
"author": "Jellyfin", "author": "Jellyfin",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13", "dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin" "repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": ["8096:8096"],
"volumes": ["/var/lib/archipelago/jellyfin/config:/config", "/var/lib/archipelago/jellyfin/cache:/cache"]
}
}, },
{ {
"id": "immich", "id": "immich",
@ -206,7 +233,12 @@
"author": "Home Assistant", "author": "Home Assistant",
"category": "home", "category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1", "dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core" "repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": ["8123:8123"],
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
"env": ["TZ=UTC"]
}
}, },
{ {
"id": "grafana", "id": "grafana",
@ -218,7 +250,12 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0", "dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana" "repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": ["3000:3000"],
"volumes": ["/var/lib/archipelago/grafana:/var/lib/grafana"],
"env": ["GF_PATHS_DATA=/var/lib/grafana", "GF_USERS_ALLOW_SIGN_UP=false"]
}
}, },
{ {
"id": "tailscale", "id": "tailscale",
@ -230,7 +267,13 @@
"category": "networking", "category": "networking",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable", "dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale" "repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": ["8240:8240"],
"volumes": ["/var/lib/archipelago/tailscale:/var/lib/tailscale"],
"env": ["TS_STATE_DIR=/var/lib/tailscale"],
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
}
}, },
{ {
"id": "uptime-kuma", "id": "uptime-kuma",
@ -242,7 +285,13 @@
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1", "dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma" "repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": ["3002:3001"],
"volumes": ["/var/lib/archipelago/uptime-kuma:/app/data"],
"env": ["TZ=UTC"],
"args": ["--", "node", "server/server.js"]
}
}, },
{ {
"id": "photoprism", "id": "photoprism",
@ -253,7 +302,12 @@
"author": "PhotoPrism", "author": "PhotoPrism",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915", "dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism" "repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": ["2342:2342"],
"volumes": ["/var/lib/archipelago/photoprism:/photoprism/storage"],
"env": ["PHOTOPRISM_ADMIN_PASSWORD=archipelago", "PHOTOPRISM_DEFAULT_LOCALE=en"]
}
}, },
{ {
"id": "nextcloud", "id": "nextcloud",
@ -264,7 +318,11 @@
"author": "Nextcloud", "author": "Nextcloud",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28", "dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server" "repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": ["8085:80"],
"volumes": ["/var/lib/archipelago/nextcloud:/var/www/html"]
}
} }
] ]
} }

View File

@ -16,6 +16,13 @@ export interface MarketplaceAppInfo {
dockerImage: string dockerImage: string
/** External web URL for iframe-based web apps (no container needed) */ /** External web URL for iframe-based web apps (no container needed) */
webUrl?: string webUrl?: string
containerConfig?: {
ports?: string[]
volumes?: string[]
env?: string[]
command?: string
args?: string[]
}
} }
// Simple in-memory store for the current marketplace app // Simple in-memory store for the current marketplace app
@ -39,6 +46,7 @@ export function useMarketplaceApp() {
s9pkUrl: app.s9pkUrl ?? '', s9pkUrl: app.s9pkUrl ?? '',
dockerImage: app.dockerImage ?? '', dockerImage: app.dockerImage ?? '',
webUrl: (app as Record<string, unknown>).webUrl as string | undefined, webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
} }
} }

View File

@ -185,6 +185,20 @@ input[type="radio"]:active + * {
isolation: isolate; isolation: isolate;
} }
/* The Apps grid has many backdrop-filter cards inside an animated dashboard
viewport. Chromium/Brave can corrupt those layers into black rectangles,
so keep the translucency but avoid per-card backdrop compositor layers. */
.apps-view .glass-card,
.apps-view .glass,
.apps-view .mode-switcher,
.apps-view .glass-button,
.apps-view input {
backdrop-filter: none;
-webkit-backdrop-filter: none;
transform: none;
isolation: auto;
}
/* Mode switcher - sidebar toggle */ /* Mode switcher - sidebar toggle */
.mode-switcher { .mode-switcher {
display: inline-flex; display: inline-flex;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="pb-6"> <div class="apps-view pb-6">
<!-- Nav header -- tabs + categories + search --> <!-- Nav header -- tabs + categories + search -->
<div class="mb-4"> <div class="mb-4">
<!-- Desktop: page tabs + category tabs + search --> <!-- Desktop: page tabs + category tabs + search -->

View File

@ -436,9 +436,11 @@ async function installCommunityApp(app: MarketplaceApp) {
router.push('/dashboard/apps').catch(() => {}) router.push('/dashboard/apps').catch(() => {})
try { try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
if (app.containerConfig) installParams.containerConfig = app.containerConfig
await rpcClient.call({ await rpcClient.call({
method: 'package.install', method: 'package.install',
params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, params: installParams,
timeout: 15000, timeout: 15000,
}) })
} catch (err) { } catch (err) {

View File

@ -382,6 +382,7 @@ import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMa
import { useMobileBackButton } from '../composables/useMobileBackButton' import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher' import { useAppLauncherStore } from '../stores/appLauncher'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { handleImageError } from './apps/appsConfig'
const { t } = useI18n() const { t } = useI18n()
const { bottomPosition } = useMobileBackButton() const { bottomPosition } = useMobileBackButton()
@ -530,11 +531,6 @@ onBeforeUnmount(() => {
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null } if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
}) })
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
target.src = '/assets/img/logo-archipelago.svg'
}
function goBack() { function goBack() {
if (route.query.from === 'discover') { if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {}) router.push('/dashboard/discover').catch(() => {})
@ -611,13 +607,15 @@ async function installApp() {
try { try {
if (app.value.dockerImage) { if (app.value.dockerImage) {
// Docker-based app installation // Docker-based app installation
const installParams: Record<string, unknown> = {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
}
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
await rpcClient.call({ await rpcClient.call({
method: 'package.install', method: 'package.install',
params: { params: installParams,
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
},
timeout: 15000, timeout: 15000,
}) })
} else { } else {

View File

@ -739,6 +739,10 @@ function showStatus(msg: string, isError = false) {
setTimeout(() => { statusMessage.value = '' }, 8000) setTimeout(() => { statusMessage.value = '' }, 8000)
} }
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
async function loadStatus() { async function loadStatus() {
try { try {
const res = await rpcClient.call<{ const res = await rpcClient.call<{
@ -814,7 +818,17 @@ async function downloadUpdate() {
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1) const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB })) showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
} catch (e) { } catch (e) {
showStatus(t('systemUpdate.downloadFailed'), true) const msg = errorMessage(e)
if (/no update.*available/i.test(msg)) {
updateInfo.value = null
updateMethod.value = null
downloaded.value = false
updateInProgress.value = false
await loadStatus()
showStatus(t('systemUpdate.upToDateMessage'))
} else {
showStatus(`${t('systemUpdate.downloadFailed')} ${msg}`, true)
}
if (import.meta.env.DEV) console.warn('Download failed', e) if (import.meta.env.DEV) console.warn('Download failed', e)
} finally { } finally {
clearInterval(progressInterval) clearInterval(progressInterval)

View File

@ -6,6 +6,8 @@ export interface ContainerConfig {
ports?: string[] ports?: string[]
volumes?: string[] volumes?: string[]
env?: string[] env?: string[]
command?: string
args?: string[]
} }
export type MarketplaceApp = Partial<MarketplaceAppInfo> & { export type MarketplaceApp = Partial<MarketplaceAppInfo> & {

View File

@ -18,6 +18,13 @@ export interface MarketplaceApp {
source?: string source?: string
url?: string url?: string
s9pkUrl?: string s9pkUrl?: string
containerConfig?: {
ports?: string[]
volumes?: string[]
env?: string[]
command?: string
args?: string[]
}
trustScore?: number trustScore?: number
trustTier?: string trustTier?: string
relayCount?: number relayCount?: number

View File

@ -1,29 +1,27 @@
{ {
"version": "1.7.54-alpha", "version": "1.7.55-alpha",
"release_date": "2026-05-06", "release_date": "2026-05-13",
"changelog": [ "changelog": [
"Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.", "Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.",
"LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.", "`.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses.",
"OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.", "Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired."
"Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.",
"`.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.54-alpha", "current_version": "1.7.55-alpha",
"new_version": "1.7.54-alpha", "new_version": "1.7.55-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.54-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.55-alpha/archipelago",
"sha256": "77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632", "sha256": "f2caba778f63c7435431fb1b95cf6470bd43c4769ebe6adee2cbd2721707a663",
"size_bytes": 42600560 "size_bytes": 42580880
}, },
{ {
"name": "archipelago-frontend-1.7.54-alpha.tar.gz", "name": "archipelago-frontend-1.7.55-alpha.tar.gz",
"current_version": "1.7.54-alpha", "current_version": "1.7.55-alpha",
"new_version": "1.7.54-alpha", "new_version": "1.7.55-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.54-alpha/archipelago-frontend-1.7.54-alpha.tar.gz", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.55-alpha/archipelago-frontend-1.7.55-alpha.tar.gz",
"sha256": "a010ac43a2dd02f528202cb2f7b99b61ceab80adc6827877594e41df4ea951fb", "sha256": "fe37425aad25724db49ec2be8d602342cfdc5fb99f08b4a3f04709751a3ed560",
"size_bytes": 166461921 "size_bytes": 166464949
} }
] ]
} }

View File

@ -1,29 +1,27 @@
{ {
"version": "1.7.54-alpha", "version": "1.7.55-alpha",
"release_date": "2026-05-06", "release_date": "2026-05-13",
"changelog": [ "changelog": [
"Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.", "Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.",
"LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.", "`.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses.",
"OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.", "Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired."
"Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.",
"`.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana."
], ],
"components": [ "components": [
{ {
"name": "archipelago", "name": "archipelago",
"current_version": "1.7.54-alpha", "current_version": "1.7.55-alpha",
"new_version": "1.7.54-alpha", "new_version": "1.7.55-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.54-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.55-alpha/archipelago",
"sha256": "77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632", "sha256": "f2caba778f63c7435431fb1b95cf6470bd43c4769ebe6adee2cbd2721707a663",
"size_bytes": 42600560 "size_bytes": 42580880
}, },
{ {
"name": "archipelago-frontend-1.7.54-alpha.tar.gz", "name": "archipelago-frontend-1.7.55-alpha.tar.gz",
"current_version": "1.7.54-alpha", "current_version": "1.7.55-alpha",
"new_version": "1.7.54-alpha", "new_version": "1.7.55-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.54-alpha/archipelago-frontend-1.7.54-alpha.tar.gz", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.55-alpha/archipelago-frontend-1.7.55-alpha.tar.gz",
"sha256": "a010ac43a2dd02f528202cb2f7b99b61ceab80adc6827877594e41df4ea951fb", "sha256": "fe37425aad25724db49ec2be8d602342cfdc5fb99f08b4a3f04709751a3ed560",
"size_bytes": 166461921 "size_bytes": 166464949
} }
] ]
} }

View File

@ -202,7 +202,7 @@ load_spec_lnd() {
SPEC_NAME="lnd" SPEC_NAME="lnd"
SPEC_IMAGE="${LND_IMAGE}" SPEC_IMAGE="${LND_IMAGE}"
SPEC_NETWORK="archy-net" SPEC_NETWORK="archy-net"
SPEC_PORTS="9735:9735 10009:10009 8080:8080" SPEC_PORTS="9735:9735 10009:10009 18080:8080"
SPEC_VOLUMES="/var/lib/archipelago/lnd:/root/.lnd" SPEC_VOLUMES="/var/lib/archipelago/lnd:/root/.lnd"
SPEC_MEMORY="$(mem_limit lnd)" SPEC_MEMORY="$(mem_limit lnd)"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_RAW" SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_RAW"
@ -493,7 +493,7 @@ load_spec_nginx-proxy-manager() {
reset_spec reset_spec
SPEC_NAME="nginx-proxy-manager" SPEC_NAME="nginx-proxy-manager"
SPEC_IMAGE="${NPM_IMAGE}" SPEC_IMAGE="${NPM_IMAGE}"
SPEC_PORTS="81:81 8084:80 8443:443" SPEC_PORTS="81:81 8084:80 8444:443"
SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt" SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
SPEC_MEMORY="$(mem_limit nginx-proxy-manager)" SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1" SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"

View File

@ -685,7 +685,7 @@ LNDCONF
--health-cmd 'curl -sf --insecure https://localhost:8080/v1/getinfo' --health-interval=30s --health-timeout=5s --health-retries=3 \ --health-cmd 'curl -sf --insecure https://localhost:8080/v1/getinfo' --health-interval=30s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 9735:9735 -p 10009:10009 -p 8080:8080 \ -p 9735:9735 -p 10009:10009 -p 18080:8080 \
-v /var/lib/archipelago/lnd:/root/.lnd \ -v /var/lib/archipelago/lnd:/root/.lnd \
$LND_IMAGE $LND_IMAGE
fi fi
@ -914,7 +914,7 @@ LNDCONF
--health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \ --health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8443:443 \ -p 81:81 -p 8084:80 -p 8444:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \ -v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \ -v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
$NPM_IMAGE $NPM_IMAGE

View File

@ -1642,7 +1642,7 @@ LNDCONF
$DOCKER run -d --name lnd --restart unless-stopped --network archy-net \ $DOCKER run -d --name lnd --restart unless-stopped --network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 9735:9735 -p 10009:10009 -p 8080:8080 \ -p 9735:9735 -p 10009:10009 -p 18080:8080 \
-v /var/lib/archipelago/lnd:/root/.lnd \ -v /var/lib/archipelago/lnd:/root/.lnd \
"$LND_IMAGE" "$LND_IMAGE"
echo " LND created" echo " LND created"

View File

@ -900,7 +900,7 @@ LNDCONF
$ADD_HOST_FLAG \ $ADD_HOST_FLAG \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_RAW \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_RAW \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 9735:9735 -p 10009:10009 -p 8080:8080 \ -p 9735:9735 -p 10009:10009 -p 18080:8080 \
-v /var/lib/archipelago/lnd:/root/.lnd \ -v /var/lib/archipelago/lnd:/root/.lnd \
"$LND_IMAGE" 2>>"$LOG" || true "$LND_IMAGE" 2>>"$LOG" || true
fi fi
@ -1151,7 +1151,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager;
--memory=$(mem_limit nginx-proxy-manager) \ --memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8443:443 \ -p 81:81 -p 8084:80 -p 8444:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \ -v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \ -v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
"${NPM_IMAGE}" 2>>"$LOG" || true "${NPM_IMAGE}" 2>>"$LOG" || true

View File

@ -303,13 +303,20 @@ if [ -n "$UI_REBUILD_LIST" ]; then
fi fi
fi fi
# Update systemd service if changed # Update systemd services if changed
if [ -f "$REPO_DIR/image-recipe/configs/archipelago.service" ]; then SYSTEMD_UNITS_CHANGED=false
if ! diff -q "$REPO_DIR/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service &>/dev/null; then for unit in archipelago.service archipelago-fips.service; do
sudo cp "$REPO_DIR/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service src="$REPO_DIR/image-recipe/configs/$unit"
sudo systemctl daemon-reload dst="/etc/systemd/system/$unit"
ok "Systemd service updated" [ -f "$src" ] || continue
if [ ! -f "$dst" ] || ! diff -q "$src" "$dst" &>/dev/null; then
sudo install -m 644 "$src" "$dst"
SYSTEMD_UNITS_CHANGED=true
ok "Updated $unit"
fi fi
done
if [ "$SYSTEMD_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
fi fi
# Keep the doctor timer/service current too. Container uptime fixes rely on # Keep the doctor timer/service current too. Container uptime fixes rely on

View File

@ -10,8 +10,10 @@ required_containers=(
"bitcoin-knots" "bitcoin-knots"
"electrumx" "electrumx"
"lnd" "lnd"
"archy-mempool-db"
"mempool-api" "mempool-api"
"mempool" "mempool"
"filebrowser"
"archy-bitcoin-ui" "archy-bitcoin-ui"
"archy-lnd-ui" "archy-lnd-ui"
"archy-electrs-ui" "archy-electrs-ui"
@ -26,6 +28,18 @@ container_running() {
podman inspect --format '{{.State.Running}}' "$name" 2>/dev/null podman inspect --format '{{.State.Running}}' "$name" 2>/dev/null
} }
bitcoin_rpc() {
curl -fsS --max-time 60 \
--user "archipelago:$(cat /var/lib/archipelago/secrets/bitcoin-rpc-password)" \
--data-binary '{"jsonrpc":"1.0","id":"required-stack","method":"getblockchaininfo","params":[]}' \
-H 'content-type: text/plain;' \
http://127.0.0.1:8332/
}
bitcoin_json() {
python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; print(r[sys.argv[1]])' "$1"
}
@test "required containers are present" { @test "required containers are present" {
local names local names
names="$(podman_names)" names="$(podman_names)"
@ -43,9 +57,29 @@ container_running() {
} }
@test "bitcoin-knots RPC responds" { @test "bitcoin-knots RPC responds" {
run sh -lc 'podman exec bitcoin-knots bitcoin-cli -rpcuser=archipelago -rpcpassword="$(cat /var/lib/archipelago/secrets/bitcoin-rpc-password)" getblockchaininfo' run bitcoin_rpc
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
echo "$output" | jq -e '.chain == "main" and (.blocks >= 0)' >/dev/null echo "$output" | python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; assert r["chain"] == "main" and r["blocks"] >= 0'
}
@test "bitcoin backend is synced archival for electrumx/lnd gate" {
run bitcoin_rpc
[ "$status" -eq 0 ]
local pruned ibd blocks headers
pruned="$(echo "$output" | bitcoin_json pruned)"
ibd="$(echo "$output" | bitcoin_json initialblockdownload)"
blocks="$(echo "$output" | bitcoin_json blocks)"
headers="$(echo "$output" | bitcoin_json headers)"
if [ "$pruned" = "True" ] || [ "$pruned" = "true" ]; then
echo "bitcoin is pruned (blocks=$blocks headers=$headers); electrumx cannot index pruned historical blocks"
return 1
fi
if [ "$ibd" = "True" ] || [ "$ibd" = "true" ]; then
echo "bitcoin is still in initial block download (blocks=$blocks headers=$headers)"
return 1
fi
} }
@test "electrumx TCP port accepts connections" { @test "electrumx TCP port accepts connections" {
@ -59,7 +93,17 @@ PY
} }
@test "lnd CLI getinfo succeeds" { @test "lnd CLI getinfo succeeds" {
run sh -lc 'podman exec lnd lncli --tlscertpath /root/.lnd/tls.cert --macaroonpath /root/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon --rpcserver localhost:10009 getinfo >/dev/null' run sh -lc 'timeout 60 podman exec lnd lncli --tlscertpath /root/.lnd/tls.cert --macaroonpath /root/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon --rpcserver localhost:10009 getinfo >/dev/null'
[ "$status" -eq 0 ]
}
@test "lnd REST port accepts connections" {
run python3 - <<'PY'
import socket
s = socket.create_connection(("127.0.0.1", 18080), 3)
s.close()
print("ok")
PY
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@ -79,6 +123,11 @@ PY
} }
@test "lnd ui responds" { @test "lnd ui responds" {
run curl -fsS "http://127.0.0.1:8081/" run curl -fsS "http://127.0.0.1:18083/"
[ "$status" -eq 0 ]
}
@test "filebrowser responds" {
run curl -fsS "http://127.0.0.1:8083/"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }

View File

@ -20,6 +20,8 @@ ARCHY_APPS="${ARCHY_APPS:-}"
ARCHY_TIMEOUT="${ARCHY_TIMEOUT:-900}" ARCHY_TIMEOUT="${ARCHY_TIMEOUT:-900}"
ARCHY_STABILITY_SECONDS="${ARCHY_STABILITY_SECONDS:-5}" ARCHY_STABILITY_SECONDS="${ARCHY_STABILITY_SECONDS:-5}"
ARCHY_ALLOW_BITCOIN_SWAP="${ARCHY_ALLOW_BITCOIN_SWAP:-0}" ARCHY_ALLOW_BITCOIN_SWAP="${ARCHY_ALLOW_BITCOIN_SWAP:-0}"
ARCHY_APP_CATALOG="${ARCHY_APP_CATALOG:-}"
ARCHY_PRUNED_NODE="${ARCHY_PRUNED_NODE:-auto}"
if [[ -z "$ARCHY_HOST" || -z "$ARCHY_PASSWORD" ]]; then if [[ -z "$ARCHY_HOST" || -z "$ARCHY_PASSWORD" ]]; then
echo "ARCHY_HOST and ARCHY_PASSWORD are required" >&2 echo "ARCHY_HOST and ARCHY_PASSWORD are required" >&2
@ -37,6 +39,7 @@ fi
BASE_URL="${ARCHY_SCHEME}://${ARCHY_HOST}" BASE_URL="${ARCHY_SCHEME}://${ARCHY_HOST}"
SESSION="" SESSION=""
CSRF="" CSRF=""
CATALOG_FILE=""
ALL_APPS=( ALL_APPS=(
bitcoin-knots bitcoin-knots
@ -65,6 +68,69 @@ ALL_APPS=(
gitea gitea
) )
ARCHIVAL_ONLY_APPS=(
electrumx
mempool
)
app_in_list() {
local needle="$1"
shift
local item
for item in "$@"; do
[[ "$item" == "$needle" ]] && return 0
done
return 1
}
fetch_catalog() {
CATALOG_FILE=$(mktemp)
if [[ -n "$ARCHY_APP_CATALOG" ]]; then
cp "$ARCHY_APP_CATALOG" "$CATALOG_FILE"
return 0
fi
if curl -skfL --connect-timeout 8 -m 30 "${BASE_URL}/api/app-catalog" -o "$CATALOG_FILE" \
&& jq -e '.apps | length > 0' "$CATALOG_FILE" >/dev/null; then
return 0
fi
curl -skfL --connect-timeout 8 -m 30 "${BASE_URL}/catalog.json" -o "$CATALOG_FILE"
jq -e '.apps | length > 0' "$CATALOG_FILE" >/dev/null
}
catalog_app_ids() {
jq -r '.apps[] | select((.dockerImage // "") != "") | .id' "$CATALOG_FILE"
}
catalog_app_json() {
local app="$1"
[[ -n "$CATALOG_FILE" && -r "$CATALOG_FILE" ]] || return 1
jq -c --arg app "$app" '
.registry as $registry
| .apps[]
| select(.id == $app)
| .dockerImage = (if ((.dockerImage // "") | contains("/")) then .dockerImage else ($registry + "/" + .dockerImage) end)
' "$CATALOG_FILE" | head -n 1
}
is_pruned_node() {
case "$ARCHY_PRUNED_NODE" in
1|true|yes) return 0 ;;
0|false|no) return 1 ;;
esac
local pass body
pass=$(ssh "${ARCHY_HOST}" 'sudo cat /var/lib/archipelago/secrets/bitcoin-rpc-password 2>/dev/null || cat /var/lib/archipelago/secrets/bitcoin-rpc-password 2>/dev/null' 2>/dev/null || true)
[[ -n "$pass" ]] || return 1
body=$(curl -fsS --max-time 20 \
--user "archipelago:${pass}" \
--data-binary '{"jsonrpc":"1.0","id":"remote-lifecycle","method":"getblockchaininfo","params":[]}' \
-H 'content-type: text/plain;' \
"http://${ARCHY_HOST}:8332/" 2>/dev/null || true)
printf '%s' "$body" | jq -e '.result.pruned == true' >/dev/null 2>&1
}
image_for() { image_for() {
case "$1" in case "$1" in
bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;; bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;;
@ -341,7 +407,7 @@ probe_lnd_wallet_connect() {
(.cert_base64url | type == "string" and length > 100) and (.cert_base64url | type == "string" and length > 100) and
(.macaroon_base64url | type == "string" and length > 50) and (.macaroon_base64url | type == "string" and length > 50) and
(.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$")) and (.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$")) and
(.rest_port == 8080) and (.rest_port == 18080) and
(.grpc_port == 10009) (.grpc_port == 10009)
' >/dev/null || { ' >/dev/null || {
echo "lnd connect info incomplete: $info" >&2 echo "lnd connect info incomplete: $info" >&2
@ -371,12 +437,29 @@ probe_electrum_wallet_connect() {
} }
install_app() { install_app() {
local app="$1" image params local app="$1" app_json image params
image=$(image_for "$app") app_json=$(catalog_app_json "$app" || true)
params=$(jq -nc --arg id "$app" --arg img "$image" '{id:$id,dockerImage:$img,version:"latest"}') if [[ -n "$app_json" ]]; then
params=$(printf '%s' "$app_json" | jq -c '{id, dockerImage, version, containerConfig} | with_entries(select(.value != null))')
else
image=$(image_for "$app")
params=$(jq -nc --arg id "$app" --arg img "$image" '{id:$id,dockerImage:$img,version:"latest"}')
fi
rpc_result package.install "$params" >/dev/null rpc_result package.install "$params" >/dev/null
} }
expect_archival_blocked_install() {
local app="$1" app_json resp err params
app_json=$(catalog_app_json "$app")
params=$(printf '%s' "$app_json" | jq -c '{id, dockerImage, version, containerConfig} | with_entries(select(.value != null))')
resp=$(rpc_call package.install "$params")
err=$(printf '%s' "$resp" | jq -r '.error.message // empty')
if [[ "$err" != *"Requires an archival Bitcoin node"* && "$err" != *"requires an archival Bitcoin node"* && "$err" != *"running pruned Bitcoin"* ]]; then
echo "expected archival Bitcoin block for $app, got: $resp" >&2
return 1
fi
}
start_app() { rpc_result package.start "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; } start_app() { rpc_result package.start "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
stop_app() { rpc_result package.stop "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; } stop_app() { rpc_result package.stop "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
restart_app() { rpc_result package.restart "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; } restart_app() { rpc_result package.restart "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
@ -405,6 +488,11 @@ full_lifecycle_app() {
echo "skip bitcoin-core: set ARCHY_ALLOW_BITCOIN_SWAP=1 to test mutually-exclusive Bitcoin implementation" echo "skip bitcoin-core: set ARCHY_ALLOW_BITCOIN_SWAP=1 to test mutually-exclusive Bitcoin implementation"
return 0 return 0
fi fi
if app_in_list "$app" "${ARCHIVAL_ONLY_APPS[@]}" && is_pruned_node; then
echo "== $app: expect archival Bitcoin block =="
expect_archival_blocked_install "$app"
return $?
fi
echo "== $app: install ==" echo "== $app: install =="
install_app "$app" || return 1 install_app "$app" || return 1
wait_not_installing "$app" || return 1 wait_not_installing "$app" || return 1
@ -451,11 +539,16 @@ full_lifecycle_app() {
apps=() apps=()
if [[ -n "$ARCHY_APPS" ]]; then if [[ -n "$ARCHY_APPS" ]]; then
IFS=',' read -r -a apps <<< "$ARCHY_APPS" IFS=',' read -r -a apps <<< "$ARCHY_APPS"
fetch_catalog || true
elif [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then elif [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then
echo "ARCHY_FULL_LIFECYCLE=1 requires ARCHY_APPS to avoid installing unqualified catalog apps" >&2 fetch_catalog
exit 2 mapfile -t apps < <(catalog_app_ids)
else else
apps=("${ALL_APPS[@]}") if fetch_catalog; then
mapfile -t apps < <(catalog_app_ids)
else
apps=("${ALL_APPS[@]}")
fi
fi fi
rpc_login rpc_login