Compare commits

...
83 changed files with 4219 additions and 658 deletions
+5
View File
@@ -57,6 +57,11 @@ coverage/
*.dmg
*.app
# Release artifacts live in Gitea Release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
# macOS build output
build/macos/
+21 -1
View File
@@ -1,5 +1,25 @@
# 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)
- 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.
- 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.
- 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 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.
## v1.7.53-alpha (2026-05-05)
- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.
- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners.
- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror.
## v1.7.52-alpha (2026-05-05)
- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.
@@ -23,7 +43,7 @@
## 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.
- 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)
+70 -12
View File
@@ -115,7 +115,12 @@
"author": "BotFights",
"category": "community",
"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",
@@ -126,7 +131,12 @@
"author": "Gitea",
"category": "development",
"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",
@@ -138,7 +148,12 @@
"category": "data",
"tier": "core",
"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",
@@ -150,7 +165,11 @@
"category": "data",
"tier": "recommended",
"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",
@@ -162,7 +181,11 @@
"category": "data",
"tier": "recommended",
"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",
@@ -184,7 +207,11 @@
"author": "Jellyfin",
"category": "data",
"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",
@@ -206,7 +233,12 @@
"author": "Home Assistant",
"category": "home",
"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",
@@ -218,7 +250,12 @@
"category": "data",
"tier": "recommended",
"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",
@@ -230,7 +267,13 @@
"category": "networking",
"tier": "recommended",
"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",
@@ -242,7 +285,13 @@
"category": "data",
"tier": "recommended",
"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",
@@ -253,7 +302,12 @@
"author": "PhotoPrism",
"category": "data",
"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",
@@ -264,7 +318,11 @@
"author": "Nextcloud",
"category": "data",
"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"]
}
}
]
}
+1 -1
View File
@@ -19,7 +19,7 @@ This document lists all port assignments for Archipelago apps.
| searxng | 8888 | TCP | Web UI | 18888 |
| onlyoffice | 8088 | TCP | Web UI | 18088 |
| 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 |
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
+7 -4
View File
@@ -26,10 +26,13 @@ app:
echo "bitcoind not found in image" >&2;
exit 127;
fi;
if [ "${DISK_GB:-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="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
RPC_USER="$(printenv BITCOIN_RPC_USER)";
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
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
derived_env:
- key: DISK_GB
@@ -44,7 +47,7 @@ app:
resources:
cpu_limit: 0
memory_limit: 4Gi
memory_limit: 8Gi
disk_limit: 500Gi
security:
+5 -3
View File
@@ -12,7 +12,7 @@ app:
entrypoint: ["sh", "-lc"]
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
secret_env:
- key: BITCOIN_RPC_PASS
@@ -24,8 +24,8 @@ app:
- storage: 50Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 50Gi
security:
@@ -48,6 +48,8 @@ app:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- MAX_SEND=10000000
health_check:
type: tcp
+1
View File
@@ -8,6 +8,7 @@ app:
image: grafana/grafana:10.2.0
image_signature: cosign://...
pull_policy: if-not-present
data_uid: "472:472"
dependencies:
- storage: 5Gi
+1 -1
View File
@@ -53,7 +53,7 @@ app:
health_check:
type: http
endpoint: http://localhost:8123
path: /api/
path: /
interval: 30s
timeout: 5s
retries: 3
+1 -1
View File
@@ -34,7 +34,7 @@ app:
- host: 10009
container: 10009
protocol: tcp
- host: 8080
- host: 18080
container: 8080
protocol: tcp
+1 -1
View File
@@ -58,7 +58,7 @@ app:
health_check:
type: http
endpoint: http://localhost:8999
path: /
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
+2 -2
View File
@@ -46,8 +46,8 @@ app:
health_check:
type: http
endpoint: http://localhost:8081
path: /health
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 3
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: searxng
name: SearXNG
version: latest
version: 1.0.0
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
@@ -42,7 +42,7 @@ app:
health_check:
type: http
endpoint: http://localhost:8888
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.52-alpha"
version = "1.7.55-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.52-alpha"
version = "1.7.55-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+2 -1
View File
@@ -1,4 +1,5 @@
use super::build_response;
use crate::api::rpc::lnd::LND_REST_BASE_URL;
use crate::api::rpc::RpcHandler;
use crate::bitcoin_status;
use crate::electrs_status;
@@ -123,7 +124,7 @@ impl ApiHandler {
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
let url = format!("{LND_REST_BASE_URL}{suffix}");
match reqwest::get(&url).await {
Ok(resp) => {
let status = resp.status().as_u16();
+164 -8
View File
@@ -2,6 +2,10 @@ use super::package::validate_app_id;
use super::transitional::Op;
use super::RpcHandler;
use anyhow::{Context, Result};
use std::time::Duration;
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(10);
impl RpcHandler {
pub(super) async fn handle_container_install(
@@ -379,6 +383,10 @@ impl RpcHandler {
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
if let Some(health) = self.stack_health(app_id).await? {
return Ok(serde_json::json!({ app_id: health }));
}
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.health(&candidate).await {
@@ -434,6 +442,78 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map))
}
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
let Some(members) = stack_health_members(app_id) else {
return Ok(None);
};
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
let mut saw_starting = false;
let mut saw_unknown = false;
for member in members {
match member_health(orchestrator.as_ref(), member)
.await
.as_deref()
{
Ok(health) if health == "healthy" => {}
Ok(health) if health == "starting" => saw_starting = true,
Ok(health) if health == "unknown" => saw_unknown = true,
Ok(_) => return Ok(Some("unhealthy".to_string())),
Err(_) => saw_unknown = true,
}
}
if saw_unknown {
Ok(Some("unknown".to_string()))
} else if saw_starting {
Ok(Some("starting".to_string()))
} else {
Ok(Some("healthy".to_string()))
}
}
}
async fn member_health(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
app_id: &str,
) -> Result<String> {
if let Ok(health) = orchestrator.health(app_id).await {
return Ok(health);
}
for name in status_container_name_candidates(app_id) {
if let Some(health) = inspect_container_health_value(&name).await {
return Ok(health);
}
}
Ok("unknown".to_string())
}
fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
match app_id {
"mempool" | "mempool-web" => {
Some(&["archy-mempool-db", "mempool-api", "archy-mempool-web"])
}
"btcpay-server" | "btcpayserver" | "btcpay" => {
Some(&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"])
}
"immich" => Some(&["immich_postgres", "immich_redis", "immich_server"]),
"indeedhub" => Some(&[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
]),
"fedimint" => Some(&["fedimint"]),
_ => None,
}
}
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
@@ -504,15 +584,21 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
}
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
let out = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}}",
])
.output()
if let Some(v) = ps_container_state_value(name).await {
return Some(v);
}
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
@@ -525,20 +611,90 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
let mut parts = line.split_whitespace();
let status = parts.next().unwrap_or("unknown");
let running = parts.next().unwrap_or("false") == "true";
let health = parts.next().unwrap_or("none");
Some(serde_json::json!({
"name": name,
"status": status,
"state": status,
"running": running,
"health": health,
}))
}
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
"-a",
"--filter",
&format!("name={name}"),
"--format",
"{{.Names}}|{{.Status}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let mut parts = line.splitn(2, '|');
let container_name = parts.next().unwrap_or_default();
if container_name != name {
continue;
}
let status = parts.next().unwrap_or_default();
let state = state_from_podman_status(status);
let health = parse_health_from_status(status).unwrap_or("none");
return Some(serde_json::json!({
"name": name,
"status": state,
"state": state,
"running": state.eq_ignore_ascii_case("running"),
"health": health,
}));
}
None
}
fn state_from_podman_status(status: &str) -> &str {
if status.starts_with("Up ") {
"running"
} else if status.starts_with("Exited ") {
"exited"
} else if status.starts_with("Created") {
"created"
} else if status.starts_with("Stopping") {
"stopping"
} else if status.starts_with("Removing") {
"removing"
} else {
"unknown"
}
}
fn parse_health_from_status(status: &str) -> Option<&str> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| &status[start + 1..end])
}
async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?;
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
if health != "none" {
return Some(health.to_string());
}
}
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()),
"stopping" => Some("unhealthy".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")),
}
+7 -5
View File
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::info;
use super::LND_REST_BASE_URL;
#[derive(Debug, Serialize)]
struct ChannelInfo {
chan_id: String,
@@ -62,7 +64,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let channels_resp: LndListChannelsResponse = client
.get("https://127.0.0.1:8080/v1/channels")
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -72,7 +74,7 @@ impl RpcHandler {
.context("Failed to parse LND channels response")?;
let pending_resp: LndPendingChannelsResponse = match client
.get("https://127.0.0.1:8080/v1/channels/pending")
.get(format!("{LND_REST_BASE_URL}/v1/channels/pending"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -211,7 +213,7 @@ impl RpcHandler {
"perm": true
});
let _ = client
.post("https://127.0.0.1:8080/v1/peers")
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&connect_body)
.send()
@@ -224,7 +226,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/channels")
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&open_body)
.send()
@@ -291,7 +293,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!(
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
+6 -6
View File
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
#[derive(Debug, Serialize)]
struct LndInfo {
@@ -44,7 +44,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client
.get("https://127.0.0.1:8080/v1/getinfo")
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -54,7 +54,7 @@ impl RpcHandler {
.context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/channels")
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -70,7 +70,7 @@ impl RpcHandler {
};
let wallet_balance: LndBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/blockchain")
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -166,7 +166,7 @@ impl RpcHandler {
"cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion,
"rest_port": 8080,
"rest_port": 18080,
"grpc_port": 10009,
}))
}
@@ -186,7 +186,7 @@ impl RpcHandler {
.context("Failed to build HTTP client")?;
let resp = client
.get("https://127.0.0.1:8080/v1/channels/backup")
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
+1
View File
@@ -9,6 +9,7 @@ use anyhow::{anyhow, Context, Result};
/// Canonical on-host path for LND's admin macaroon.
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
// Shared LND response types used by multiple submodules
#[derive(Debug, serde::Deserialize)]
+4 -2
View File
@@ -2,6 +2,8 @@ use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::info;
use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Pay a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
@@ -35,7 +37,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/channels/transactions")
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&pay_body)
.send()
@@ -91,7 +93,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get("https://127.0.0.1:8080/v1/transactions")
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
+11 -9
View File
@@ -4,13 +4,15 @@ use base64::Engine;
use tracing::info;
use zeroize::Zeroize;
use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get("https://127.0.0.1:8080/v1/newaddress")
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -69,7 +71,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/transactions")
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&send_body)
.send()
@@ -129,7 +131,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/invoices")
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body)
.send()
@@ -231,7 +233,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body)
.send()
@@ -289,7 +291,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body)
.send()
@@ -322,7 +324,7 @@ impl RpcHandler {
});
let pub_resp = client
.post("https://127.0.0.1:8080/v2/wallet/tx")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/tx"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&publish_body)
.send()
@@ -387,7 +389,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body)
.send()
@@ -416,7 +418,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body)
.send()
@@ -514,7 +516,7 @@ impl RpcHandler {
});
let resp = client
.post("https://127.0.0.1:8080/v1/initwallet")
.post(format!("{LND_REST_BASE_URL}/v1/initwallet"))
.json(&init_body)
.send()
.await
+1 -1
View File
@@ -188,7 +188,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
let url = format!("{}/v1/invoice/{r_hash}", super::lnd::LND_REST_BASE_URL);
let paid = match client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
@@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Unauthorized",
"Forbidden",
"Not supported",
"Requires",
"requires",
"must be",
"cannot",
+1 -1
View File
@@ -12,7 +12,7 @@ mod fips;
mod handshake;
mod identity;
mod interfaces;
mod lnd;
pub(in crate::api) mod lnd;
mod marketplace;
mod mesh;
mod middleware;
@@ -54,6 +54,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
+152 -53
View File
@@ -1,6 +1,96 @@
use super::validation::validate_app_id;
use crate::port_allocator::PortAllocator;
use anyhow::{Context, Result};
use std::time::Duration;
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(15);
fn is_platform_managed_app(app_id: &str) -> bool {
matches!(
app_id,
"bitcoin"
| "bitcoin-core"
| "bitcoin-knots"
| "bitcoin-ui"
| "lnd"
| "lnd-ui"
| "electrumx"
| "electrs"
| "mempool-electrs"
| "electrs-ui"
| "mempool"
| "mempool-web"
| "mempool-api"
| "archy-mempool-db"
| "archy-mempool-web"
| "btcpay"
| "btcpay-server"
| "btcpayserver"
| "archy-btcpay-db"
| "archy-nbxplorer"
| "fedimint"
| "fedimint-gateway"
| "indeedhub"
| "immich"
)
}
fn safe_dynamic_arg(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 512
&& !value.chars().any(|c| matches!(c, '\0' | '\n' | '\r'))
}
async fn dynamic_app_config(
app_id: &str,
) -> Option<(
Vec<String>,
Vec<String>,
Vec<String>,
Option<String>,
Option<Vec<String>>,
)> {
if is_platform_managed_app(app_id) {
return None;
}
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
let data = tokio::fs::read_to_string(&config_path).await.ok()?;
let cfg = serde_json::from_str::<serde_json::Value>(&data).ok()?;
let string_array = |key: &str| -> Vec<String> {
cfg.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from)
.collect()
})
.unwrap_or_default()
};
let command = cfg
.get("command")
.and_then(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from);
let args = cfg.get("args").and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from)
.collect::<Vec<_>>()
});
tracing::info!(app_id = %app_id, "loaded catalog runtime config for generic app");
Some((
string_array("ports"),
string_array("volumes"),
string_array("env"),
command,
args.filter(|a| !a.is_empty()),
))
}
/// Trusted Docker registries. Only images from these sources are allowed.
#[allow(dead_code)]
@@ -164,22 +254,21 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
/// Get container health check arguments for podman run.
/// Returns (health-cmd, interval, retries) args to append to run_args.
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
let btc_health =
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
let (cmd, interval, retries) = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
// Bitcoin images do not consistently ship bitcoin-cli/curl/nc. Rely on
// process state here; manifests still describe the desired TCP check.
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
"btcpay-server" | "btcpayserver" => {
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
}
"mempool-api" => (
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
"30s",
"3",
),
"mempool" | "mempool-web" | "archy-mempool-web" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
(http_probe_cmd("http://localhost:8080/"), "30s", "3")
}
"electrumx" | "mempool-electrs" | "electrs" => {
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
@@ -190,10 +279,10 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"3",
),
"homeassistant" | "home-assistant" => {
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
}
"grafana" => (
"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",
"30s",
"3",
),
@@ -210,7 +299,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"searxng" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"),
"searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s",
@@ -230,10 +319,8 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
"nostr-rs-relay" | "nostr-relay" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
}
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
"nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
"routstr" => (
"curl -sf http://localhost:8000/v1/models || exit 1",
"30s",
@@ -248,10 +335,21 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
format!("--health-cmd={}", cmd),
format!("--health-interval={}", interval),
format!("--health-retries={}", retries),
"--health-timeout=10s".to_string(),
"--health-start-period=60s".to_string(),
]
}
fn http_probe_cmd(url: &'static str) -> &'static str {
match url {
"http://localhost:8999/api/v1/backend-info" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8999/api/v1/backend-info; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8999/api/v1/backend-info; else exit 0; fi",
"http://localhost:8080/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi",
"http://localhost:81/api/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/api/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/api/; else exit 0; fi",
"http://localhost:81/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/; else exit 0; fi",
_ => "exit 0",
}
}
/// Get per-app memory limit.
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
match app_id {
@@ -260,10 +358,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: bumped from 1g to 2g so its CACHE_MB has somewhere
// to live during initial blockchain indexing. CACHE_MB=2048 in
// env vars below requires this much.
"electrumx" | "mempool-electrs" | "electrs" => "2g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@@ -293,7 +391,8 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"nginx-proxy-manager" => "256m",
// Databases
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
"immich_postgres" | "penpot-postgres" => "256m",
"immich_postgres" => "2g",
"penpot-postgres" => "256m",
"immich_redis" | "penpot-valkey" => "128m",
// Default
_ => "512m",
@@ -407,12 +506,14 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
/// Find all running/stopped containers that belong to a given app.
/// Uses the canonical name list from all_container_names().
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
validate_app_id(package_id)?;
let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "{{.Names}}"])
.output()
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["ps", "-a", "--format", "{{.Names}}"]);
cmd.kill_on_drop(true);
let output = tokio::time::timeout(PODMAN_LIST_TIMEOUT, cmd.output())
.await
.context("podman ps timed out while listing containers")?
.context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
@@ -429,7 +530,7 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
#[cfg(test)]
mod tests {
use super::all_container_names;
use super::{all_container_names, get_health_check_args};
#[test]
fn bitcoin_variant_container_names_are_precise() {
@@ -441,6 +542,19 @@ mod tests {
assert!(knots.contains(&"bitcoin-knots".to_string()));
assert!(!knots.contains(&"bitcoin-core".to_string()));
}
#[test]
fn grafana_health_requires_writable_data_and_http_health() {
let args = get_health_check_args("grafana", "unused");
let health_cmd = args
.iter()
.find_map(|arg| arg.strip_prefix("--health-cmd="))
.expect("grafana should have a health command");
assert!(health_cmd.contains("test -w /var/lib/grafana"));
assert!(health_cmd.contains("test -w /var/lib/grafana/grafana.db"));
assert!(health_cmd.contains("http://localhost:3000/api/health"));
}
}
/// Get data directories to clean for an app.
@@ -527,6 +641,10 @@ pub(super) async fn get_app_config(
Option<String>,
Option<Vec<String>>,
) {
if let Some(config) = dynamic_app_config(app_id).await {
return config;
}
match app_id {
"homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()],
@@ -587,7 +705,7 @@ pub(super) async fn get_app_config(
vec![
"9735:9735".to_string(),
"10009:10009".to_string(),
"8080:8080".to_string(),
"18080:8080".to_string(),
],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec![],
@@ -663,9 +781,10 @@ pub(super) async fn get_app_config(
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB, container now
// gets 2g (config.rs::get_memory_limit) so 2048 fits.
"CACHE_MB=2048".to_string(),
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@@ -809,7 +928,7 @@ pub(super) async fn get_app_config(
vec![
"81:81".to_string(),
"8084:80".to_string(),
"8443:443".to_string(),
"8444:443".to_string(),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
@@ -1036,31 +1155,11 @@ pub(super) async fn get_app_config(
None,
),
_ => {
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
// This allows dynamic apps from the remote catalog to be installed
// without hardcoding their config here.
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
let ports = cfg.get("ports")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let volumes = cfg.get("volumes")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let env_vars = cfg.get("env")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
tracing::info!("Loaded dynamic config for app: {}", app_id);
return (ports, volumes, env_vars, None, None);
}
}
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
// No catalog runtime metadata found; use minimal defaults
// (container's own EXPOSE/VOLUME). New generic apps should declare
// containerConfig in the registry catalog instead of adding Rust cases.
tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", app_id);
(vec![], vec![], vec![], None, None)
},
}
}
}
@@ -9,6 +9,7 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
/// Names of container variants that represent a running Electrum indexer
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
matches!(
@@ -17,6 +18,12 @@ fn requires_unpruned_bitcoin(package_id: &str) -> bool {
)
}
fn archival_bitcoin_required_message(package_id: &str) -> String {
format!(
"Requires an archival Bitcoin node while indexing: {package_id}. This node is running pruned Bitcoin because it does not have enough disk for full block history. Add enough storage for an archival node (about 1 TB or more), resync Bitcoin without pruning/with txindex, then install {package_id}."
)
}
/// Snapshot of which dependency services are currently running.
pub(super) struct RunningDeps {
pub has_bitcoin: bool,
@@ -135,27 +142,57 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building Bitcoin RPC client")?;
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("checking Bitcoin pruning status")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC response")?;
if !status.is_success() {
anyhow::bail!(
"Bitcoin RPC returned {} while checking pruning status",
status
);
}
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
anyhow::bail!("Bitcoin RPC error while checking pruning status: {}", error);
let mut last_error = None;
for _ in 0..3 {
match client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(&rpc_user, Some(&rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
match resp.json::<serde_json::Value>().await {
Ok(json) if status.is_success() => {
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
last_error = Some(format!(
"Bitcoin RPC error while checking pruning status: {error}"
));
} else {
return check_blockchain_info_for_pruning(package_id, &json);
}
}
Ok(json) => {
last_error = Some(format!(
"Bitcoin RPC returned {status} while checking pruning status: {json}"
));
}
Err(e) => {
last_error = Some(format!("decode Bitcoin RPC response: {e}"));
}
}
}
Err(e) => {
last_error = Some(format!("checking Bitcoin pruning status: {e}"));
}
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
if detect_disk_gb() < ARCHIVAL_BITCOIN_DISK_GB {
anyhow::bail!(archival_bitcoin_required_message(package_id));
}
anyhow::bail!(
"Bitcoin RPC unavailable while checking pruning status: {}",
last_error.unwrap_or_else(|| "unknown error".to_string())
);
}
fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value) -> Result<()> {
let Some(result) = json.get("result") else {
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
};
@@ -164,16 +201,28 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
anyhow::bail!(
"{} requires an unpruned Bitcoin node while indexing. Current Bitcoin is pruned; use a full node with enough disk for txindex/full block history, then reinstall/restart {}.",
package_id,
package_id
);
anyhow::bail!(archival_bitcoin_required_message(package_id));
}
Ok(())
}
fn detect_disk_gb() -> u64 {
let output = std::process::Command::new("df")
.args(["-BG", "/var/lib/archipelago"])
.output();
let Ok(output) = output else {
return u64::MAX;
};
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.nth(1)
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|size| size.trim_end_matches('G').parse::<u64>().ok())
.unwrap_or(u64::MAX)
}
/// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@@ -254,17 +303,23 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
/// order for the given app. Unknown containers sort to the end.
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
let containers = get_containers_for_app(package_id).await?;
if containers.is_empty() {
return Ok(vec![format!("archy-{}", package_id)]);
}
let order = startup_order(package_id);
// If no special order defined, fall back to mempool order (legacy behavior)
if order.is_empty() && containers.is_empty() {
return Ok(vec![package_id.to_string()]);
}
let mut sorted = containers;
for required in order {
if !sorted.iter().any(|name| name == required) {
sorted.push((*required).to_string());
}
}
// If no special order is defined, fall back to mempool order for legacy
// multi-container names that may still be returned by config lookups.
let effective_order: &[&str] = if order.is_empty() {
startup_order("mempool")
} else {
order
};
let mut sorted = containers;
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
Ok(sorted)
}
@@ -324,7 +379,15 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)]
mod tests {
use super::requires_unpruned_bitcoin;
use super::{requires_unpruned_bitcoin, startup_order};
#[test]
fn btcpay_start_order_includes_required_stack_members() {
assert_eq!(
startup_order("btcpay-server"),
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
);
}
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
+174 -45
View File
@@ -14,6 +14,7 @@ use crate::data_model::InstallPhase;
use crate::update::host_sudo;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log";
@@ -221,6 +222,12 @@ impl RpcHandler {
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
if matches!(package_id, "mempool" | "mempool-web") {
let deps = self.running_deps_for_install(package_id).await?;
check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?;
}
// Multi-container stacks get their own install path
if package_id == "immich" {
return self.install_immich_stack().await;
@@ -239,15 +246,7 @@ impl RpcHandler {
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
// only when the cache does not show the dependency.
let deps = {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
if dependency_cache_satisfies(package_id, &cached) {
cached
} else {
detect_running_deps().await?
}
};
let deps = self.running_deps_for_install(package_id).await?;
check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
@@ -260,7 +259,7 @@ impl RpcHandler {
// bitcoin_rpc_credentials() generates + persists on first
// call (OnceCell-cached), so this is idempotent.
let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
ensure_bitcoin_rpc_bindings().await?
ensure_bitcoin_rpc_config().await?
} else {
false
};
@@ -362,31 +361,65 @@ impl RpcHandler {
if !start_output.status.success() {
let stderr = String::from_utf8_lossy(&start_output.stderr);
install_log(&format!(
"INSTALL ADOPT FAIL: {} — start failed: {}",
"INSTALL ADOPT RECREATE: {} — start failed, removing wedged container: {}",
package_id, stderr
))
.await;
return Err(anyhow::anyhow!(
"Container {} exists but failed to start: {}",
package_id,
stderr
));
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", package_id])
.output()
.await;
} else {
wait_for_adopted_container(package_id, package_id).await?;
install_log(&format!(
"INSTALL ADOPT OK: {} — already running",
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
))
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", package_id])
.output()
.await;
} else {
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"message": format!("Package {} already installed and running", package_id)
}));
}
}
// Fall through to the fresh install path. Volume-backed data
// remains under /var/lib/archipelago and is not deleted.
} else {
install_log(&format!(
"INSTALL ADOPT OK: {} — already running",
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
))
.await;
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", package_id])
.output()
.await;
} else {
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"message": format!("Package {} already installed and running", package_id)
}));
}
wait_for_adopted_container(package_id, package_id).await?;
}
install_log(&format!(
"INSTALL ADOPT OK: {} — already running",
package_id
))
.await;
ensure_host_port_listener(package_id, package_id).await?;
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
"message": format!("Package {} already installed and running", package_id)
}));
}
}
@@ -513,7 +546,20 @@ impl RpcHandler {
let network_alias_flag = format!("--network-alias={}", container_name);
// Network mode
if package_id == "uptime-kuma" || package_id == "gitea" || package_id == "tailscale" {
if matches!(
package_id,
"uptime-kuma"
| "gitea"
| "tailscale"
| "vaultwarden"
| "homeassistant"
| "home-assistant"
| "nextcloud"
| "searxng"
| "jellyfin"
| "nginx-proxy-manager"
| "portainer"
) {
// These standalone web UIs have repeatedly lost host listeners
// under Podman's rootless pasta backend while staying healthy internally.
// Use slirp4netns/rootlessport for this standalone web UI.
@@ -599,6 +645,10 @@ impl RpcHandler {
self.write_lnd_conf(&rpc_user, &rpc_pass).await?;
}
if package_id == "portainer" {
ensure_user_podman_socket().await?;
}
// Pre-install: SearXNG settings.yml (required or container exits immediately)
if package_id == "searxng" {
let searx_dir = "/var/lib/archipelago/searxng";
@@ -705,6 +755,10 @@ impl RpcHandler {
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
if cleanup_start_conflict(package_id, &stderr).await {
let _ = tokio::process::Command::new("podman")
.args(["rm", "-f", container_name])
.output()
.await;
run_output = cmd.output().await.context("Failed to rerun container")?;
}
}
@@ -750,11 +804,14 @@ impl RpcHandler {
.await;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let status = tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let status = timeout(
Duration::from_secs(10),
tokio::process::Command::new("podman")
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
.output(),
)
.await;
if let Ok(Ok(o)) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "running" {
container_running = true;
@@ -787,6 +844,8 @@ impl RpcHandler {
log_output.chars().take(500).collect::<String>()
));
}
} else {
warn!("podman inspect {} timed out during install", container_name);
}
if i == 11 {
warn!(
@@ -838,6 +897,16 @@ impl RpcHandler {
}))
}
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
if dependency_cache_satisfies(package_id, &cached) {
Ok(cached)
} else {
detect_running_deps().await
}
}
// -- Private helpers for install --
/// Pull the image from a registry or verify a local image exists.
@@ -1175,14 +1244,14 @@ impl RpcHandler {
// user" and skip. Matches the lnd.conf behavior below.
match tokio::fs::metadata(&conf_path).await {
Ok(_) => {
ensure_bitcoin_rpc_bindings().await?;
info!("bitcoin.conf already exists, ensured RPC bind settings");
ensure_bitcoin_rpc_config().await?;
info!("bitcoin.conf already exists, ensured Bitcoin RPC config");
return Ok(());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => {
ensure_bitcoin_rpc_bindings().await?;
info!("bitcoin.conf path inaccessible, ensured RPC bind settings via host helper");
ensure_bitcoin_rpc_config().await?;
info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper");
return Ok(());
}
}
@@ -1205,9 +1274,7 @@ impl RpcHandler {
# rpcauth: salted hash only no plaintext password in config or CLI\n\
{}\n\
server=1\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
rpcport=8332\n\
listen=1\n\
printtoconsole=1\n",
rpcauth_line
@@ -1790,6 +1857,7 @@ autopilot.active=false\n",
async fn cleanup_stale_package_ports(package_id: &str) {
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
@@ -1797,11 +1865,21 @@ async fn cleanup_stale_package_ports(package_id: &str) {
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
_ => {}
}
}
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
if stderr.contains("name is already in use") || stderr.contains("name \"") {
return true;
}
match package_id {
"grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
@@ -1809,6 +1887,12 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
cleanup_stale_pasta_port("3000").await;
true
}
"homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
@@ -1827,6 +1911,20 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
cleanup_stale_pasta_port("3000").await;
true
}
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
}
"nextcloud"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8085").await;
true
}
_ => false,
}
}
@@ -1841,6 +1939,11 @@ async fn cleanup_stale_pasta_port(port: &str) {
.output()
.await;
let _ = tokio::process::Command::new("sudo")
.args(["fuser", "-k", &format!("{}/tcp", port)])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
@@ -1912,12 +2015,40 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
))
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
return Ok(());
}
let status = tokio::process::Command::new("systemctl")
.args(["--user", "restart", "podman.socket"])
.status()
.await
.context("spawn systemctl --user restart podman.socket")?;
if !status.success() {
anyhow::bail!("systemctl --user restart podman.socket exited {status}");
}
for _ in 0..20 {
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
anyhow::bail!("podman socket {socket_path} did not appear after restart")
}
fn required_host_port(package_id: &str) -> Option<u16> {
match package_id {
"grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
_ => None,
}
}
@@ -2029,7 +2160,7 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
))
}
async fn ensure_bitcoin_rpc_bindings() -> Result<bool> {
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
@@ -2044,9 +2175,7 @@ ensure_line() {
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
+170 -100
View File
@@ -5,9 +5,14 @@ use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState;
use anyhow::{Context, Result};
use std::process::Output;
use std::sync::Arc;
use std::time::Duration;
use tracing::warn;
const PODMAN_CONTROL_TIMEOUT: Duration = Duration::from_secs(30);
const PODMAN_LOG_TIMEOUT: Duration = Duration::from_secs(15);
/// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
@@ -292,10 +297,7 @@ impl RpcHandler {
)
.await;
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
let stop_out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let stop_out = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
match stop_out {
Ok(o) if o.status.success() => stopped += 1,
Ok(o) => {
@@ -314,10 +316,7 @@ impl RpcHandler {
// Remove container (without -f to respect graceful shutdown above)
tracing::info!("Uninstall {}: removing container {}", package_id, name);
let rm_out = tokio::process::Command::new("podman")
.args(["rm", name])
.output()
.await;
let rm_out = podman_control(&["rm", name]).await;
match rm_out {
Ok(o) if o.status.success() => removed += 1,
Ok(o) => {
@@ -329,10 +328,7 @@ impl RpcHandler {
name,
stderr.trim()
);
let force_rm = tokio::process::Command::new("podman")
.args(["rm", "-f", name])
.output()
.await;
let force_rm = podman_control(&["rm", "-f", name]).await;
match force_rm {
Ok(o2) if o2.status.success() => removed += 1,
_ => {
@@ -353,10 +349,7 @@ impl RpcHandler {
self.set_uninstall_stage(package_id, "Cleaning up volumes")
.await;
// Clean up dangling volumes associated with removed containers
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
.output()
.await;
let _ = podman_control(&["volume", "prune", "-f"]).await;
// Clean up app-specific networks (only if no other containers use them)
let app_networks: Vec<&str> = match package_id {
@@ -366,10 +359,7 @@ impl RpcHandler {
_ => vec![],
};
for net in &app_networks {
let _ = tokio::process::Command::new("podman")
.args(["network", "rm", net])
.output()
.await;
let _ = podman_control(&["network", "rm", net]).await;
}
// Release port allocation
@@ -485,18 +475,16 @@ impl RpcHandler {
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
let check_output = tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name={}", app_id),
])
.output()
.await
.context("Failed to check container")?;
let check_output = podman_control(&[
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
&format!("name={}", app_id),
])
.await
.context("Failed to check container")?;
let existing = String::from_utf8_lossy(&check_output.stdout);
@@ -541,16 +529,14 @@ impl RpcHandler {
cmd.arg(image);
let output = cmd.output().await.context("Failed to create container")?;
let output = command_with_timeout(cmd, PODMAN_CONTROL_TIMEOUT, "podman run").await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
}
} else {
let output = tokio::process::Command::new("podman")
.args(["start", app_id])
.output()
let output = podman_control(&["start", app_id])
.await
.context("Failed to start container")?;
@@ -575,9 +561,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let output = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
.output()
let output = podman_control(&["stop", "-t", stop_timeout_secs(app_id), app_id])
.await
.context("Failed to stop container")?;
@@ -611,9 +595,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
}
repair_before_package_start(name).await;
tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
let out = podman_control(&["start", name])
.await
.context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() {
@@ -632,17 +614,12 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
// container exits immediately after).
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for name in to_start {
let status = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output()
.await;
let status = podman_control(&["inspect", name, "--format", "{{.State.Status}}"]).await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "5", name])
.output()
.await;
let logs =
podman_with_timeout(&["logs", "--tail", "5", name], PODMAN_LOG_TIMEOUT).await;
let log_text = logs
.map(|o| {
let combined = format!(
@@ -669,6 +646,9 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
for name in to_start {
ensure_runtime_host_port_listener(name).await?;
}
if to_start.iter().any(|name| name == "indeedhub") {
super::install::patch_indeedhub_nostr_provider().await;
}
Ok(())
}
@@ -711,6 +691,39 @@ async fn wait_after_orchestrator_start(name: &str) {
}
}
async fn podman_control(args: &[&str]) -> Result<Output> {
podman_with_timeout(args, podman_control_timeout(args)).await
}
fn podman_control_timeout(args: &[&str]) -> Duration {
args.windows(2)
.find_map(|pair| {
(pair[0] == "-t")
.then(|| pair[1].parse::<u64>().ok())
.flatten()
})
.map(|secs| Duration::from_secs(secs.saturating_add(30)))
.unwrap_or(PODMAN_CONTROL_TIMEOUT)
}
async fn podman_with_timeout(args: &[&str], timeout: Duration) -> Result<Output> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(args);
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
}
async fn command_with_timeout(
mut cmd: tokio::process::Command,
timeout: Duration,
description: &str,
) -> Result<Output> {
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))?
.with_context(|| format!("Failed to exec {}", description))
}
async fn do_orchestrator_package_stop(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
containers: &[String],
@@ -754,9 +767,7 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
name,
stop_timeout_secs(name)
);
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
let out = podman_control(&["stop", "-t", stop_timeout_secs(name), name])
.await
.context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() {
@@ -782,9 +793,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
for name in containers {
tracing::info!("Restarting container: {}", name);
repair_before_package_start(name).await;
let out = tokio::process::Command::new("podman")
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
let out = podman_control(&["restart", "-t", stop_timeout_secs(name), name])
.await
.context(format!("Failed to exec podman restart {}", name))?;
@@ -800,13 +809,8 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
stderr
);
// Fallback: stop then start
let _ = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let start_out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
let _ = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
let start_out = podman_control(&["start", name])
.await
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
@@ -826,6 +830,9 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
}
ensure_runtime_host_port_listener(name).await?;
}
if containers.iter().any(|name| name == "indeedhub") {
super::install::patch_indeedhub_nostr_provider().await;
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
}
@@ -842,7 +849,16 @@ async fn repair_before_package_start(container_name: &str) {
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
"grafana" => cleanup_stale_pasta_port("3000").await,
"grafana" => {
repair_grafana_dirs().await;
cleanup_stale_pasta_port("3000").await;
}
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"nextcloud" => {
repair_nextcloud_dirs().await;
cleanup_stale_pasta_port("8085").await;
}
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
@@ -862,9 +878,7 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
container_name, port
))
.await;
let output = tokio::process::Command::new("podman")
.args(["restart", container_name])
.output()
let output = podman_control(&["restart", container_name])
.await
.context("failed to restart container after missing host port")?;
if !output.status.success() {
@@ -896,9 +910,13 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
match container_name {
"grafana" => Some(3000),
"homeassistant" | "home-assistant" => Some(8123),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"vaultwarden" => Some(8082),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
_ => None,
}
}
@@ -943,6 +961,58 @@ async fn repair_btcpay_dirs() {
repair_btcpay_database_password().await;
}
async fn repair_grafana_dirs() {
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/grafana"])
.output()
.await;
let podman_chown = podman_control(&[
"unshare",
"chown",
"-R",
"472:472",
"/var/lib/archipelago/grafana",
])
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"100471:100471",
"/var/lib/archipelago/grafana",
])
.output()
.await;
}
}
async fn repair_nextcloud_dirs() {
let _ = tokio::process::Command::new("sudo")
.args(["mkdir", "-p", "/var/lib/archipelago/nextcloud"])
.output()
.await;
let podman_chown = podman_control(&[
"unshare",
"chown",
"-R",
"33:33",
"/var/lib/archipelago/nextcloud",
])
.await;
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
let _ = tokio::process::Command::new("sudo")
.args([
"chown",
"-R",
"100032:100032",
"/var/lib/archipelago/nextcloud",
])
.output()
.await;
}
}
async fn repair_btcpay_database_password() {
let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
@@ -954,39 +1024,32 @@ async fn repair_btcpay_database_password() {
return;
}
let _ = tokio::process::Command::new("podman")
.args(["start", "archy-btcpay-db"])
.output()
.await;
let _ = podman_control(&["start", "archy-btcpay-db"]).await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let escaped = db_pass.replace('\'', "''");
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"psql",
"-U",
"btcpay",
"-d",
"btcpay",
"-c",
&sql,
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"createdb",
"-U",
"btcpay",
"nbxplorer",
])
.output()
.await;
let _ = podman_control(&[
"exec",
"archy-btcpay-db",
"psql",
"-U",
"btcpay",
"-d",
"btcpay",
"-c",
&sql,
])
.await;
let _ = podman_control(&[
"exec",
"archy-btcpay-db",
"createdb",
"-U",
"btcpay",
"nbxplorer",
])
.await;
}
async fn repair_indeedhub_network() {
@@ -1003,11 +1066,18 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return;
}
if container_name != "grafana" {
return;
match container_name {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
_ => {}
}
cleanup_stale_pasta_port("3000").await;
}
async fn cleanup_stale_pasta_port(port: &str) {
@@ -450,7 +450,7 @@ impl RpcHandler {
"--cap-add=SETGID",
"--cap-add=SETUID",
"--security-opt=no-new-privileges:true",
"--memory=512m",
"--memory=2g",
"--pids-limit=4096",
"--health-cmd=pg_isready -U postgres || exit 1",
"--health-interval=30s",
@@ -775,6 +775,8 @@ impl RpcHandler {
/// Install Mempool stack (mariadb + mempool-api + mempool-web).
pub(super) async fn install_mempool_stack(&self) -> Result<serde_json::Value> {
super::dependencies::check_bitcoin_pruning_compatibility("mempool").await?;
if let Some(adopted) = adopt_stack_if_exists(
"mempool",
"mempool",
+23 -20
View File
@@ -4,26 +4,9 @@ use anyhow::{Context, Result};
impl RpcHandler {
/// Check for available system updates.
/// Tries git-based check first (if repo exists), falls back to manifest-based.
/// Prefer manifest-based OTA so installed nodes with a checked-out repo do
/// not depend on a potentially stale git remote. Git remains a dev fallback.
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
// the operator wants OTA via manifest — typically a dev box where
// ~/archy/.git exists but isn't the intended update surface.
// Without this short-circuit the dev box always advertises "Pull
// & Rebuild" and can never exercise the manifest OTA path.
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
let repo_dir = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy");
if !manifest_override && repo_dir.join(".git").exists() {
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
return Ok(git_status);
}
}
// Fall back to manifest-based check
let state = update::check_for_updates(&self.config.data_dir).await?;
let update_info = state.available_update.as_ref().map(|u| {
@@ -35,10 +18,30 @@ impl RpcHandler {
})
});
if update_info.is_some() {
return Ok(serde_json::json!({
"current_version": state.current_version,
"last_check": state.last_check,
"update_available": true,
"update": update_info,
"manifest_mirror": state.manifest_mirror,
}));
}
let repo_dir = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
.join("archy");
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_ok() && repo_dir.join(".git").exists() {
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
return Ok(git_status);
}
}
Ok(serde_json::json!({
"current_version": state.current_version,
"last_check": state.last_check,
"update_available": update_info.is_some(),
"update_available": false,
"update": update_info,
"manifest_mirror": state.manifest_mirror,
}))
+75 -39
View File
@@ -8,8 +8,8 @@
//!
//! Two things are synced on startup:
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
//! 2. An nginx `location /api/app-catalog` proxy block required for
//! the App Store catalog proxy to actually reach the backend.
//! 2. Missing nginx backend proxy blocks required for frontend fetches to
//! reach the backend instead of the SPA fallback.
//!
//! Idempotent: no-ops on boxes that are already in sync. All work is
//! best-effort — failures are logged but never abort the backend.
@@ -31,6 +31,7 @@ const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.servic
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
@@ -38,6 +39,8 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
@@ -57,8 +60,8 @@ pub async fn ensure_doctor_installed() {
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
}
match run_nginx().await {
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"),
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
Ok(true) => info!("Patched nginx config to proxy missing backend endpoints"),
Ok(false) => debug!("Nginx backend endpoint proxy blocks already present"),
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
}
match run_bitcoin_rpc_repair().await {
@@ -304,10 +307,9 @@ fn path_dot(path: &Path) -> String {
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. In that state bitcoind is healthy internally, but the
// host-network bitcoin-ui proxy to 127.0.0.1:8332 gets connection resets.
// Repair it at startup so OTA fixes existing nodes without a manual
// uninstall/reinstall.
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
// without a manual uninstall/reinstall. Bind/port stay in the container
// command line to avoid duplicate RPC endpoint definitions.
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
@@ -322,9 +324,7 @@ ensure_line() {
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
@@ -447,13 +447,10 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true)
}
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
/// it's missing. The original ISO shipped individual per-endpoint `location`
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
/// through to the SPA `index.html` and the frontend got HTML instead of
/// JSON. We anchor the insert to the DWN comment that already sits right
/// after the `/api/blob` block, so the new block lands in both the HTTP
/// and HTTPS server blocks.
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend
/// got HTML instead of JSON.
///
/// Validates via `nginx -t` before reloading. On failure the patch is
/// rolled back from a backup written just before the write.
@@ -468,51 +465,90 @@ async fn run_nginx() -> Result<bool> {
return Ok(false);
}
if !Path::new(NGINX_CONF_PATH).exists() {
debug!("{} missing — skipping nginx bootstrap", NGINX_CONF_PATH);
return Ok(false);
let mut changed = false;
let mut patched_paths = Vec::<PathBuf>::new();
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
let candidate = Path::new(path);
if !candidate.exists() {
debug!("{} missing — skipping nginx bootstrap", path);
continue;
}
let canonical = fs::canonicalize(candidate)
.await
.unwrap_or_else(|_| candidate.to_path_buf());
if patched_paths.iter().any(|p| p == &canonical) {
continue;
}
patched_paths.push(canonical);
changed |= patch_nginx_conf(path).await?;
}
Ok(changed)
}
let content = fs::read_to_string(NGINX_CONF_PATH)
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
if content.contains("location /api/app-catalog") {
.with_context(|| format!("read {}", path))?;
let missing_app_catalog = !content.contains("location /api/app-catalog");
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
if !missing_app_catalog && !missing_bitcoin_status {
return Ok(false);
}
// The DWN comment sits at the same indent right after the `/api/blob`
// block in both server blocks — a stable anchor that existed on every
// ISO shipped to date. If it's absent (config got heavily customized),
// we bail rather than guess where to splice.
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
if !content.contains(anchor) {
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
return Ok(false);
let mut patched = content.clone();
if missing_bitcoin_status {
let anchor = " location /electrs-status {";
if !patched.contains(anchor) {
warn!("nginx conf missing electrs-status anchor — skipping /bitcoin-status patch");
} else {
let replacement = format!("{}{}", NGINX_BITCOIN_STATUS_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
}
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
let patched = content.replace(anchor, &replacement);
if missing_app_catalog {
// The DWN comment sits at the same indent right after the `/api/blob`
// block in both server blocks — a stable anchor that existed on every
// ISO shipped to date. If it's absent (config got heavily customized),
// skip rather than guess where to splice.
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
if !patched.contains(anchor) {
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
} else {
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
}
if patched == content {
return Ok(false);
}
// Write patched config via a user-owned tmp + sudo mv, after stashing
// a backup so we can revert if `nginx -t` hates what we produced.
// a backup outside nginx include dirs so validation cannot load it too.
let pid = std::process::id();
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
fs::write(&tmp, &patched)
.await
.with_context(|| format!("write {}", tmp))?;
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid);
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
let backup = format!(
"/tmp/archipelago-nginx-backup-{}-{}.conf",
pid,
patched.len()
);
if let Err(e) = host_sudo(&["cp", path, &backup]).await {
let _ = fs::remove_file(&tmp).await;
return Err(e.context("backup nginx conf"));
}
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await;
let mv = host_sudo(&["mv", &tmp, path]).await;
match mv {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = fs::remove_file(&tmp).await;
anyhow::bail!("sudo mv nginx conf exited with {}", s);
anyhow::bail!("sudo mv nginx conf to {} exited with {}", path, s);
}
Err(e) => {
let _ = fs::remove_file(&tmp).await;
@@ -525,7 +561,7 @@ async fn run_nginx() -> Result<bool> {
let valid = matches!(&test, Ok(s) if s.success());
if !valid {
warn!("nginx -t failed after patch — reverting");
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
let _ = host_sudo(&["mv", &backup, path]).await;
if let Err(e) = test {
return Err(e.context("nginx -t"));
}
+10 -1
View File
@@ -186,6 +186,7 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
/// URL for pull).
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
let local_image = format!("localhost/{}:latest", spec.image_base);
let local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
// Prefer local build — companions can carry build-time customizations
@@ -193,6 +194,9 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
if image_exists(&local_image_compat).await {
return Ok(local_image_compat);
}
if image_exists(&local_image).await {
return Ok(local_image);
}
@@ -335,13 +339,18 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
}
/// Does this companion need install_one to be re-run? Returns true if
/// the unit file is missing OR the service is not active.
/// the unit file is missing, stale, or the service is not active.
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
let dir = quadlet::unit_dir().await?;
let unit_path = dir.join(format!("{}.container", spec.name));
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(true);
}
let expected_image = ensure_image_present(spec).await?;
let expected_unit = build_unit(spec, &expected_image);
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
return Ok(true);
}
let svc = format!("{}.service", spec.name);
Ok(!quadlet::is_active(&svc).await)
}
@@ -252,7 +252,8 @@ impl DevContainerOrchestrator {
match status.state {
archipelago_container::ContainerState::Running => Ok("healthy".to_string()),
archipelago_container::ContainerState::Stopped
| archipelago_container::ContainerState::Exited => Ok("unhealthy".to_string()),
| archipelago_container::ContainerState::Exited
| archipelago_container::ContainerState::Stopping => Ok("unhealthy".to_string()),
archipelago_container::ContainerState::Created => Ok("starting".to_string()),
archipelago_container::ContainerState::Paused => Ok("paused".to_string()),
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),
@@ -136,11 +136,13 @@ impl DockerPackageScanner {
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
Some(ui_address.clone())
reachable_lan_address(&app_id, Some(ui_address.clone())).await
} else {
// Dynamic: use actual port bindings from container, fall back to static map
extract_lan_address(&container.ports)
.or_else(|| PodmanClient::lan_address_for(&app_id))
// Prefer the known web UI port over arbitrary first binding
// (for example Gitea exposes SSH on 2222 before web on 3001).
let candidate = PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports));
reachable_lan_address(&app_id, candidate).await
};
debug!(
@@ -156,21 +158,8 @@ impl DockerPackageScanner {
// Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image);
// Check for available update by comparing running image vs pinned image
let available_update =
image_versions::pinned_image_for_app(&app_id).and_then(|pinned| {
if pinned != container.image {
let pinned_version = image_versions::extract_version_from_image(&pinned);
// Don't flag if both are "latest" — no meaningful diff
if pinned_version != "latest" || running_version != "latest" {
Some(pinned_version)
} else {
None
}
} else {
None
}
});
image_versions::available_update_for_app(&app_id, &container.image);
let package = PackageDataEntry {
state: package_state.clone(),
@@ -631,6 +620,51 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
let url = candidate?;
if !requires_reachable_launch(app_id) {
return Some(url);
}
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
return None;
};
match tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
{
Ok(Ok(_)) => Some(url),
_ => {
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
None
}
}
}
fn requires_reachable_launch(app_id: &str) -> bool {
matches!(
app_id,
"botfights"
| "btcpay-server"
| "fedimint"
| "filebrowser"
| "grafana"
| "homeassistant"
| "home-assistant"
| "jellyfin"
| "mempool"
| "nginx-proxy-manager"
| "uptime-kuma"
| "gitea"
| "nextcloud"
| "portainer"
| "tailscale"
| "immich"
| "searxng"
)
}
fn companion_lan_address(app_id: &str) -> Option<String> {
match app_id {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
@@ -642,6 +676,7 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
ContainerState::Stopping => (PackageState::Stopping, ServiceStatus::Stopped),
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
@@ -205,6 +205,30 @@ pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
images.get(var).cloned()
}
/// Return the pinned tag only when the running image is genuinely behind.
/// Registry host changes alone are not app updates, and floating tags are not
/// explicit versions we should advertise to users as available updates.
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
let pinned = pinned_image_for_app(app_id)?;
let pinned_version = extract_version_from_image(&pinned);
if is_floating_tag(&pinned_version) {
return None;
}
let running_version = extract_version_from_image(running_image);
if pinned_version == running_version {
return None;
}
let pinned_repo = image_without_registry_or_tag(&pinned);
let running_repo = image_without_registry_or_tag(running_image);
if pinned_repo != running_repo {
return None;
}
Some(pinned_version)
}
/// Extract version tag from a full image reference.
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty.
@@ -223,6 +247,32 @@ pub fn extract_version_from_image(image: &str) -> String {
"latest".to_string()
}
fn is_floating_tag(tag: &str) -> bool {
matches!(tag, "latest" | "stable" | "release" | "main")
}
fn image_without_registry_or_tag(image: &str) -> &str {
let without_tag = strip_tag(image);
match without_tag.split_once('/') {
Some((first, rest))
if first.contains('.') || first.contains(':') || first == "localhost" =>
{
rest
}
_ => without_tag,
}
}
fn strip_tag(image: &str) -> &str {
if let Some(slash_pos) = image.rfind('/') {
let after_slash = &image[slash_pos..];
if let Some(colon_pos) = after_slash.rfind(':') {
return &image[..slash_pos + colon_pos];
}
}
image
}
/// Container names and their image variable names for multi-container stacks.
/// Returns empty vec for single-container apps.
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
@@ -286,6 +336,25 @@ mod tests {
);
}
#[test]
fn strips_registry_and_tag_for_image_identity() {
assert_eq!(
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
"lfg2025/lnd"
);
assert_eq!(
image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
"lfg2025/lnd"
);
}
#[test]
fn floating_tags_are_not_explicit_updates() {
assert!(is_floating_tag("latest"));
assert!(is_floating_tag("stable"));
assert!(!is_floating_tag("v0.18.4-beta"));
}
#[test]
fn test_parse_image_versions() {
let content = r#"
+36 -8
View File
@@ -10,6 +10,7 @@ use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
pub const WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
@@ -42,7 +43,7 @@ pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<Ensure
let existing = fs::read_to_string(&paths.conf_path)
.await
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
if has_required_lnd_flags(&existing) {
if has_required_lnd_flags(&existing, rpc_pass) {
return Ok(EnsureOutcome::Unchanged);
}
}
@@ -121,6 +122,31 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
}
async fn unlock_existing_wallet() -> Result<()> {
unlock_existing_wallet_via_rest().await
}
async fn unlock_existing_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/unlockwallet",
serde_json::json!({ "wallet_password": wallet_password }),
)
.await
.context("unlocking existing LND wallet")?
{
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
}
}
#[allow(dead_code)]
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
@@ -221,7 +247,7 @@ async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let url = format!("{LND_REST_BASE_URL}{path}");
let mut last_err = None;
for _ in 0..60 {
match client.get(&url).send().await {
@@ -244,7 +270,7 @@ async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
path: &str,
body: serde_json::Value,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let url = format!("{LND_REST_BASE_URL}{path}");
let mut last_err = None;
for _ in 0..60 {
match client.post(&url).json(&body).send().await {
@@ -291,7 +317,7 @@ async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
return false;
};
client
.get("https://127.0.0.1:8080/v1/getinfo")
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
.send()
.await
@@ -344,12 +370,14 @@ fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
fn has_required_lnd_flags(conf: &str) -> bool {
fn has_required_lnd_flags(conf: &str, rpc_pass: &str) -> bool {
let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}");
[
"bitcoin.active=true",
"bitcoin.mainnet=true",
"bitcoin.node=bitcoind",
"bitcoind.rpchost=bitcoin-knots:8332",
rpc_pass_line.as_str(),
]
.iter()
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
@@ -378,7 +406,7 @@ mod tests {
}
#[tokio::test]
async fn ensure_config_is_idempotent() {
async fn ensure_config_repairs_rpc_password_drift() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
@@ -391,10 +419,10 @@ mod tests {
);
assert_eq!(
ensure_config(&paths, "second").await.unwrap(),
EnsureOutcome::Unchanged
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoind.rpcpass=first"));
assert!(conf.contains("bitcoind.rpcpass=second"));
}
#[tokio::test]
@@ -47,6 +47,27 @@ use crate::update::host_sudo;
/// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable.
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
fn is_required_baseline_app(app_id: &str) -> bool {
matches!(
app_id,
"bitcoin-knots"
| "electrumx"
| "lnd"
| "mempool-api"
| "mempool"
| "archy-mempool-db"
| "filebrowser"
)
}
fn requires_archival_bitcoin(app_id: &str) -> bool {
matches!(
app_id,
"electrumx" | "mempool-api" | "mempool" | "archy-mempool-db"
)
}
/// Compute the podman container name for a manifest.
///
@@ -113,6 +134,132 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
))
}
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64) -> Result<()> {
for port in manifest.app.ports.iter().map(|p| p.host) {
if !wait_for_host_port(port, timeout_secs).await {
return Err(anyhow::anyhow!(
"{} host port {} did not become reachable within {}s",
manifest.app.id,
port,
timeout_secs
));
}
}
Ok(())
}
async fn patch_indeedhub_nostr_provider() {
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
}
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
if !already_patched {
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
.output()
.await;
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.output()
.await;
}
/// Outcome of `reconcile_all` for a single app.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconcileAction {
@@ -204,6 +351,8 @@ pub struct ProdContainerOrchestrator {
/// false so the legacy path remains the production path until the
/// 20× lifecycle harness goes green against the new path.
use_quadlet_backends: bool,
#[cfg(test)]
test_disk_gb: Option<u64>,
}
struct FileSecretsProvider {
@@ -251,6 +400,8 @@ impl ProdContainerOrchestrator {
lnd_paths: lnd::EnsurePaths::default(),
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
})
}
@@ -268,6 +419,7 @@ impl ProdContainerOrchestrator {
lnd_paths: lnd::EnsurePaths::default(),
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: false,
test_disk_gb: None,
}
}
@@ -299,6 +451,11 @@ impl ProdContainerOrchestrator {
self.lnd_paths = paths;
}
#[cfg(test)]
pub fn set_disk_gb_for_test(&mut self, disk_gb: u64) {
self.test_disk_gb = Some(disk_gb);
}
/// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each,
/// validates it, and stores it in the in-memory state.
///
@@ -475,8 +632,19 @@ impl ProdContainerOrchestrator {
.collect()
};
let mut report = ReconcileReport::default();
let disk_gb = self.disk_gb();
for lm in manifests {
let app_id = lm.manifest.app.id.clone();
if mode == ReconcileMode::ExistingOnly
&& requires_archival_bitcoin(&app_id)
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
{
report.record(
&app_id,
ReconcileAction::Left("requires-archival-bitcoin".into()),
);
continue;
}
match self.ensure_running_with_mode(&lm, mode).await {
Ok(action) => report.record(&app_id, action),
Err(e) => {
@@ -501,9 +669,10 @@ impl ProdContainerOrchestrator {
let app_id = lm.manifest.app.id.clone();
if app_id == "indeedhub" {
// IndeedHub is a multi-container stack installed by the package
// stack path. Reconciling its single manifest races stack installs
// and can recreate a broken frontend container with the same name.
return Ok(ReconcileAction::Left("stack-managed".to_string()));
// stack path. Boot reconcile must not fresh-install the catalog
// manifest, but it does need to start/repair an already-installed
// stack and reapply the frontend's Nostr provider patch after boot.
return self.reconcile_indeedhub_stack(mode).await;
}
let lock = self.app_lock(&app_id).await;
let _guard = lock.lock().await;
@@ -578,8 +747,20 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Installed);
}
self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(ReconcileAction::Started)
}
ContainerState::Stopping => {
tracing::warn!(
app_id = %app_id,
container = %name,
"container stuck in stopping state; force-recreating container record"
);
self.prepare_for_start(&resolved_manifest).await?;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
Ok(ReconcileAction::Installed)
}
ContainerState::Created => {
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
@@ -601,6 +782,7 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Installed);
}
self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(ReconcileAction::Started)
}
ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())),
@@ -608,7 +790,36 @@ impl ProdContainerOrchestrator {
}
}
Err(_) => {
// Container missing entirely → install fresh.
// Container missing entirely. With Quadlet backends enabled, an
// existing .container file is installed state even if Podman
// lost the container record after a crash/reboot. Sync the unit
// bytes first (clears stale Notify=healthy/nc probes), then ask
// user systemd to start the generated service.
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? {
self.prepare_for_start(&resolved_manifest).await?;
self.sync_quadlet_unit(lm, &name).await?;
quadlet::enable_now(&format!("{name}.service"))
.await
.with_context(|| {
format!("start existing quadlet service {name}.service")
})?;
self.run_post_start_hooks(&app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
return Ok(ReconcileAction::Started);
}
// Required baseline services must self-heal even if both the
// podman record and Quadlet unit are gone. These are installed
// by first boot and are prerequisites for dependent apps; an
// "absent" result leaves the node permanently degraded after
// crash cleanup.
if mode == ReconcileMode::ExistingOnly && is_required_baseline_app(&app_id) {
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
// Optional container missing entirely → leave absent during
// boot reconcile; explicit install/start can create it.
if mode == ReconcileMode::ExistingOnly {
return Ok(ReconcileAction::Left("absent".to_string()));
}
@@ -693,6 +904,7 @@ impl ProdContainerOrchestrator {
.with_context(|| format!("start_container {name}"))?;
}
self.run_post_start_hooks(&lm.manifest.app.id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
Ok(())
}
@@ -720,10 +932,24 @@ impl ProdContainerOrchestrator {
async fn run_post_data_uid_hooks(&self, app_id: &str) -> Result<()> {
match app_id {
"fedimint" | "fedimint-gateway" => self.ensure_fedimint_dirs().await,
"grafana" => self.ensure_grafana_dirs().await,
_ => Ok(()),
}
}
async fn ensure_grafana_dirs(&self) -> Result<()> {
let dir = "/var/lib/archipelago/grafana";
let mkdir = host_sudo(&["mkdir", "-p", dir])
.await
.context("mkdir grafana data dir")?;
if !mkdir.success() {
return Err(anyhow::anyhow!("mkdir -p {dir} failed with status {mkdir}"));
}
chown_for_rootless_container("472:472", dir)
.await
.context("chown grafana data dir for rootless uid 472")
}
/// Phase 3.3 in-place migration. When `use_quadlet_backends` flips
/// from off → on, existing nodes have backend containers parented
/// under archipelago.service's cgroup (the bad shape). They need to
@@ -815,6 +1041,16 @@ impl ProdContainerOrchestrator {
Ok(Some(ReconcileAction::Installed))
}
async fn quadlet_unit_exists(&self, name: &str) -> Result<bool> {
let unit_dir = quadlet::unit_dir()
.await
.context("locate user quadlet unit dir for existing unit check")?;
let unit_path = unit_dir.join(format!("{name}.container"));
tokio::fs::try_exists(&unit_path)
.await
.with_context(|| format!("check existing quadlet unit {}", unit_path.display()))
}
/// Drift-sync an existing Quadlet unit file's bytes against what the
/// current renderer produces. No-op when the flag is off, when the
/// app is a companion (companion.rs owns those units), or when no
@@ -844,9 +1080,20 @@ impl ProdContainerOrchestrator {
if !tokio::fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(());
}
let old_body = tokio::fs::read_to_string(&unit_path)
.await
.unwrap_or_default();
let restart_required = quadlet::contains_stale_health_gate(&old_body);
let mut resolved = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved)?;
let unit = quadlet::QuadletUnit::from_manifest(&resolved, name);
let new_body = unit.render();
let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body);
let restart_for_network_alias_change =
quadlet::network_aliases_changed(&old_body, &new_body);
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
let changed = quadlet::write_if_changed(&unit, &unit_dir)
.await
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
@@ -860,6 +1107,36 @@ impl ProdContainerOrchestrator {
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
);
}
if changed
&& (restart_required
|| restart_for_port_change
|| restart_for_network_alias_change
|| restart_for_exec_change
|| restart_for_health_change)
{
let service = unit.service_name();
let reason = if restart_required {
"stale health gate"
} else if restart_for_port_change {
"port binding drift"
} else if restart_for_network_alias_change {
"network alias drift"
} else if restart_for_health_change {
"health command drift"
} else {
"exec drift"
};
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
service = %service,
reason = reason,
"Quadlet unit rewrite requires service restart"
);
quadlet::restart_service(&service)
.await
.with_context(|| format!("restart drifted quadlet service {service}"))?;
}
Ok(())
}
@@ -1138,6 +1415,62 @@ impl ProdContainerOrchestrator {
Ok(())
}
async fn reconcile_indeedhub_stack(&self, mode: ReconcileMode) -> Result<ReconcileAction> {
let frontend_status = match self.runtime.get_container_status("indeedhub").await {
Ok(status) => status,
Err(_) => {
if mode == ReconcileMode::ExistingOnly {
return Ok(ReconcileAction::Left("absent".to_string()));
}
// Fresh stack creation is owned by package::stacks so we do not
// create a single broken frontend container from the manifest.
return Ok(ReconcileAction::Left("stack-managed".to_string()));
}
};
self.start_indeedhub_backends().await?;
let mut started = false;
match frontend_status.state {
ContainerState::Running => {}
ContainerState::Stopped
| ContainerState::Exited
| ContainerState::Created
| ContainerState::Stopping => {
self.runtime
.start_container("indeedhub")
.await
.context("start IndeedHub frontend during reconcile")?;
started = true;
}
ContainerState::Paused => return Ok(ReconcileAction::Left("paused".to_string())),
ContainerState::Unknown(s) => return Ok(ReconcileAction::Left(s)),
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
self.repair_indeedhub_network_aliases().await;
patch_indeedhub_nostr_provider().await;
if !wait_for_host_port(7778, 10).await {
tracing::warn!(
"IndeedHub frontend running but host port 7778 is not listening; restarting"
);
let _ = self.runtime.stop_container("indeedhub").await;
self.runtime
.start_container("indeedhub")
.await
.context("restart IndeedHub frontend after missing host port")?;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
patch_indeedhub_nostr_provider().await;
}
if started {
Ok(ReconcileAction::Started)
} else {
Ok(ReconcileAction::NoOp)
}
}
async fn repair_indeedhub_network_aliases(&self) {
for (container, alias) in [
("indeedhub-postgres", "postgres"),
@@ -1186,7 +1519,7 @@ impl ProdContainerOrchestrator {
fn detect_host_facts(&self) -> HostFacts {
let host_ip = Self::detect_host_ip().unwrap_or_else(|| "127.0.0.1".to_string());
let host_mdns = Self::detect_host_mdns();
let disk_gb = Self::detect_disk_gb();
let disk_gb = self.disk_gb();
HostFacts {
host_ip,
host_mdns,
@@ -1249,6 +1582,14 @@ impl ProdContainerOrchestrator {
kb / 1_000_000
}
fn disk_gb(&self) -> u64 {
#[cfg(test)]
if let Some(disk_gb) = self.test_disk_gb {
return disk_gb;
}
Self::detect_disk_gb()
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut env = manifest.app.environment.clone();
@@ -1302,6 +1643,10 @@ impl ProdContainerOrchestrator {
return false;
}
if self.container_command_drifted(name, manifest).await {
return true;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
@@ -1334,6 +1679,56 @@ impl ProdContainerOrchestrator {
})
}
async fn container_command_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if manifest.app.container.entrypoint.is_none()
&& manifest.app.container.custom_args.is_empty()
{
return false;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"entry={{json .Config.Entrypoint}}\ncmd={{json .Config.Cmd}}",
])
.output()
.await;
let Ok(output) = inspect else {
return false;
};
if !output.status.success() {
return false;
}
let text = String::from_utf8_lossy(&output.stdout);
let current_entry = text
.lines()
.find_map(|line| line.strip_prefix("entry="))
.and_then(|json| serde_json::from_str::<Option<Vec<String>>>(json).ok())
.flatten()
.unwrap_or_default();
let current_cmd = text
.lines()
.find_map(|line| line.strip_prefix("cmd="))
.and_then(|json| serde_json::from_str::<Option<Vec<String>>>(json).ok())
.flatten()
.unwrap_or_default();
if let Some(expected_entry) = &manifest.app.container.entrypoint {
if current_entry != *expected_entry {
return true;
}
}
if !manifest.app.container.custom_args.is_empty()
&& current_cmd != manifest.app.container.custom_args
{
return true;
}
false
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
return Ok(());
@@ -1461,10 +1856,20 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let action = self.ensure_running(&lm).await?;
match action {
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => Ok(()),
ReconcileAction::Left(state) => Err(anyhow::anyhow!(
"container {} left in {state}",
compute_container_name(&lm.manifest)
)),
ReconcileAction::Left(state) => {
let name = compute_container_name(&lm.manifest);
tracing::warn!(
app_id = %app_id,
container = %name,
state = %state,
"start: container in wedged state, force-recreating"
);
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(&lm).await
}
}
}
@@ -1477,6 +1882,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
// Quadlet-owned containers are restarted by systemd if only `podman stop`
// is used. Stop the user service first, then stop the container as a
// defensive fallback for legacy/non-Quadlet installs.
if let Err(err) = quadlet::stop_service(&format!("{name}.service")).await {
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
}
self.runtime
.stop_container(&name)
.await
@@ -1488,6 +1899,24 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
let service = format!("{name}.service");
if self.quadlet_unit_exists(&name).await? {
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
self.prepare_for_start(&resolved_manifest).await?;
self.sync_quadlet_unit(&lm, &name).await?;
if let Err(err) = quadlet::restart_service(&service).await {
tracing::warn!(container = %name, error = %err, "quadlet restart failed; trying start");
quadlet::enable_now(&service)
.await
.with_context(|| format!("restart start quadlet service {service}"))?;
}
self.run_post_start_hooks(app_id).await?;
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
return Ok(());
}
// Best-effort stop (ignored if already stopped), then start.
let _ = self.runtime.stop_container(&name).await;
self.prepare_for_start(&lm.manifest).await?;
@@ -1589,8 +2018,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn health(&self, app_id: &str) -> Result<String> {
let status = <Self as ContainerOrchestrator>::status(self, app_id).await?;
Ok(match status.state {
ContainerState::Running => "healthy".to_string(),
ContainerState::Stopped | ContainerState::Exited => "unhealthy".to_string(),
ContainerState::Running => status.health.unwrap_or_else(|| "healthy".to_string()),
ContainerState::Stopped | ContainerState::Exited | ContainerState::Stopping => {
"unhealthy".to_string()
}
ContainerState::Created => "starting".to_string(),
ContainerState::Paused => "paused".to_string(),
ContainerState::Unknown(s) => format!("unknown:{s}"),
@@ -1616,6 +2047,8 @@ mod tests {
calls: StdMutex<Vec<String>>,
/// container_name -> ContainerState. Absence = "doesn't exist".
containers: StdMutex<HashMap<String, ContainerState>>,
/// container_name -> Podman health status.
health: StdMutex<HashMap<String, String>>,
/// image_ref -> present. Absence = "not present in local storage".
images: StdMutex<HashMap<String, bool>>,
/// container_name -> env that create_container received.
@@ -1639,6 +2072,12 @@ mod tests {
.unwrap()
.insert(name.to_string(), state);
}
fn set_health(&self, name: &str, health: &str) {
self.health
.lock()
.unwrap()
.insert(name.to_string(), health.to_string());
}
fn mark_image_present(&self, tag: &str) {
self.images.lock().unwrap().insert(tag.to_string(), true);
}
@@ -1699,11 +2138,12 @@ mod tests {
.get(name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("not found: {name}"))?;
let health = self.health.lock().unwrap().get(name).cloned();
Ok(ContainerStatus {
id: format!("id-{name}"),
name: name.to_string(),
state,
health: None,
health,
exit_code: None,
started_at: None,
image: "test-image".to_string(),
@@ -2206,6 +2646,82 @@ app:
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots"));
}
#[tokio::test]
async fn reconcile_existing_installs_missing_required_baseline_app() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
orch.insert_manifest_for_test(
pull_manifest("filebrowser", "docker.io/filebrowser/filebrowser:latest"),
PathBuf::from("/tmp/filebrowser"),
)
.await;
let report = orch.reconcile_existing().await;
assert_eq!(
report.actions,
vec![("filebrowser".to_string(), ReconcileAction::Installed)]
);
assert!(report.failures.is_empty());
let calls = rt.calls();
assert!(calls.iter().any(|c| c.starts_with("pull_image:")));
assert!(calls
.iter()
.any(|c| c == "create_container:filebrowser:offset=0"));
assert!(calls.iter().any(|c| c == "start_container:filebrowser"));
}
#[tokio::test]
async fn reconcile_existing_skips_archival_baseline_apps_on_pruned_hosts() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
orch.insert_manifest_for_test(
pull_manifest("electrumx", "docker.io/spesmilo/electrumx:latest"),
PathBuf::from("/tmp/electrumx"),
)
.await;
let report = orch.reconcile_existing().await;
assert_eq!(
report.actions,
vec![(
"electrumx".to_string(),
ReconcileAction::Left("requires-archival-bitcoin".into())
)]
);
assert!(report.failures.is_empty());
let calls = rt.calls();
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
}
#[tokio::test]
async fn reconcile_existing_leaves_missing_optional_app_absent() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest("gitea", "docker.io/gitea/gitea:latest"),
PathBuf::from("/tmp/gitea"),
)
.await;
let report = orch.reconcile_existing().await;
assert_eq!(
report.actions,
vec![("gitea".to_string(), ReconcileAction::Left("absent".into()))]
);
assert!(report.failures.is_empty());
let calls = rt.calls();
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
}
#[tokio::test]
async fn reconcile_collects_per_app_failures_without_short_circuiting() {
let rt = Arc::new(MockRuntime::default());
@@ -2381,6 +2897,32 @@ app:
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots"));
}
#[tokio::test]
async fn reconcile_force_recreates_stopping_container() {
let rt = Arc::new(MockRuntime::default());
rt.set_state("nostr-rs-relay", ContainerState::Stopping);
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest("nostr-rs-relay", "docker.io/scsibug/nostr-rs-relay:0.8.9"),
PathBuf::from("/tmp/nostr-rs-relay"),
)
.await;
let report = orch.reconcile_all().await;
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(
report.actions,
vec![("nostr-rs-relay".to_string(), ReconcileAction::Installed)]
);
let calls = rt.calls();
assert!(calls.iter().any(|c| c == "remove_container:nostr-rs-relay"));
assert!(calls
.iter()
.any(|c| c == "create_container:nostr-rs-relay:offset=0"));
assert!(calls.iter().any(|c| c == "start_container:nostr-rs-relay"));
}
#[tokio::test]
async fn health_maps_states_to_strings() {
let rt = Arc::new(MockRuntime::default());
@@ -2390,6 +2932,9 @@ app:
.await;
assert_eq!(orch.health("lnd").await.unwrap(), "healthy");
rt.set_health("lnd", "unhealthy");
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
rt.set_state("lnd", ContainerState::Exited);
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
@@ -2398,6 +2943,9 @@ app:
rt.set_state("lnd", ContainerState::Created);
assert_eq!(orch.health("lnd").await.unwrap(), "starting");
rt.set_state("lnd", ContainerState::Stopping);
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
}
#[tokio::test]
+229 -68
View File
@@ -52,6 +52,10 @@ pub struct BindMount {
#[allow(dead_code)] // Bridge is reserved for Phase 5 per-app network isolation.
pub enum NetworkMode {
#[default]
Default,
/// Host networking is only for companion/proxy containers that need to
/// reach node-local daemons directly. It cannot be combined with
/// PublishPort because Podman discards port mappings in host mode.
Host,
/// A user-defined podman network — quadlet creates the container
/// attached to it. The network must already exist (orchestrator's
@@ -86,11 +90,11 @@ impl RestartPolicy {
}
}
/// Container healthcheck wired through to systemd via `Notify=healthy`.
/// When set, `systemctl start <name>.service` blocks until the container's
/// own healthcheck reports green — eliminating the "container up but RPC
/// not ready" race that the orchestrator currently papers over with
/// post-start polling.
/// Container healthcheck wired through to Podman.
/// Systemd should consider the unit started once the container process is
/// running; health probes are app status, not boot ordering. Blocking
/// `systemctl start` on health made boot reconciliation hang when an image
/// lacked the probe helper binary, even though the service itself was live.
///
/// Ranges roughly mirror the manifest's HealthCheck struct: `cmd` is the
/// shell form (`/usr/bin/curl -fsS http://localhost:8332/health` etc.),
@@ -120,8 +124,8 @@ pub struct QuadletUnit {
pub extra_podman_args: Vec<String>,
pub depends_on: Vec<String>,
/// Phase 3.4: when present the rendered unit emits HealthCmd=,
/// HealthInterval=, HealthTimeout=, HealthRetries=, AND Notify=healthy
/// so systemctl start blocks on a green health probe.
/// HealthInterval=, HealthTimeout=, and HealthRetries= for Podman's
/// health state without blocking systemd's start job.
pub health: Option<HealthSpec>,
// Backend-manifest extensions (Phase 3.1). Companion units leave
// these defaulted; the renderer skips empty/false directives so a
@@ -130,6 +134,7 @@ pub struct QuadletUnit {
pub environment: Vec<String>,
pub devices: Vec<String>,
pub add_hosts: Vec<(String, String)>,
pub network_aliases: Vec<String>,
pub entrypoint: Option<Vec<String>>,
pub command: Vec<String>,
pub read_only_root: bool,
@@ -172,11 +177,16 @@ impl QuadletUnit {
// must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never");
match &self.network {
NetworkMode::Default => {}
NetworkMode::Host => {
let _ = writeln!(s, "Network=host");
}
NetworkMode::Bridge(net) => {
let _ = writeln!(s, "Network={net}");
for alias in &self.network_aliases {
let _ = writeln!(s, "NetworkAlias={alias}");
let _ = writeln!(s, "PodmanArgs=--network-alias={alias}");
}
}
}
if let Some(user) = &self.user {
@@ -234,11 +244,6 @@ impl QuadletUnit {
let _ = writeln!(s, "HealthInterval={}", h.interval);
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
let _ = writeln!(s, "HealthRetries={}", h.retries);
// Notify=healthy: systemd treats the unit as "started" only
// after the first green health probe. Start ordering
// (Requires=/After=) downstream of this unit therefore
// doesn't fire until the app is actually serving requests.
let _ = writeln!(s, "Notify=healthy");
}
if let Some(ep) = &self.entrypoint {
// Quadlet's Exec= replaces the image entrypoint+cmd. When
@@ -261,20 +266,6 @@ impl QuadletUnit {
// OnFailure (clean stops stay stopped).
let _ = writeln!(s, "Restart={}", self.restart_policy.as_systemd());
let _ = writeln!(s, "RestartSec=10");
if self.health.is_some() {
// Notify=healthy makes systemd block the unit's "started"
// state on the first green health probe. systemd's default
// TimeoutStartSec is 90s — but `HealthInterval=30s` ×
// `HealthRetries=3` is itself 90s, so the timeout fires the
// moment the third probe MIGHT succeed. On .228 every backend
// (lnd, electrumx, fedimint, btcpay-server, mempool-api,
// bitcoin-knots) timed out at 90s and systemd terminated the
// container while it was still warming up. Bump to 600s — long
// enough for slow-starting backends (electrumx replays its
// index, lnd unlocks its wallet) without being so long that a
// truly stuck unit hangs forever.
let _ = writeln!(s, "TimeoutStartSec=600");
}
let _ = writeln!(s);
let _ = writeln!(s, "[Install]");
let _ = writeln!(s, "WantedBy=default.target");
@@ -290,11 +281,15 @@ fn shell_join(parts: &[String]) -> String {
parts
.iter()
.map(|p| {
let p = p.replace(['\r', '\n'], " ");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
let escaped = p.replace('\\', "\\\\").replace('"', "\\\"");
let escaped = p
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "$$");
format!("\"{escaped}\"")
} else {
p.clone()
p
}
})
.collect::<Vec<_>>()
@@ -323,7 +318,7 @@ impl QuadletUnit {
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
_ => match app.container.network.as_deref() {
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
_ => NetworkMode::Host,
_ => NetworkMode::Default,
},
};
@@ -366,6 +361,7 @@ impl QuadletUnit {
environment: app.environment.clone(),
devices: app.devices.clone(),
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
network_aliases: vec![name.to_string()],
entrypoint: app.container.entrypoint.clone(),
command: app.container.custom_args.clone(),
read_only_root: app.security.readonly_root,
@@ -378,12 +374,11 @@ impl QuadletUnit {
/// Translate the manifest's HealthCheck shape into a HealthSpec the
/// renderer understands. Returns None when the manifest's health spec
/// is malformed or unsupported — we'd rather skip Notify=healthy than
/// emit a broken HealthCmd that fails the unit start forever.
/// is malformed or unsupported rather than emitting a broken HealthCmd.
///
/// Supported shapes:
/// - type: tcp, endpoint: "host:port" → `nc -z host port`
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → curl
/// - type: tcp, endpoint: "host:port" → skipped for Quadlet units
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → wget/curl
/// - type: cmd, endpoint: "<shell command>" → `<shell command>` verbatim
///
/// For type=http we accept the endpoint with or without scheme; manifests
@@ -393,13 +388,11 @@ impl QuadletUnit {
/// that pasted on .228 2026-05-02 and failed every probe.
fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<HealthSpec> {
let cmd = match hc.check_type.as_str() {
"tcp" => {
let endpoint = hc.endpoint.as_deref()?;
let (host, port) = endpoint.rsplit_once(':')?;
// nc is in busybox/coreutils on every base image we ship.
// The -z flag does a "scan" that exits 0 on connect, 1 otherwise.
format!("nc -z {host} {port}")
}
// A generic TCP probe inside arbitrary app images is not reliable:
// some images lack nc, some lack bash /dev/tcp, and failures leave
// Podman/systemd health in a false-negative state. Keep TCP readiness
// checks in the host-side lifecycle/status layer instead.
"tcp" => return None,
"http" => {
let endpoint = hc.endpoint.as_deref()?.trim();
// Accept either bare host:port or a full URL. If endpoint
@@ -426,9 +419,14 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
let path = hc.path.as_deref().unwrap_or("/");
format!("{url}{path}")
};
// -fsS: fail on non-2xx, silent except on error, show errors.
// -m 5: per-request timeout matches the default manifest timeout.
format!("curl -fsS -m 5 {final_url}")
// Images vary wildly: SearXNG ships wget but no curl, while some
// Node images ship neither. Use whichever probe helper exists and
// skip Podman health if the image has none; host-side lifecycle
// probes still verify reachability.
format!(
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 {0}; else exit 0; fi",
final_url
)
}
"cmd" => hc.endpoint.as_deref()?.to_string(),
_ => return None,
@@ -528,6 +526,70 @@ pub async fn enable_now(service: &str) -> Result<()> {
Ok(())
}
/// Restart a generated Quadlet service after rewriting a known-bad unit.
pub async fn restart_service(service: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["--user", "restart", service])
.status()
.await
.with_context(|| format!("spawn systemctl --user restart {service}"))?;
if !status.success() {
return Err(anyhow!(
"systemctl --user restart {service} exited {status}"
));
}
Ok(())
}
/// Stop a generated Quadlet service without removing its unit file.
pub async fn stop_service(service: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["--user", "stop", service])
.status()
.await
.with_context(|| format!("spawn systemctl --user stop {service}"))?;
if !status.success() {
return Err(anyhow!("systemctl --user stop {service} exited {status}"));
}
Ok(())
}
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
unit_body.contains("Notify=healthy")
|| unit_body.contains("TimeoutStartSec=600")
|| unit_body.contains("HealthCmd=nc -z")
}
pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
directive_values(old_body, "HealthCmd=") != directive_values(new_body, "HealthCmd=")
}
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
let old_ports = directive_values(old_body, "PublishPort=");
let new_ports = directive_values(new_body, "PublishPort=");
old_ports != new_ports
}
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
let old_aliases = directive_values(old_body, "NetworkAlias=");
let new_aliases = directive_values(new_body, "NetworkAlias=");
old_aliases != new_aliases
}
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
let old_exec = directive_values(old_body, "Exec=");
let new_exec = directive_values(new_body, "Exec=");
old_exec != new_exec
}
fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
unit_body
.lines()
.filter_map(|line| line.trim().strip_prefix(prefix))
.map(str::to_string)
.collect()
}
/// Stop + remove a quadlet unit and its on-disk file. Best-effort:
/// errors stop only the destructive write at the failing step so a
/// partial removal doesn't leave a quadlet file pointing at a service
@@ -706,6 +768,14 @@ mod tests {
);
// Embedded quotes must escape:
assert_eq!(shell_join(&[r#"say "hi""#.into()]), r#""say \"hi\"""#);
assert_eq!(
shell_join(&[
"sh".into(),
"-lc".into(),
"if true; then\n exec app;\nfi".into()
]),
"sh -lc \"if true; then exec app; fi\""
);
}
#[test]
@@ -829,6 +899,64 @@ app:
assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
}
#[test]
fn from_manifest_uses_default_network_for_isolated_ports() {
let yaml = r#"
app:
id: searxng
name: SearXNG
version: 1.0.0
container:
image: searxng:latest
ports:
- host: 8888
container: 8080
protocol: tcp
security:
network_policy: isolated
"#;
let m = AppManifest::parse(yaml).expect("manifest must parse");
let s = QuadletUnit::from_manifest(&m, "searxng").render();
assert!(s.contains("PublishPort=8888:8080/tcp"));
assert!(!s.contains("Network=host"));
}
#[test]
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
let yaml = r#"
app:
id: grafana
name: Grafana
version: 10.2.0
container:
image: grafana/grafana:10.2.0
data_uid: "472:472"
volumes:
- type: bind
source: /var/lib/archipelago/grafana
target: /var/lib/grafana
options: [rw]
resources:
memory_limit: 1g
"#;
let m = AppManifest::parse(yaml).unwrap();
assert_eq!(m.app.container.data_uid.as_deref(), Some("472:472"));
let u = QuadletUnit::from_manifest(&m, "grafana");
assert_eq!(u.memory_mb, Some(1024));
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(
u.bind_mounts[0].host,
PathBuf::from("/var/lib/archipelago/grafana")
);
assert_eq!(
u.bind_mounts[0].container,
PathBuf::from("/var/lib/grafana")
);
assert!(!u.bind_mounts[0].read_only);
}
#[test]
fn from_manifest_marks_ro_volumes_read_only() {
let yaml = r#"
@@ -881,28 +1009,25 @@ app:
u.name = "lnd".into();
u.image = "x:1".into();
u.health = Some(HealthSpec {
cmd: "nc -z localhost 10009".into(),
cmd: "probe-ready".into(),
interval: "30s".into(),
timeout: "5s".into(),
retries: 3,
});
let s = u.render();
assert!(s.contains("HealthCmd=nc -z localhost 10009"));
assert!(s.contains("HealthCmd=probe-ready"));
assert!(s.contains("HealthInterval=30s"));
assert!(s.contains("HealthTimeout=5s"));
assert!(s.contains("HealthRetries=3"));
assert!(s.contains("Notify=healthy"));
// Notify=healthy needs a long-enough TimeoutStartSec or systemd
// kills the unit before the first probe can pass — observed live
// on .228 2026-05-02 across all six backends.
assert!(s.contains("TimeoutStartSec=600"), "got: {s}");
assert!(!s.contains("Notify=healthy"));
assert!(!s.contains("TimeoutStartSec=600"));
}
#[test]
fn render_skips_health_directives_when_absent() {
// No health spec → no Notify=healthy, no HealthCmd, no
// TimeoutStartSec override (default 90s applies). Companions rely
// on this so their rendered bytes stay unchanged.
// No health spec → no Notify=healthy, no HealthCmd, no TimeoutStartSec
// override. Companions rely on this so their rendered bytes stay
// unchanged.
let s = sample_unit().render();
assert!(!s.contains("HealthCmd="));
assert!(!s.contains("Notify=healthy"));
@@ -921,9 +1046,7 @@ app:
timeout: "5s".into(),
retries: 3,
};
let h = translate_health_check(&tcp).expect("tcp must translate");
assert_eq!(h.cmd, "nc -z localhost 10009");
assert_eq!(h.retries, 3);
assert!(translate_health_check(&tcp).is_none());
let http = HealthCheck {
check_type: "http".into(),
@@ -934,7 +1057,10 @@ app:
retries: 5,
};
let h = translate_health_check(&http).expect("http must translate");
assert_eq!(h.cmd, "curl -fsS -m 5 http://localhost:8080/health");
assert_eq!(
h.cmd,
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/health; else exit 0; fi"
);
let cmdck = HealthCheck {
check_type: "cmd".into(),
@@ -947,8 +1073,7 @@ app:
let h = translate_health_check(&cmdck).expect("cmd must translate");
assert_eq!(h.cmd, "/usr/local/bin/probe.sh");
// Unknown type → None (renderer skips Notify=healthy entirely
// rather than emit a broken HealthCmd that hangs the unit start).
// Unknown type → None rather than emit a broken HealthCmd.
let bad = HealthCheck {
check_type: "exec".into(),
endpoint: Some("foo".into()),
@@ -959,7 +1084,7 @@ app:
};
assert!(translate_health_check(&bad).is_none());
// Malformed tcp endpoint → None (no port separator).
// TCP is skipped entirely for Quadlet units.
let badtcp = HealthCheck {
check_type: "tcp".into(),
endpoint: Some("hostonly".into()),
@@ -987,7 +1112,7 @@ app:
retries: 3,
};
let h = translate_health_check(&with_scheme).expect("with-scheme must translate");
assert_eq!(h.cmd, "curl -fsS -m 5 http://localhost:8175/");
assert!(h.cmd.contains("http://localhost:8175/"));
assert!(!h.cmd.contains("http://http://"), "got: {}", h.cmd);
let with_https = HealthCheck {
@@ -1000,7 +1125,7 @@ app:
};
let h = translate_health_check(&with_https).expect("https must translate");
// Endpoint already has /health → don't append the default "/".
assert_eq!(h.cmd, "curl -fsS -m 5 https://example.local/health");
assert!(h.cmd.contains("https://example.local/health"));
}
#[test]
@@ -1021,12 +1146,46 @@ app:
"#;
let m = AppManifest::parse(yaml).unwrap();
let u = QuadletUnit::from_manifest(&m, "lnd");
let h = u.health.as_ref().expect("health should be populated");
assert_eq!(h.cmd, "nc -z localhost 10009");
assert_eq!(h.interval, "15s");
assert_eq!(h.timeout, "4s");
assert_eq!(h.retries, 5);
assert!(u.render().contains("Notify=healthy"));
assert!(u.health.is_none());
assert!(!u.render().contains("Notify=healthy"));
}
#[test]
fn publish_ports_changed_detects_port_binding_drift() {
let old = "[Container]\nPublishPort=9735:9735/tcp\nPublishPort=8080:8080/tcp\n";
let new = "[Container]\nPublishPort=9735:9735/tcp\nPublishPort=18080:8080/tcp\n";
assert!(publish_ports_changed(old, new));
assert!(!publish_ports_changed(new, new));
}
#[test]
fn network_aliases_changed_detects_service_discovery_drift() {
let old = "[Container]\nNetwork=archy-net\n";
let new = "[Container]\nNetwork=archy-net\nNetworkAlias=bitcoin-knots\n";
assert!(network_aliases_changed(old, new));
assert!(!network_aliases_changed(new, new));
}
#[test]
fn shell_join_escapes_dollars_for_container_runtime_expansion() {
let rendered = shell_join(&["sh".into(), "-lc".into(), "echo ${BITCOIN_RPC_PASS}".into()]);
assert!(rendered.contains("$${BITCOIN_RPC_PASS}"));
}
#[test]
fn exec_changed_detects_command_drift() {
let old = "[Container]\nExec=sh -lc \"echo ${BITCOIN_RPC_PASS}\"\n";
let new = "[Container]\nExec=sh -lc \"echo $${BITCOIN_RPC_PASS}\"\n";
assert!(exec_changed(old, new));
assert!(!exec_changed(new, new));
}
#[test]
fn health_cmd_changed_detects_probe_drift() {
let old = "[Container]\nHealthCmd=curl -fsS http://localhost:8080/\n";
let new = "[Container]\nHealthCmd=if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi\n";
assert!(health_cmd_changed(old, new));
assert!(!health_cmd_changed(new, new));
}
#[test]
@@ -1063,6 +1222,8 @@ app:
assert!(body.contains("ContainerName=lnd"));
assert!(body.contains("Image=registry/lnd:latest"));
assert!(body.contains("Network=archy-net"));
assert!(body.contains("NetworkAlias=lnd"));
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
assert!(body.contains("PublishPort=10009:10009/tcp"));
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
+54 -60
View File
@@ -11,8 +11,9 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Output;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use std::time::{Duration, Instant};
use tokio::fs;
use tracing::{info, warn};
@@ -189,15 +190,9 @@ pub async fn remove_pid_marker(data_dir: &Path) {
/// Save a snapshot of currently running containers to disk.
/// Called periodically so we know what to restart after a crash.
pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
let output = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("podman")
.args(["ps", "--format", "json"])
.output(),
)
.await
.context("podman ps timed out (30s)")?
.context("Failed to run podman ps")?;
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["ps", "--format", "json"]);
let output = command_with_timeout(cmd, Duration::from_secs(30), "podman ps").await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -277,22 +272,23 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
);
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
tokio::process::Command::new("podman")
.args(["start", &record.name])
.output(),
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["start", &record.name]);
let result = command_with_timeout(
cmd,
Duration::from_secs(timeout_secs),
&format!("podman start {}", record.name),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
Ok(output) if output.status.success() => {
info!("Successfully restarted container: {}", record.name);
report.recovered += 1;
started = true;
break;
}
Ok(Ok(output)) => {
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"Failed to restart container {} (attempt {}): {}",
@@ -301,20 +297,13 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
stderr.trim()
);
}
Ok(Err(e)) => {
Err(e) => {
warn!(
"Failed to execute podman start for {} (attempt {}): {}",
record.name,
attempt + 1,
e
);
}
Err(_) => {
warn!(
"Timeout starting container {} ({}s, attempt {})",
"Failed to start container {} ({}s, attempt {}): {}",
record.name,
timeout_secs,
attempt + 1
attempt + 1,
e
);
}
}
@@ -345,26 +334,22 @@ fn is_process_running(pid: u32) -> bool {
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
/// Skips containers that the user intentionally stopped via the UI.
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
let output = match tokio::time::timeout(
std::time::Duration::from_secs(60),
tokio::process::Command::new("podman")
.args([
"ps",
"-a",
"--filter",
"status=exited",
"--filter",
"status=created",
"--format",
"{{.Names}}",
])
.output(),
)
.await
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
"-a",
"--filter",
"status=exited",
"--filter",
"status=created",
"--format",
"{{.Names}}",
]);
let output = match command_with_timeout(cmd, Duration::from_secs(60), "podman ps stopped").await
{
Ok(result) => result,
Err(_) => {
warn!("Timeout listing stopped containers (60s)");
Ok(output) => output,
Err(e) => {
warn!("Failed listing stopped containers: {}", e);
return RecoveryReport {
total: 0,
recovered: 0,
@@ -373,13 +358,14 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
}
};
let all_names: Vec<String> = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
let all_names: Vec<String> = if output.status.success() {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect(),
_ => Vec::new(),
.collect()
} else {
Vec::new()
};
if all_names.is_empty() {
@@ -481,27 +467,35 @@ pub async fn run_boot_reconciliation() {
return;
}
info!("Running boot reconciliation...");
let result = tokio::time::timeout(
std::time::Duration::from_secs(300),
tokio::process::Command::new(script).output(),
)
.await;
let cmd = tokio::process::Command::new(script);
let result = command_with_timeout(cmd, Duration::from_secs(300), script).await;
match result {
Ok(Ok(output)) if output.status.success() => {
Ok(output) if output.status.success() => {
info!("Boot reconciliation complete");
}
Ok(Ok(output)) => {
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"Boot reconciliation had failures: {}",
stderr.chars().take(500).collect::<String>()
);
}
Ok(Err(e)) => warn!("Boot reconciliation failed to run: {}", e),
Err(_) => warn!("Boot reconciliation timed out (300s)"),
Err(e) => warn!("Boot reconciliation failed: {}", e),
}
}
async fn command_with_timeout(
mut cmd: tokio::process::Command,
timeout: Duration,
description: &str,
) -> Result<Output> {
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))?
.with_context(|| format!("Failed to run {}", description))
}
/// Spawn a background task that periodically saves the container snapshot.
pub fn spawn_snapshot_task(data_dir: PathBuf) {
tokio::spawn(async move {
+55 -3
View File
@@ -216,6 +216,7 @@ struct ContainerHealth {
name: String,
app_id: String,
state: String,
podman_health: Option<String>,
healthy: bool,
}
@@ -447,18 +448,40 @@ async fn check_containers() -> Vec<ContainerHealth> {
.unwrap_or("unknown")
.to_lowercase();
let healthy = state == "running";
let podman_health = parse_podman_health(c, &state);
let healthy = state == "running" && podman_health.as_deref() != Some("unhealthy");
Some(ContainerHealth {
name,
app_id,
state,
podman_health,
healthy,
})
})
.collect()
}
fn parse_podman_health(c: &serde_json::Value, state: &str) -> Option<String> {
c.get("Status")
.and_then(|v| v.as_str())
.and_then(parse_health_from_status)
.or_else(|| {
c.get("State")
.and_then(|v| v.get("Health"))
.and_then(|v| v.get("Status"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| state.contains("unhealthy").then(|| "unhealthy".to_string()))
}
fn parse_health_from_status(status: &str) -> Option<String> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| status[start + 1..end].to_string())
}
/// Try to restart a container.
async fn restart_container(name: &str) -> bool {
info!("Auto-restarting unhealthy container: {}", name);
@@ -590,6 +613,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
"Skipping orphan container (not in package_data): {}",
container.name
);
let before = data.notifications.len();
data.notifications.retain(|n| {
n.app_id.as_deref() != Some(&container.app_id)
&& !n.title.contains(&container.app_id)
});
if data.notifications.len() != before {
state_changed = true;
}
continue;
}
@@ -628,8 +659,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
}
continue;
}
// Handle exited, stopped, AND created state containers
if container.state == "exited"
// Handle exited, stopped, created, and Podman-unhealthy running containers.
if container.podman_health.as_deref() == Some("unhealthy")
|| container.state == "exited"
|| container.state == "stopped"
|| container.state == "created"
{
@@ -874,6 +906,7 @@ mod tests {
name: "archy-bitcoin-knots".to_string(),
app_id: "bitcoin-knots".to_string(),
state: "running".to_string(),
podman_health: Some("healthy".to_string()),
healthy: true,
};
assert!(health.healthy);
@@ -888,6 +921,7 @@ mod tests {
name: "archy-mempool-web".to_string(),
app_id: "mempool-web".to_string(),
state: "exited".to_string(),
podman_health: None,
healthy: false,
};
assert!(!health.healthy);
@@ -977,18 +1011,21 @@ mod tests {
name: "indeedhub-postgres".into(),
app_id: "indeedhub-postgres".into(),
state: "running".into(),
podman_health: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-api".into(),
app_id: "indeedhub-api".into(),
state: "exited".into(),
podman_health: None,
healthy: false,
},
];
@@ -998,6 +1035,7 @@ mod tests {
name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
assert!(!deps_are_running("indeedhub-api", &partial));
@@ -1009,6 +1047,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
assert!(deps_are_running("lnd", &core));
@@ -1017,6 +1056,7 @@ mod tests {
name: "bitcoin-knots".into(),
app_id: "bitcoin-knots".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
assert!(deps_are_running("fedimint", &knots));
@@ -1025,6 +1065,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
healthy: false,
}];
assert!(!deps_are_running("electrumx", &stopped));
@@ -1036,6 +1077,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
healthy: true,
}];
@@ -1050,6 +1092,7 @@ mod tests {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
healthy: false,
}];
@@ -1121,4 +1164,13 @@ mod tests {
tracker.record("test", 100_000_000);
assert!(tracker.check_leak("test").is_none());
}
#[test]
fn podman_unhealthy_makes_running_container_unhealthy() {
let c = serde_json::json!({ "Status": "Up 5 minutes (unhealthy)" });
assert_eq!(
parse_podman_health(&c, "running").as_deref(),
Some("unhealthy")
);
}
}
+2 -1
View File
@@ -27,7 +27,8 @@ const RESERVED_PORTS: &[u16] = &[
3001, 3002, // Gitea, Uptime Kuma
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, 8084, // NPM
8443, // FIPS TCP fallback
8444, 8084, // NPM
];
/// Start of range for allocating web app ports when preferred is taken.
+11 -1
View File
@@ -838,7 +838,7 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
/// 600s. 2× that gives the spawned task ample margin before we assume it
/// died (panic, OOM, process restart mid-stop) and fall back to the
/// scanner's authoritative view. Applies to all transitional variants.
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(1200);
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
/// Returns true if `state` is one of the transitional variants that a
/// `spawn_transitional`-style background task owns. While such a state is
@@ -1029,6 +1029,16 @@ async fn scan_and_update_packages(
absence_tracker.remove(&id);
continue;
}
// Quadlet-generated units run containers with `--rm`, so a
// clean user stop removes the Podman record. Keep the package
// visible as Stopped while the user-stopped marker exists so
// package.start can recreate it via systemd/Quadlet.
if entry.state == crate::data_model::PackageState::Stopped
&& user_stopped.contains(&id)
{
absence_tracker.remove(&id);
continue;
}
}
let count = absence_tracker.entry(id.clone()).or_insert(0);
*count += 1;
+5 -2
View File
@@ -713,11 +713,14 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// verified over the complete file at the end of each component, so a
/// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let state = load_state(data_dir).await?;
let mut state = load_state(data_dir).await?;
if state.available_update.is_none() {
state = check_for_updates(data_dir).await?;
}
let manifest = state
.available_update
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No update available to download"))?;
.ok_or_else(|| anyhow::anyhow!("No update is available to download"))?;
let staging_dir = data_dir.join("update-staging");
fs::create_dir_all(&staging_dir)
+4 -1
View File
@@ -46,6 +46,7 @@ pub struct ContainerStatus {
pub enum ContainerState {
Created,
Running,
Stopping,
Stopped,
Exited,
Paused,
@@ -57,6 +58,7 @@ impl From<&str> for ContainerState {
match s.to_lowercase().as_str() {
"created" => ContainerState::Created,
"running" => ContainerState::Running,
"stopping" => ContainerState::Stopping,
"stopped" => ContainerState::Stopped,
"exited" => ContainerState::Exited,
"paused" => ContainerState::Paused,
@@ -120,6 +122,7 @@ impl PodmanClient {
"penpot" => "http://localhost:9001",
"nextcloud" => "http://localhost:8085",
"vaultwarden" => "http://localhost:8082",
"gitea" => "http://localhost:3001",
"jellyfin" => "http://localhost:8096",
"photoprism" => "http://localhost:2342",
"immich_server" | "immich" => "http://localhost:2283",
@@ -130,7 +133,7 @@ impl PodmanClient {
"fedimint" | "fedimintd" => "http://localhost:8175",
"fedimint-gateway" => "http://localhost:8176",
"nostr-rs-relay" => "http://localhost:18081",
"indeedhub" => "http://localhost:7777",
"indeedhub" => "http://localhost:7778",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
+189 -7
View File
@@ -3,8 +3,12 @@ use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
use anyhow::{Context, Result};
use async_trait::async_trait;
use std::process::Command;
use std::time::Duration;
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]
pub trait ContainerRuntime: Send + Sync {
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
/// 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.
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");
cmd.args(args);
cmd.output()
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| {
format!(
"podman {} timed out after {}s",
args.join(" "),
timeout.as_secs()
)
})?
.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]
@@ -84,19 +106,72 @@ impl ContainerRuntime for PodmanRuntime {
}
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<()> {
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<()> {
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> {
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>> {
@@ -142,7 +217,9 @@ impl ContainerRuntime for PodmanRuntime {
async fn build_image(&self, config: &BuildConfig) -> Result<()> {
let args = build_args_for_podman(config);
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() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -211,6 +288,103 @@ fn parse_podman_ps_json(stdout: &[u8]) -> Result<Vec<ContainerStatus>> {
.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> {
ports
.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())
}
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.
///
/// Extracted so it can be unit-tested without actually invoking podman.
+317
View File
@@ -0,0 +1,317 @@
# Chat Transcript And Working Notes
Date: 2026-05-02
This file captures the current chat context, decisions, progress, and next steps so work can continue from another device/session.
## User Request
The user asked to continue hardening Archipelago app/container lifecycle, then asked multiple times to save the plan/progress/next steps and finally to save the entire chat to Markdown.
Key user constraints and corrections:
- Continue if next steps are clear; ask only if blocked.
- Exhaustively harden app/container lifecycle before release.
- Preserve data during destructive lifecycle testing unless explicitly instructed otherwise.
- Do not rely on `/app/...` proxy paths for app launch/testing. The user corrected: “we never use paths only ports.”
- LND/Electrum wallet-connect tests must validate real connection details and QR, including Tor.
## Earlier Progress Summary
Before the latest work, the project already had substantial lifecycle hardening in progress:
- Remote lifecycle harness exists at `tests/lifecycle/remote-lifecycle.sh`.
- `.198` SSH works with `/home/archipelago/.ssh/id_ed25519`.
- `.228` RPC works, but SSH is blocked with `Permission denied (publickey,password)`.
- Multiple backend release binaries were built and deployed to `.198` with backups in `/usr/local/bin/archipelago.bak-*`.
- Fixed stale package scanner state recovery from `Removing -> Running` when a container is actually live.
- Fixed startup ordering so crash recovery runs before BootReconciler.
- Removed dangerous automatic Podman runtime directory deletion on `podman info` failure.
- Narrowed generic crash recovery to safe legacy containers.
- Fixed companion reconciliation on install/start/restart.
- Fixed uninstall/reinstall behavior so uninstall disables manifest apps instead of deleting manifest availability, and reinstall re-enables them.
- Fixed LND config generation/repair:
- `bitcoin.active=true`
- `bitcoin.mainnet=true`
- `bitcoin.node=bitcoind`
- `bitcoind.rpchost=bitcoin-knots:8332`
- sudo fallback for writing container-owned config paths.
- `.198` had previously passed focused lifecycle for `filebrowser`, `bitcoin-knots`, and a looser LND launch test.
## Major Files Touched In This Session
- `docs/CONTAINER_LIFECYCLE_HANDOFF.md`
- `docs/CHAT_TRANSCRIPT_2026-05-02.md`
- `tests/lifecycle/remote-lifecycle.sh`
- `core/archipelago/src/container/lnd.rs`
- `core/archipelago/src/container/companion.rs`
- `core/archipelago/src/container/prod_orchestrator.rs`
- `core/archipelago/src/container/docker_packages.rs`
- `core/container/src/podman_client.rs`
- `core/archipelago/src/port_allocator.rs`
- `apps/lnd-ui/manifest.yml`
- `neode-ui/src/views/appSession/appSessionConfig.ts`
- `neode-ui/src/stores/container.ts`
- `neode-ui/src/stores/appLauncher.ts`
- `neode-ui/src/views/appDetails/appDetailsData.ts`
- nginx config/snippet files under `scripts/` and `image-recipe/`
## LND Wallet Bootstrap Investigation
Initial strict LND probe failed because `/lnd-connect-info` could not read `admin.macaroon`:
```text
Failed to read LND admin macaroon — is LND installed?
direct: Permission denied (os error 13)
sudo: cat: /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon: No such file or directory
```
LND logs showed the wallet was uninitialized/locked:
```text
Waiting for wallet encryption password. Use lncli create...
```
Tests showed `lncli create` is interactive and does not support `--stdin`:
```text
[lncli] flag provided but not defined: -stdin
```
`lncli unlock --stdin` is supported, so the final approach was:
- Use LND REST unlocker endpoints for new wallet creation.
- Use `lncli unlock --stdin` only for an existing wallet.
- Treat “wallet already exists” from REST as a signal to unlock.
- Use sudo-aware checks/reads for wallet artifacts because LND data directories are container-owned and `0700`.
Implemented in `core/archipelago/src/container/lnd.rs`:
- `ensure_wallet_initialized()`
- `file_exists_as_root()`
- `read_file_as_root()`
- `init_wallet_via_rest()`
- `get_lnd_unlocker_json()`
- `post_lnd_unlocker_json()`
- `unlock_existing_wallet()`
- `wait_for_admin_macaroon()`
- `lnd_getinfo_ready()`
Focused Rust test passes:
```bash
cd /home/archipelago/Projects/archy/core
cargo test -p archipelago --bin archipelago lnd
```
Result:
```text
7 passed; 0 failed
```
## LND UI Port Collision
The strict LND UI test then failed with `502`.
Investigation found a real port collision:
- `nostr-rs-relay` uses host `8081`.
- Old `archy-lnd-ui` also used host `8081`.
- nginx `/app/lnd/` proxy also pointed at `8081`.
Fix implemented:
- Move LND UI companion to host port `18083`, container port `80`.
- Keep `nostr-rs-relay` on `8081`.
- Update app metadata/routing to `18083`.
- Update tests to expect direct port launch.
Important correction from user:
```text
we never use paths only ports, how many times do you need to be told
```
Action taken after correction:
- Stop validating through `/app/lnd/` and `/app/electrumx/` in the lifecycle harness.
- Switch `launch_url_for()` to direct app ports.
- Switch app session resolver to direct `http://host:port` launch, even from HTTPS parent pages.
- Remove use of `HTTPS_PROXY_PATHS[id]` in `resolveAppUrl()`.
Direct-port LND audit command:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=lnd tests/lifecycle/remote-lifecycle.sh
```
Result:
```text
### 192.168.1.198 iteration 1 / 1 ###
lnd state=running
all checks passed
```
The audit now validates `http://192.168.1.198:18083/`, not `/app/lnd/`.
## Lifecycle Harness Changes
`tests/lifecycle/remote-lifecycle.sh` changes made:
- Normalize package states with `ascii_downcase` because API returned `Running`.
- Direct port launch URLs:
- LND: `http://${ARCHY_HOST}:18083/`
- Electrum/Electrs: `http://${ARCHY_HOST}:50002/`
- Bitcoin UI: `http://${ARCHY_HOST}:8334/`
- Other apps mapped to direct ports where known.
- LND probe checks:
- `Connect Your Wallet`
- `id="lndQrBox"`
- `id="connHost"`
- `value="rest-tor"`
- `value="grpc-tor"`
- `value="rest-local"`
- `value="grpc-local"`
- `Copy lndconnect URI`
- `/lnd-connect-info` cert, macaroon, ports, and Tor onion.
- Electrum probe checks:
- local QR container and address field
- Tor QR container and onion field
- port `50001`
- QR renderer
- direct `http://${ARCHY_HOST}:50002/qrcode.js`
- `/electrs-status` Tor onion.
- Full lifecycle now fails immediately on any failed phase with `|| return 1` so a later reinstall cannot mask a failed restart/probe.
## Deployments To `.198`
Several release builds were made and deployed:
```bash
cd /home/archipelago/Projects/archy/core
cargo build -p archipelago --bin archipelago --release
```
Deploy pattern:
```bash
scp -i /home/archipelago/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
/home/archipelago/Projects/archy/core/target/release/archipelago \
archipelago@192.168.1.198:/tmp/archipelago.new
ssh -i /home/archipelago/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
archipelago@192.168.1.198 \
"sudo cp /usr/local/bin/archipelago /usr/local/bin/archipelago.bak-<timestamp> && \
sudo install -m 0755 /tmp/archipelago.new /usr/local/bin/archipelago && \
sudo systemctl restart archipelago.service && \
systemctl is-active archipelago.service"
```
Latest deploy returned:
```text
active
```
## `.198` Current Observations
After forcing LND package restart, companion reconciliation succeeded:
```text
nostr-rs-relay Up ... 0.0.0.0:8081->8080/tcp
lnd Up ... 0.0.0.0:8080->8080/tcp, 0.0.0.0:9735->9735/tcp, 0.0.0.0:10009->10009/tcp
archy-lnd-ui Up ... 0.0.0.0:18083->80/tcp
```
Direct UI test from `.198` returned `200`:
```bash
curl -i http://127.0.0.1:18083/
```
Strict direct-port LND audit is green:
```text
lnd state=running
all checks passed
```
## Full LND Lifecycle Status
Full direct-port lifecycle was started:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=lnd ARCHY_FULL_LIFECYCLE=1 tests/lifecycle/remote-lifecycle.sh
```
It reached:
```text
### 192.168.1.198 iteration 1 / 1 ###
== lnd: install ==
== lnd: stop ==
```
Then the user aborted the command while asking to save memory/transcript.
The next continuation point is to rerun full LND direct-port lifecycle from scratch and inspect the stop phase if it hangs/fails.
## Handoff File
A durable handoff file was also created:
```text
docs/CONTAINER_LIFECYCLE_HANDOFF.md
```
It contains the plan, progress, current blockers, and next steps.
## Immediate Next Steps
1. Rerun full strict LND direct-port lifecycle:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=lnd ARCHY_FULL_LIFECYCLE=1 tests/lifecycle/remote-lifecycle.sh
```
2. If it hangs/fails at `stop`, inspect package runtime stop path and logs:
```bash
ssh -i /home/archipelago/.ssh/id_ed25519 -o StrictHostKeyChecking=no archipelago@192.168.1.198 \
'journalctl -u archipelago.service -n 260 --no-pager | egrep -i "package\.(stop|start|restart|install|uninstall)|lnd|companion|error|failed" | sed -n "1,220p"; podman ps -a --format "{{.Names}} {{.Status}} {{.Ports}}" | egrep "lnd|nostr" || true'
```
3. If stop is unreliable, inspect/fix:
- `core/archipelago/src/api/rpc/package/runtime.rs`
- `core/archipelago/src/container/prod_orchestrator.rs`
Likely causes to check:
- Reconciler restarting LND while stop is expected.
- State scanner reporting stale `running`.
- Companion handling interfering with parent app state.
- Async lifecycle returning before actual stop completes.
4. Once LND full lifecycle is green, run Electrum strict lifecycle with direct port `50002`:
```bash
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=electrumx ARCHY_FULL_LIFECYCLE=1 tests/lifecycle/remote-lifecycle.sh
```
5. Continue with app groups after LND/Electrum:
- `filebrowser`
- `bitcoin-knots`
- `lnd`
- `electrumx`
- `mempool`
- `btcpay-server`
- `fedimint`
- remaining catalog apps.
## Important Instruction To Preserve
Use ports only for app launch/testing. Do not add or rely on `/app/...` path proxy launch behavior unless the user explicitly changes this requirement.
File diff suppressed because it is too large Load Diff
+3
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
- 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.
@@ -211,10 +211,15 @@ check_tools() {
fi
fi
# Ensure insecure registry config for Archipelago app registry (HTTP)
if [ "$CONTAINER_CMD" = "podman" ]; then
# Ensure insecure registry config for Archipelago app registries that are
# intentionally served over HTTP during ISO builds.
if [[ "$CONTAINER_CMD" == podman* ]]; then
mkdir -p /etc/containers/registries.conf.d
cat > /etc/containers/registries.conf.d/archipelago.conf <<'REGCONF'
[[registry]]
location = "146.59.87.168:3000"
insecure = true
[[registry]]
location = "git.tx1138.com"
insecure = true
@@ -227,6 +232,15 @@ check_tools
mkdir -p "$WORK_DIR"
mkdir -p "$OUTPUT_DIR"
container_pull() {
local image="$1"
if [[ "$CONTAINER_CMD" == podman* && "$image" == 146.59.87.168:3000/* ]]; then
$CONTAINER_CMD pull --tls-verify=false --platform "$CONTAINER_PLATFORM" "$image"
else
$CONTAINER_CMD pull --platform "$CONTAINER_PLATFORM" "$image"
fi
}
# =============================================================================
# STEP 1: Build complete root filesystem using Docker
# =============================================================================
@@ -1289,7 +1303,7 @@ if [ "$UNBUNDLED" = "1" ]; then
echo " ✅ Using cached: $CORE_FILE"
else
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
if $CONTAINER_CMD pull --platform $CONTAINER_PLATFORM "$CORE_IMAGE"; then
if container_pull "$CORE_IMAGE"; then
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
echo " ⚠️ Failed to save $CORE_IMAGE"
@@ -1367,7 +1381,7 @@ echo "$CONTAINER_IMAGES" | while read -r image filename; do
echo " ✅ Using cached: $filename"
else
echo " Pulling $image ($CONTAINER_PLATFORM)..."
if $CONTAINER_CMD pull --platform $CONTAINER_PLATFORM "$image"; then
if container_pull "$image"; then
echo " Saving $filename..."
if $CONTAINER_CMD save "$image" -o "$tarpath" 2>/dev/null; then
echo " ✅ Saved: $(du -h "$tarpath" | cut -f1)"
@@ -3456,9 +3470,9 @@ echo ""
echo "Step 6: Creating bootable ISO..."
if [ "$UNBUNDLED" = "1" ]; then
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-unbundled-${ARCH}.iso"
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}.iso"
else
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${ARCH}.iso"
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}.iso"
fi
# Use the proven MBR code for hybrid USB boot
@@ -56,7 +56,6 @@ listen=1
# RPC
rpcuser=$RPC_USER
rpcpassword=$RPC_PASS
rpcbind=0.0.0.0
rpcallowip=10.0.0.0/8
rpcallowip=172.16.0.0/12
rpcallowip=192.168.0.0/16
@@ -110,7 +110,7 @@ ExecStart=/usr/bin/podman run --name lnd \\
-v ${LND_DATA}:/data/.lnd:Z \\
-p 9735:9735 \\
-p 10009:10009 \\
-p 8080:8080 \\
-p 18080:8080 \\
docker.io/lightninglabs/lnd:v0.18.0-beta \\
--configfile=/data/.lnd/lnd.conf
ExecStop=/usr/bin/podman stop lnd
+4 -3
View File
@@ -7,10 +7,11 @@ Wants=network-online.target
Type=notify
User=archipelago
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
Environment="XDG_RUNTIME_DIR=/run/user/1000"
# + 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'
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
@@ -26,13 +27,13 @@ ProtectSystem=strict
ProtectHome=no
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
# 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
# (WireGuard peer management). Scoped via sudoers to only archipelago-wg.
NoNewPrivileges=no
PrivateDevices=no
SupplementaryGroups=dialout debian-tor
SupplementaryGroups=dialout debian-tor fips
# Syscall and network restrictions — safe on Debian 13 (systemd 256+)
# which respects NoNewPrivileges=no as an explicit override for seccomp filters
@@ -156,6 +156,16 @@ server {
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 10s;
proxy_read_timeout 10s;
proxy_send_timeout 5s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /electrs-status {
proxy_pass http://127.0.0.1:5678/electrs-status;
proxy_http_version 1.1;
@@ -969,6 +979,16 @@ server {
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_connect_timeout 10s;
proxy_read_timeout 10s;
proxy_send_timeout 5s;
error_page 502 503 = @backend_unavailable;
error_page 504 = @backend_timeout;
}
location /electrs-status {
proxy_pass http://127.0.0.1:5678/electrs-status;
proxy_http_version 1.1;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.52-alpha",
"version": "1.7.55-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.52-alpha",
"version": "1.7.55-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.52-alpha",
"version": "1.7.55-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+70 -12
View File
@@ -115,7 +115,12 @@
"author": "BotFights",
"category": "community",
"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",
@@ -126,7 +131,12 @@
"author": "Gitea",
"category": "development",
"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",
@@ -138,7 +148,12 @@
"category": "data",
"tier": "core",
"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",
@@ -150,7 +165,11 @@
"category": "data",
"tier": "recommended",
"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",
@@ -162,7 +181,11 @@
"category": "data",
"tier": "recommended",
"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",
@@ -184,7 +207,11 @@
"author": "Jellyfin",
"category": "data",
"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",
@@ -206,7 +233,12 @@
"author": "Home Assistant",
"category": "home",
"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",
@@ -218,7 +250,12 @@
"category": "data",
"tier": "recommended",
"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",
@@ -230,7 +267,13 @@
"category": "networking",
"tier": "recommended",
"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",
@@ -242,7 +285,13 @@
"category": "data",
"tier": "recommended",
"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",
@@ -253,7 +302,12 @@
"author": "PhotoPrism",
"category": "data",
"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",
@@ -264,7 +318,11 @@
"author": "Nextcloud",
"category": "data",
"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"]
}
}
]
}
@@ -16,6 +16,13 @@ export interface MarketplaceAppInfo {
dockerImage: string
/** External web URL for iframe-based web apps (no container needed) */
webUrl?: string
containerConfig?: {
ports?: string[]
volumes?: string[]
env?: string[]
command?: string
args?: string[]
}
}
// Simple in-memory store for the current marketplace app
@@ -39,6 +46,7 @@ export function useMarketplaceApp() {
s9pkUrl: app.s9pkUrl ?? '',
dockerImage: app.dockerImage ?? '',
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
}
}
+2 -1
View File
@@ -14,7 +14,7 @@ export const useAppStore = defineStore('app', () => {
// Writable refs — delegate reads and writes to the sub-stores
const { isAuthenticated, isLoading, error } = storeToRefs(auth)
const { data, isConnected, isReconnecting } = storeToRefs(sync)
const { data, isConnected, isReconnecting, hasLoadedInitialData } = storeToRefs(sync)
// Read-only computed — delegate to sub-stores
const { serverInfo, packages, peerHealth, uiData } = storeToRefs(sync)
@@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => {
data,
isConnected,
isReconnecting,
hasLoadedInitialData,
// Sync computed (read-only)
serverInfo,
+7
View File
@@ -11,6 +11,7 @@ export const useSyncStore = defineStore('sync', () => {
const data = ref<DataModel | null>(null)
const isConnected = ref(false)
const isReconnecting = ref(false)
const hasLoadedInitialData = ref(false)
let isWsSubscribed = false
let isWsConnecting = false
@@ -47,12 +48,14 @@ export const useSyncStore = defineStore('sync', () => {
if (update?.type === 'initial' && update?.data) {
if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend')
data.value = update.data
hasLoadedInitialData.value = true
isConnected.value = true
isReconnecting.value = false
}
// Handle real backend format: {rev: 0, data: {...}}
else if (update?.data && update?.rev !== undefined) {
data.value = update.data
hasLoadedInitialData.value = true
isConnected.value = true
isReconnecting.value = false
}
@@ -90,6 +93,7 @@ export const useSyncStore = defineStore('sync', () => {
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
if (freshState?.data) {
data.value = freshState.data
hasLoadedInitialData.value = true
}
} catch {
// Non-fatal: WebSocket patches will still work
@@ -149,11 +153,13 @@ export const useSyncStore = defineStore('sync', () => {
theme: 'dark',
},
}
hasLoadedInitialData.value = false
}
/** Reset sync state on logout — called by auth store */
function resetOnLogout(): void {
data.value = null
hasLoadedInitialData.value = false
isWsSubscribed = false
wsClient.disconnect()
isConnected.value = false
@@ -165,6 +171,7 @@ export const useSyncStore = defineStore('sync', () => {
data,
isConnected,
isReconnecting,
hasLoadedInitialData,
// Computed
serverInfo,
+14
View File
@@ -185,6 +185,20 @@ input[type="radio"]:active + * {
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 {
display: inline-flex;
+1 -2
View File
@@ -267,8 +267,7 @@ const canLaunch = computed(() => {
if (!pkg.value) return false
if (isWebOnly.value) return true
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main)
const isRunning = pkg.value.state === 'running'
return hasUI && isRunning
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => [
+12 -15
View File
@@ -1,5 +1,5 @@
<template>
<div class="pb-6">
<div class="apps-view pb-6">
<!-- Nav header -- tabs + categories + search -->
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search -->
@@ -40,19 +40,14 @@
</div>
<!-- Loading Skeleton -->
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
<div class="flex-1">
<div class="h-5 w-32 bg-white/10 rounded mb-2"></div>
<div class="h-4 w-48 bg-white/5 rounded mb-3"></div>
<div class="h-6 w-20 bg-white/5 rounded"></div>
</div>
</div>
<div class="mt-4 flex gap-2">
<div class="flex-1 h-9 bg-white/5 rounded-lg"></div>
</div>
<div v-if="isLoadingApps" class="text-center py-16 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-lg font-semibold text-white mb-2">Loading apps</h3>
<p class="text-white/60 text-sm">Checking the latest app status before showing launch controls.</p>
</div>
</div>
@@ -222,6 +217,8 @@ const packages = computed(() => {
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
// Connection error state
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
@@ -230,7 +227,7 @@ onMounted(() => {
appsAnimationDone = true
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
+3 -1
View File
@@ -436,9 +436,11 @@ async function installCommunityApp(app: MarketplaceApp) {
router.push('/dashboard/apps').catch(() => {})
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({
method: 'package.install',
params: { id: app.id, dockerImage: app.dockerImage, version: app.version },
params: installParams,
timeout: 15000,
})
} catch (err) {
+8 -10
View File
@@ -382,6 +382,7 @@ import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMa
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher'
import { useToast } from '../composables/useToast'
import { handleImageError } from './apps/appsConfig'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
@@ -530,11 +531,6 @@ onBeforeUnmount(() => {
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() {
if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
@@ -611,13 +607,15 @@ async function installApp() {
try {
if (app.value.dockerImage) {
// 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({
method: 'package.install',
params: {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
},
params: installParams,
timeout: 15000,
})
} else {
+15 -1
View File
@@ -739,6 +739,10 @@ function showStatus(msg: string, isError = false) {
setTimeout(() => { statusMessage.value = '' }, 8000)
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
async function loadStatus() {
try {
const res = await rpcClient.call<{
@@ -814,7 +818,17 @@ async function downloadUpdate() {
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
} 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)
} finally {
clearInterval(progressInterval)
@@ -34,7 +34,7 @@
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
</p>
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center justify-center gap-3">
<button
v-if="!mustOpenNewTab"
@click="$emit('refresh')"
+1 -2
View File
@@ -145,8 +145,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
return !!hasUI && canLaunchState
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
}
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
+2
View File
@@ -6,6 +6,8 @@ export interface ContainerConfig {
ports?: string[]
volumes?: string[]
env?: string[]
command?: string
args?: string[]
}
export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
@@ -18,6 +18,13 @@ export interface MarketplaceApp {
source?: string
url?: string
s9pkUrl?: string
containerConfig?: {
ports?: string[]
volumes?: string[]
env?: string[]
command?: string
args?: string[]
}
trustScore?: number
trustTier?: string
relayCount?: number
+16 -17
View File
@@ -1,28 +1,27 @@
{
"version": "1.7.52-alpha",
"release_date": "2026-05-05",
"version": "1.7.55-alpha",
"release_date": "2026-05-13",
"changelog": [
"Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.",
"Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.",
"Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.",
"Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`."
"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."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.52-alpha",
"new_version": "1.7.52-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.52-alpha/archipelago",
"sha256": "fc47c3bc42f67472252cb854bb03e200a92929ab38aeac519422704486af18d4",
"size_bytes": 42342368
"current_version": "1.7.55-alpha",
"new_version": "1.7.55-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.55-alpha/archipelago",
"sha256": "f2caba778f63c7435431fb1b95cf6470bd43c4769ebe6adee2cbd2721707a663",
"size_bytes": 42580880
},
{
"name": "archipelago-frontend-1.7.52-alpha.tar.gz",
"current_version": "1.7.52-alpha",
"new_version": "1.7.52-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.52-alpha/archipelago-frontend-1.7.52-alpha.tar.gz",
"sha256": "329e57a0491e91966afcd5a82f5c00920657695b01ecc6c9e99c6814b44abf29",
"size_bytes": 166462645
"name": "archipelago-frontend-1.7.55-alpha.tar.gz",
"current_version": "1.7.55-alpha",
"new_version": "1.7.55-alpha",
"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": "fe37425aad25724db49ec2be8d602342cfdc5fb99f08b4a3f04709751a3ed560",
"size_bytes": 166464949
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "1.7.55-alpha",
"release_date": "2026-05-13",
"changelog": [
"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."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.55-alpha",
"new_version": "1.7.55-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.55-alpha/archipelago",
"sha256": "f2caba778f63c7435431fb1b95cf6470bd43c4769ebe6adee2cbd2721707a663",
"size_bytes": 42580880
},
{
"name": "archipelago-frontend-1.7.55-alpha.tar.gz",
"current_version": "1.7.55-alpha",
"new_version": "1.7.55-alpha",
"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": "fe37425aad25724db49ec2be8d602342cfdc5fb99f08b4a3f04709751a3ed560",
"size_bytes": 166464949
}
]
}
-2
View File
@@ -266,10 +266,8 @@ server=1
prune=550
rpcuser=archipelago
rpcpassword=$BTC_RPC_PASS
rpcbind=0.0.0.0
rpcallowip=127.0.0.1/32
rpcallowip=10.88.0.0/16
rpcport=8332
listen=1
printtoconsole=1
BCONF
+3 -3
View File
@@ -202,7 +202,7 @@ load_spec_lnd() {
SPEC_NAME="lnd"
SPEC_IMAGE="${LND_IMAGE}"
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_MEMORY="$(mem_limit lnd)"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_RAW"
@@ -493,7 +493,7 @@ load_spec_nginx-proxy-manager() {
reset_spec
SPEC_NAME="nginx-proxy-manager"
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_MEMORY="$(mem_limit nginx-proxy-manager)"
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
@@ -551,7 +551,7 @@ load_spec_archy-lnd-ui() {
reset_spec
SPEC_NAME="archy-lnd-ui"
SPEC_IMAGE="localhost/lnd-ui:local"
SPEC_PORTS="8081:80"
SPEC_PORTS="18083:80"
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"
+12 -5
View File
@@ -18,7 +18,7 @@ RELEASE_DATE=""
OUTPUT_FILE="manifest.json"
BACKEND_BINARY=""
FRONTEND_ARCHIVE=""
BASE_URL="https://github.com/archipelago-os/releases/releases/download"
BASE_URL="http://146.59.87.168:3000/lfg2025/archy/releases/download"
usage() {
echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]"
@@ -29,7 +29,7 @@ usage() {
echo " --output FILE Output manifest path [default: manifest.json]"
echo " --backend PATH Path to backend binary [default: auto-detect]"
echo " --frontend PATH Path to frontend archive [default: auto-detect]"
echo " --base-url URL Base download URL [default: GitHub releases]"
echo " --base-url URL Base download URL [default: Gitea release attachments]"
exit 1
}
@@ -109,8 +109,15 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
fi
done
fi
if [ -f "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" ]; then
mkdir -p "$RUNTIME_DIR/image-recipe/configs"
echo " Including runtime nginx-archipelago.conf"
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
fi
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '._*' -o -name '*.log' \) -delete
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
# Force world-readable perms on every entry BEFORE tar, so the
# archive's internal mode bits are 755/644 regardless of what
# the staging dir's umask gave us. Without this, mktemp -d
@@ -272,6 +279,6 @@ cat "$OUTPUT_FILE"
echo ""
echo "Next steps:"
echo " 1. Review the manifest above"
echo " 2. Upload artifacts to: $BASE_URL/v$VERSION/"
echo " 3. Upload manifest.json to the releases repo main branch"
echo " 2. Upload artifacts to Gitea release v$VERSION"
echo " 3. Commit manifest.json to releases/manifest.json on main"
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
+11 -4
View File
@@ -161,7 +161,9 @@ else
fi
echo "[5/7] Creating release manifest..."
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/release-manifest.json" 2>&1 | grep -v "^$"
mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6/7] Committing version bump..."
git -C "$PROJECT_ROOT" add \
@@ -169,6 +171,7 @@ git -C "$PROJECT_ROOT" add \
neode-ui/package.json \
neode-ui/package-lock.json \
CHANGELOG.md \
releases/manifest.json \
release-manifest.json \
2>/dev/null || true
@@ -183,13 +186,17 @@ echo ""
echo "Artifacts:"
echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: release-manifest.json"
echo " - Release manifest: releases/manifest.json"
echo " - Release manifest copy: release-manifest.json"
echo " - Git tag: v${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Review: git log --oneline -5"
echo " 2. Push to gitea-local and gitea-vps2:"
echo " 2. Create Gitea release v${VERSION} and upload artifacts:"
echo " archipelago"
echo " archipelago-frontend-${VERSION}.tar.gz"
echo " 3. Push to gitea-local and gitea-vps2:"
echo " git push gitea-local main --tags && git push gitea-vps2 main --tags"
echo " 3. Verify manifest is live on both mirrors:"
echo " 4. Verify manifest is live on both mirrors:"
echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
echo " curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
+3 -3
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 \
--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 \
-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 \
$LND_IMAGE
fi
@@ -914,7 +914,7 @@ LNDCONF
--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 \
--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/letsencrypt:/etc/letsencrypt \
$NPM_IMAGE
@@ -944,7 +944,7 @@ LNDCONF
fi
case \$ui in
bitcoin-ui) PORT_ARG=''; NET_ARG='--network host' ;;
lnd-ui) PORT_ARG='-p 8081:80'; NET_ARG='' ;;
lnd-ui) PORT_ARG='-p 18083:80'; NET_ARG='' ;;
electrs-ui) PORT_ARG=''; NET_ARG='--network host' ;;
esac
if [ -d \"$TARGET_DIR/docker/\$ui\" ]; then
+3 -3
View File
@@ -1003,14 +1003,14 @@ BUILDINFO_EOF
if false; then # Legacy app installation removed — kept for reference in git history
progress "Rebuilding LND UI"
if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t lnd-ui:local . || docker build --no-cache -t lnd-ui:local .)" 2>&1 | tail -12 | sed 's/^/ /'; then
echo " Recreating LND UI container (port 8081)..."
echo " Recreating LND UI container (port 18083)..."
ssh $SSH_OPTS "$TARGET_HOST" '
DOCKER=podman
command -v podman >/dev/null 2>&1 || DOCKER=docker
for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do
[ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null
done
$DOCKER run -d --name archy-lnd-ui -p 8081:80 --memory=256m --restart unless-stopped lnd-ui:local
$DOCKER run -d --name archy-lnd-ui -p 18083:80 --memory=256m --restart unless-stopped lnd-ui:local
' 2>&1 | sed 's/^/ /' || true
fi
@@ -1642,7 +1642,7 @@ LNDCONF
$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 \
--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 \
"$LND_IMAGE"
echo " LND created"
Regular → Executable
+9 -11
View File
@@ -308,9 +308,7 @@ if [ ! -f "$BITCOIN_CONF" ] || ! grep -q "^rpcauth=" "$BITCOIN_CONF" 2>/dev/null
# rpcauth: salted hash only — no plaintext password in config or CLI
rpcauth=${RPCAUTH}
server=1
rpcbind=0.0.0.0
rpcallowip=0.0.0.0/0
rpcport=8332
listen=1
printtoconsole=1
# ZMQ publishers for LND and other services that need real-time block/tx notifications
@@ -466,8 +464,8 @@ done
for dir in mempool mysql-mempool; do
[ -d "/var/lib/archipelago/$dir" ] && chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null
done
# Grafana: container UID 472 → host UID 100472
[ -d /var/lib/archipelago/grafana ] && chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null
# Grafana: chown inside podman's user namespace so container UID 472 can write SQLite.
[ -d /var/lib/archipelago/grafana ] && podman unshare chown -R 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
log "UID mapping done"
# ── Memory limits per container ──────────────────────────────────────────
@@ -902,7 +900,7 @@ LNDCONF
$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 \
--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 \
"$LND_IMAGE" 2>>"$LOG" || true
fi
@@ -997,9 +995,9 @@ track_container "homeassistant"
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q grafana; then
log "Creating Grafana..."
mkdir -p /var/lib/archipelago/grafana
chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
podman unshare chown -R 472:472 /var/lib/archipelago/grafana 2>/dev/null || true
$DOCKER run -d --name grafana --restart unless-stopped \
--health-cmd="curl -sf http://localhost:3000/api/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--health-cmd="test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit grafana) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges:true \
@@ -1153,7 +1151,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q 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 \
--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/letsencrypt:/etc/letsencrypt \
"${NPM_IMAGE}" 2>>"$LOG" || true
@@ -1249,9 +1247,9 @@ for ui in bitcoin-ui lnd-ui electrs-ui; do
fi
case $ui in
# UI containers use --network host so they can proxy to localhost services
# Internal nginx ports: bitcoin-ui=8334, electrs-ui=50002, lnd-ui=80 (host 8081)
# Internal nginx ports: bitcoin-ui=8334, electrs-ui=50002, lnd-ui=80 (host 18083)
bitcoin-ui) PORT_ARG=""; NET_ARG="--network host"; REG_IMG="${BITCOIN_UI_IMAGE}" ;;
lnd-ui) PORT_ARG="-p 8081:80"; NET_ARG=""; REG_IMG="${LND_UI_IMAGE}" ;;
lnd-ui) PORT_ARG="-p 18083:80"; NET_ARG=""; REG_IMG="${LND_UI_IMAGE}" ;;
electrs-ui) PORT_ARG=""; NET_ARG="--network host"; REG_IMG="${ELECTRS_UI_IMAGE}" ;;
esac
CONTAINER_NAME="archy-$ui"
@@ -1286,7 +1284,7 @@ if [ ! -f "$SERVICES_JSON" ]; then
cat > "$SERVICES_JSON" <<'SJSON'
{"services":[
{"name":"archipelago","local_port":80,"enabled":true},
{"name":"lnd","local_port":8081,"enabled":true},
{"name":"lnd","local_port":18083,"enabled":true},
{"name":"btcpay","local_port":23000,"enabled":true},
{"name":"mempool","local_port":4080,"enabled":true},
{"name":"fedimint","local_port":8175,"enabled":true}
+13 -6
View File
@@ -303,13 +303,20 @@ if [ -n "$UI_REBUILD_LIST" ]; then
fi
fi
# Update systemd service if changed
if [ -f "$REPO_DIR/image-recipe/configs/archipelago.service" ]; then
if ! diff -q "$REPO_DIR/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service &>/dev/null; then
sudo cp "$REPO_DIR/image-recipe/configs/archipelago.service" /etc/systemd/system/archipelago.service
sudo systemctl daemon-reload
ok "Systemd service updated"
# Update systemd services if changed
SYSTEMD_UNITS_CHANGED=false
for unit in archipelago.service archipelago-fips.service; do
src="$REPO_DIR/image-recipe/configs/$unit"
dst="/etc/systemd/system/$unit"
[ -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
done
if [ "$SYSTEMD_UNITS_CHANGED" = "true" ]; then
sudo systemctl daemon-reload
fi
# Keep the doctor timer/service current too. Container uptime fixes rely on
+1 -1
View File
@@ -7,7 +7,7 @@ Each service gets its own .onion address. Tor runs in a container with host netw
| Service | LAN Port | Tor Hidden Service Dir |
|-----------|----------|-------------------------------|
| Archipelago | 80 | hidden_service_archipelago |
| LND UI | 8081 | hidden_service_lnd |
| LND UI | 18083 | hidden_service_lnd |
| BTCPay | 23000 | hidden_service_btcpay |
| Mempool | 4080 | hidden_service_mempool |
| Fedimint | 8175 | hidden_service_fedimint |
+53 -4
View File
@@ -10,8 +10,10 @@ required_containers=(
"bitcoin-knots"
"electrumx"
"lnd"
"archy-mempool-db"
"mempool-api"
"mempool"
"filebrowser"
"archy-bitcoin-ui"
"archy-lnd-ui"
"archy-electrs-ui"
@@ -26,6 +28,18 @@ container_running() {
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" {
local names
names="$(podman_names)"
@@ -43,9 +57,29 @@ container_running() {
}
@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 ]
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" {
@@ -59,7 +93,17 @@ PY
}
@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 ]
}
@@ -79,6 +123,11 @@ PY
}
@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 ]
}
+100 -7
View File
@@ -20,6 +20,8 @@ ARCHY_APPS="${ARCHY_APPS:-}"
ARCHY_TIMEOUT="${ARCHY_TIMEOUT:-900}"
ARCHY_STABILITY_SECONDS="${ARCHY_STABILITY_SECONDS:-5}"
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
echo "ARCHY_HOST and ARCHY_PASSWORD are required" >&2
@@ -37,6 +39,7 @@ fi
BASE_URL="${ARCHY_SCHEME}://${ARCHY_HOST}"
SESSION=""
CSRF=""
CATALOG_FILE=""
ALL_APPS=(
bitcoin-knots
@@ -65,6 +68,69 @@ ALL_APPS=(
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() {
case "$1" in
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
(.macaroon_base64url | type == "string" and length > 50) and
(.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$")) and
(.rest_port == 8080) and
(.rest_port == 18080) and
(.grpc_port == 10009)
' >/dev/null || {
echo "lnd connect info incomplete: $info" >&2
@@ -371,12 +437,29 @@ probe_electrum_wallet_connect() {
}
install_app() {
local app="$1" image params
image=$(image_for "$app")
params=$(jq -nc --arg id "$app" --arg img "$image" '{id:$id,dockerImage:$img,version:"latest"}')
local app="$1" app_json image params
app_json=$(catalog_app_json "$app" || true)
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
}
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; }
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; }
@@ -405,6 +488,11 @@ full_lifecycle_app() {
echo "skip bitcoin-core: set ARCHY_ALLOW_BITCOIN_SWAP=1 to test mutually-exclusive Bitcoin implementation"
return 0
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 =="
install_app "$app" || return 1
wait_not_installing "$app" || return 1
@@ -451,11 +539,16 @@ full_lifecycle_app() {
apps=()
if [[ -n "$ARCHY_APPS" ]]; then
IFS=',' read -r -a apps <<< "$ARCHY_APPS"
fetch_catalog || true
elif [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then
echo "ARCHY_FULL_LIFECYCLE=1 requires ARCHY_APPS to avoid installing unqualified catalog apps" >&2
exit 2
fetch_catalog
mapfile -t apps < <(catalog_app_ids)
else
apps=("${ALL_APPS[@]}")
if fetch_catalog; then
mapfile -t apps < <(catalog_app_ids)
else
apps=("${ALL_APPS[@]}")
fi
fi
rpc_login