Compare commits
@@ -77,3 +77,10 @@ web/
|
||||
|
||||
# Resilience harness reports (generated, contains session cookies)
|
||||
scripts/resilience/reports/
|
||||
|
||||
# Codex / pnpm / python caches / editor backups
|
||||
.codex
|
||||
.pnpm-store/
|
||||
**/__pycache__/
|
||||
*.bak
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
|
||||
## v1.7.49-alpha (2026-04-30)
|
||||
|
||||
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||
"icon": "/assets/img/app-icons/electrumx.webp",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
|
||||
@@ -27,12 +27,6 @@ app:
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/mempool/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
|
||||
environment:
|
||||
- FRONTEND_HTTP_PORT=8080
|
||||
- BACKEND_MAINNET_HTTP_HOST=mempool-api
|
||||
|
||||
@@ -47,6 +47,8 @@ app:
|
||||
- NBXPLORER_BIND=0.0.0.0:32838
|
||||
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- NBXPLORER_BTCRPCUSER=archipelago
|
||||
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
|
||||
- NBXPLORER_NOAUTH=1
|
||||
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -14,14 +14,22 @@ app:
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot — 4GB on full nodes, 1GB on pruned. Container
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
exec bitcoind -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}";
|
||||
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}";
|
||||
else
|
||||
exec bitcoind -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="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -14,14 +14,22 @@ app:
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot — 4GB on full nodes, 1GB on pruned. Container
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
exec bitcoind -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}";
|
||||
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}";
|
||||
else
|
||||
exec bitcoind -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="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -13,6 +13,9 @@ app:
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BTCPAY_DB_PASS
|
||||
secret_file: btcpay-db-password
|
||||
derived_env:
|
||||
- key: BTCPAY_HOST
|
||||
template: "{{HOST_IP}}:23000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
@@ -46,7 +49,6 @@ app:
|
||||
environment:
|
||||
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
||||
- BTCPAY_PROTOCOL=http
|
||||
- BTCPAY_HOST=127.0.0.1:23000
|
||||
- BTCPAY_CHAINS=btc
|
||||
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
||||
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
||||
@@ -68,3 +70,12 @@ app:
|
||||
lightning_integration:
|
||||
payment_processing: false
|
||||
invoice_management: true
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: BTCPay Server dashboard
|
||||
type: ui
|
||||
port: 23000
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- >-
|
||||
@@ -18,7 +19,7 @@ app:
|
||||
secret_file: bitcoin-rpc-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- storage: 50Gi
|
||||
|
||||
@@ -58,3 +59,4 @@ app:
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
|
||||
@@ -12,16 +12,16 @@ app:
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: FEDI_HASH
|
||||
secret_file: fedimint-gateway-hash
|
||||
data_uid: "100000:100000"
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
|
||||
@@ -16,7 +16,7 @@ app:
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "100000:100000"
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
@@ -52,7 +52,7 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
- FM_BITCOIND_URL=http://host.archipelago:8332
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
|
||||
@@ -29,9 +29,8 @@ environment:
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
|
||||
|
||||
# Gitea hardcodes X-Frame-Options: SAMEORIGIN which blocks iframe embedding.
|
||||
# Container binds to internal_port (3001), nginx proxies public port (3000)
|
||||
# stripping the X-Frame-Options header so the app works in Archipelago's iframe.
|
||||
# Gitea hardcodes X-Frame-Options: SAMEORIGIN, so Archipelago opens it in a
|
||||
# new tab on host port 3001 instead of embedding it in an iframe.
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: "http://127.0.0.1:3001"
|
||||
|
||||
@@ -27,7 +27,7 @@ app:
|
||||
apparmor_profile: grafana
|
||||
|
||||
ports:
|
||||
- host: 3001
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@@ -40,12 +40,12 @@ app:
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
- GF_SERVER_ROOT_URL=http://localhost:3001
|
||||
- GF_SERVER_ROOT_URL=http://localhost:3000
|
||||
- GF_INSTALL_PLUGINS=
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:3001
|
||||
endpoint: http://localhost:3000
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
|
||||
pull_policy: always # Pull from registry; falls back to local build
|
||||
network: indeedhub-net
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -38,6 +39,12 @@ app:
|
||||
- type: tmpfs
|
||||
target: /app/.next/cache
|
||||
options: [rw,noexec,nosuid,size=128m]
|
||||
- type: tmpfs
|
||||
target: /run
|
||||
options: [rw,nosuid,nodev,size=16m]
|
||||
- type: tmpfs
|
||||
target: /var/cache/nginx
|
||||
options: [rw,nosuid,nodev,size=32m]
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
@@ -25,9 +25,9 @@ app:
|
||||
network_policy: bridge
|
||||
|
||||
# Bridge networking via archy-net. Container nginx listens on 80;
|
||||
# host nginx proxies /app/lnd/ -> 127.0.0.1:8081 -> container:80.
|
||||
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
|
||||
ports:
|
||||
- host: 8081
|
||||
- host: 18083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
@@ -37,7 +37,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8081
|
||||
endpoint: http://127.0.0.1:18083
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -15,7 +15,7 @@ app:
|
||||
secret_file: mempool-db-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- app_id: electrumx
|
||||
version: ">=1.18.0"
|
||||
@@ -66,3 +66,4 @@ app:
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
image: scsibug/nostr-rs-relay:0.8.9
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi # For event storage
|
||||
@@ -34,7 +35,7 @@ app:
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nostr-relay
|
||||
target: /app/db
|
||||
target: /usr/src/app/db
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
app:
|
||||
id: searxng
|
||||
name: SearXNG
|
||||
version: 2024.1.0
|
||||
version: latest
|
||||
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
||||
|
||||
container:
|
||||
image: searxng/searxng:2024.1.0
|
||||
image_signature: cosign://...
|
||||
image: 146.59.87.168:3000/lfg2025/searxng:latest
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
|
||||
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.50-alpha"
|
||||
version = "1.7.53-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.50-alpha"
|
||||
version = "1.7.53-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -379,11 +379,22 @@ 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()) {
|
||||
let health = orchestrator
|
||||
.health(app_id)
|
||||
.await
|
||||
.context("Failed to get container health")?;
|
||||
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 {
|
||||
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container health"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +460,14 @@ fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||
push("mempool-electrs");
|
||||
push("electrumx");
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
push("mempool");
|
||||
push("archy-mempool-web");
|
||||
}
|
||||
"immich" => {
|
||||
push("immich");
|
||||
push("immich_server");
|
||||
}
|
||||
_ => push(app_id),
|
||||
}
|
||||
|
||||
@@ -469,6 +488,8 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
"lnd-ui" => push("archy-lnd-ui"),
|
||||
"electrs-ui" => push("archy-electrs-ui"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||
"immich" => push("immich_server"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -511,3 +532,14 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
|
||||
"running": running,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||
let v = inspect_container_state_value(name).await?;
|
||||
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()),
|
||||
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||
other => Some(format!("unknown:{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ impl RpcHandler {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
@@ -530,6 +531,11 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
|
||||
@@ -98,21 +98,14 @@ impl RpcHandler {
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let mut last_status: Option<fips::FipsStatus> = None;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
let after = loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected {
|
||||
last_status = Some(s);
|
||||
break;
|
||||
if s.anchor_connected || std::time::Instant::now() >= deadline {
|
||||
break s;
|
||||
}
|
||||
last_status = Some(s);
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = last_status.unwrap_or_else(|| before.clone());
|
||||
};
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
|
||||
@@ -410,16 +410,6 @@ async fn set_package_state_and_clear_uninstall_stage(
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a package entry from state. Used for install-failure cleanup
|
||||
/// (since there's no pre-state to revert to — the entry was created
|
||||
/// speculatively when we flipped to Installing).
|
||||
async fn remove_package_entry(state_manager: &StateManager, package_id: &str) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if data.package_data.remove(package_id).is_some() {
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the container scanner to run immediately and wait for it to finish
|
||||
/// (with a 2s timeout). Used by install/update success paths so the fresh
|
||||
/// manifest — with `interfaces.main.ui` populated from the now-running
|
||||
|
||||
@@ -70,19 +70,30 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
||||
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
|
||||
vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
]
|
||||
}
|
||||
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
|
||||
// RPC port. NO NET_RAW — bitcoind never opens raw sockets and
|
||||
// dropping it removes a class of intra-pod spoofing capability.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// LND additionally needs NET_RAW for TLS certificate generation
|
||||
// (netlink interface enumeration during `lnd --tlscertpath` first run).
|
||||
// Fedimint inherits the same set because the gateway also enumerates
|
||||
// network interfaces on startup.
|
||||
"lnd" | "fedimint" | "fedimint-gateway" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
],
|
||||
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||
"vaultwarden" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
@@ -153,11 +164,10 @@ 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")
|
||||
@@ -298,16 +308,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
let archy = format!("archy-{}", package_id);
|
||||
|
||||
match package_id {
|
||||
// Bitcoin: multiple historical names
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
// Bitcoin variants share the UI but not the backend process. Keep
|
||||
// backend names precise so stopping one implementation does not clear
|
||||
// stop markers or issue podman operations for the other.
|
||||
"bitcoin" | "bitcoin-knots" => vec![
|
||||
"bitcoin-knots".into(),
|
||||
"bitcoin".into(),
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-knots".into(),
|
||||
"archy-bitcoin".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
"bitcoin-core" => vec![
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-core".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
// LND + UI
|
||||
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||
// Electrumx: multiple aliases
|
||||
@@ -366,6 +383,15 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"penpot-exporter".into(),
|
||||
"penpot-frontend".into(),
|
||||
],
|
||||
"indeedhub" => vec![
|
||||
"indeedhub-postgres".into(),
|
||||
"indeedhub-redis".into(),
|
||||
"indeedhub-minio".into(),
|
||||
"indeedhub-relay".into(),
|
||||
"indeedhub-api".into(),
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -400,11 +426,30 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::all_container_names;
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
let core = all_container_names("bitcoin-core");
|
||||
assert!(core.contains(&"bitcoin-core".to_string()));
|
||||
assert!(!core.contains(&"bitcoin-knots".to_string()));
|
||||
|
||||
let knots = all_container_names("bitcoin-knots");
|
||||
assert!(knots.contains(&"bitcoin-knots".to_string()));
|
||||
assert!(!knots.contains(&"bitcoin-core".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
/// Caller must validate package_id before calling.
|
||||
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
let base = "/var/lib/archipelago";
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
|
||||
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
@@ -788,7 +833,11 @@ pub(super) async fn get_app_config(
|
||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
Some(vec![
|
||||
"--".to_string(),
|
||||
"node".to_string(),
|
||||
"server/server.js".to_string(),
|
||||
]),
|
||||
),
|
||||
"tailscale" => (
|
||||
vec!["8240:8240".to_string()],
|
||||
@@ -803,7 +852,7 @@ pub(super) async fn get_app_config(
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"tailscale web --listen 0.0.0.0:8240 & exec tailscaled".to_string(),
|
||||
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
]),
|
||||
),
|
||||
"fedimint" => (
|
||||
@@ -964,8 +1013,8 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
)
|
||||
}
|
||||
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
|
||||
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
|
||||
// Gitea listens on container port 3000 and is launched directly on
|
||||
// host port 3001 because it blocks iframe embedding.
|
||||
"gitea" => (
|
||||
vec!["3001:3000".to_string(), "2222:22".to_string()],
|
||||
vec![
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use crate::data_model::{PackageDataEntry, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
@@ -8,6 +10,13 @@ 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"];
|
||||
|
||||
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
|
||||
)
|
||||
}
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
@@ -15,13 +24,43 @@ pub(super) struct RunningDeps {
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
pub(super) fn detect_running_deps_from_package_data(
|
||||
packages: &HashMap<String, PackageDataEntry>,
|
||||
) -> RunningDeps {
|
||||
let is_running = |names: &[&str]| {
|
||||
names.iter().any(|name| {
|
||||
packages
|
||||
.get(*name)
|
||||
.map(|pkg| pkg.state == PackageState::Running)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
};
|
||||
|
||||
RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
let dep_check = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out checking running containers"))?
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
if !dep_check.status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to check running containers: {}",
|
||||
String::from_utf8_lossy(&dep_check.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
@@ -76,6 +115,65 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||
/// running and RPC-reachable but still leave them stuck with closed ports.
|
||||
pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Result<()> {
|
||||
if !requires_unpruned_bitcoin(package_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "package-install-prune-check",
|
||||
"method": "getblockchaininfo",
|
||||
"params": [],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.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 Some(result) = json.get("result") else {
|
||||
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
|
||||
};
|
||||
if result
|
||||
.get("pruned")
|
||||
.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
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -129,6 +227,18 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"indeedhub" => &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -211,3 +321,24 @@ pub(super) fn configure_fedimint_lnd(
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::requires_unpruned_bitcoin;
|
||||
|
||||
#[test]
|
||||
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
|
||||
for package_id in [
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"electrs",
|
||||
"mempool",
|
||||
"mempool-web",
|
||||
] {
|
||||
assert!(requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
for package_id in ["bitcoin-knots", "btcpay-server", "lnd", "fedimint"] {
|
||||
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let pre_state =
|
||||
flip_package_state(&state_manager, &package_id_owned, PackageState::Starting).await;
|
||||
@@ -70,8 +72,14 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match do_package_start(&to_start).await {
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
|
||||
} else {
|
||||
do_package_start(&to_start).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
}
|
||||
@@ -123,6 +131,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let to_stop = containers.clone();
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let pre_state =
|
||||
flip_package_state(&state_manager, &package_id_owned, PackageState::Stopping).await;
|
||||
@@ -134,7 +144,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match do_package_stop(&containers).await {
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
|
||||
} else {
|
||||
do_package_stop(&containers).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Stopped)
|
||||
.await;
|
||||
@@ -182,7 +197,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let to_restart = ordered_containers_for_start(package_id).await?;
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let pre_state =
|
||||
flip_package_state(&state_manager, &package_id_owned, PackageState::Restarting).await;
|
||||
|
||||
@@ -193,8 +211,14 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match do_package_restart(&containers).await {
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
|
||||
} else {
|
||||
do_package_restart(&containers).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
}
|
||||
@@ -227,6 +251,20 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Disable + remove Quadlet companion units BEFORE the rm loop.
|
||||
// Otherwise systemd's Restart=always will respawn each companion
|
||||
// within ~10s of `podman rm`, leaving them orphaned post-uninstall.
|
||||
crate::container::companion::remove_for(package_id).await;
|
||||
|
||||
// Keep the production reconciler from recreating an app immediately
|
||||
// after uninstall. The reconciler owns a manifest map independent of
|
||||
// podman state, so a raw `podman rm` alone is not enough.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
for app_id in orchestrator_uninstall_app_ids(package_id) {
|
||||
let _ = orchestrator.remove(&app_id, preserve_data).await;
|
||||
}
|
||||
}
|
||||
|
||||
let containers_to_remove = get_containers_for_app(package_id).await?;
|
||||
if containers_to_remove.is_empty() {
|
||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||
@@ -571,6 +609,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
tracing::info!("Starting container: {}", name);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
@@ -580,6 +619,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
tracing::error!("Failed to start {}: {}", name, stderr);
|
||||
cleanup_start_conflict(name, &stderr).await;
|
||||
install_log(&format!("START FAIL: {} — {}", name, stderr)).await;
|
||||
errors.push(format!("{}: {}", name, stderr));
|
||||
}
|
||||
@@ -625,9 +665,86 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
errors.join("; ")
|
||||
));
|
||||
}
|
||||
|
||||
for name in to_start {
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_start(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
to_start: &[String],
|
||||
) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for (i, name) in to_start.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
do_package_start(&[name.clone()]).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator start failed");
|
||||
install_log(&format!("START FAIL: {} — {:#}", name, e)).await;
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_after_orchestrator_start(name: &str) {
|
||||
let delay = match name {
|
||||
"archy-btcpay-db" => 5,
|
||||
"archy-nbxplorer" => 8,
|
||||
_ => 0,
|
||||
};
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_stop(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
containers: &[String],
|
||||
) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for name in containers.iter().rev() {
|
||||
match orchestrator.stop(name).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
if let Err(e) = do_package_stop(&[name.clone()]).await {
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator stop failed");
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_restart(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
to_restart: &[String],
|
||||
) -> Result<()> {
|
||||
do_orchestrator_package_stop(orchestrator, to_restart).await?;
|
||||
do_orchestrator_package_start(orchestrator, to_restart).await
|
||||
}
|
||||
|
||||
/// Stop all containers with their per-container graceful-shutdown timeout.
|
||||
async fn do_package_stop(containers: &[String]) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
@@ -644,6 +761,10 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
|
||||
.context(format!("Failed to exec podman stop {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if is_missing_companion_ok(name, &stderr) {
|
||||
tracing::debug!(container = %name, "companion already absent during stop");
|
||||
continue;
|
||||
}
|
||||
tracing::error!("Failed to stop {}: {}", name, stderr);
|
||||
errors.push(format!("{}: {}", name, stderr));
|
||||
}
|
||||
@@ -660,6 +781,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
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()
|
||||
@@ -668,6 +790,10 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if is_missing_companion_ok(name, &stderr) {
|
||||
tracing::debug!(container = %name, "companion absent during restart; reconcile will recreate it");
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
"podman restart {} failed: {}, trying stop+start",
|
||||
name,
|
||||
@@ -687,12 +813,18 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
let start_err = String::from_utf8_lossy(&start_out.stderr)
|
||||
.trim()
|
||||
.to_string();
|
||||
cleanup_start_conflict(name, &start_err).await;
|
||||
if is_missing_companion_ok(name, &start_err) {
|
||||
tracing::debug!(container = %name, "companion absent during restart fallback; reconcile will recreate it");
|
||||
continue;
|
||||
}
|
||||
tracing::error!("stop+start {} also failed: {}", name, start_err);
|
||||
errors.push(format!("{}: {}", name, start_err));
|
||||
} else {
|
||||
tracing::info!("Restarted {} via stop+start fallback", name);
|
||||
}
|
||||
}
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
|
||||
@@ -700,6 +832,239 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("unknown app_id"))
|
||||
}
|
||||
|
||||
async fn repair_before_package_start(container_name: &str) {
|
||||
match container_name {
|
||||
"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,
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
let Some(port) = runtime_required_host_port(container_name) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if wait_for_runtime_host_port(port, 10).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"START REPAIR: {} — host port {} missing after start; restarting container",
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["restart", container_name])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to restart container after missing host port")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} host port {} was not listening and restart failed: {}",
|
||||
container_name,
|
||||
port,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if wait_for_runtime_host_port(port, 60).await {
|
||||
install_log(&format!(
|
||||
"START REPAIR OK: {} — host port {} is listening after restart",
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Container {} is running but host port {} is not listening",
|
||||
container_name,
|
||||
port
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
|
||||
match container_name {
|
||||
"grafana" => Some(3000),
|
||||
"searxng" => Some(8888),
|
||||
"uptime-kuma" => Some(3002),
|
||||
"gitea" => Some(3001),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_runtime_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 repair_btcpay_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/btcpay/Main",
|
||||
"/var/lib/archipelago/nbxplorer/Main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
for dir in [
|
||||
"/var/lib/archipelago/btcpay",
|
||||
"/var/lib/archipelago/nbxplorer",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "1000:1000", dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
repair_btcpay_database_password().await;
|
||||
}
|
||||
|
||||
async fn repair_btcpay_database_password() {
|
||||
let Ok(db_pass) =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let db_pass = db_pass.trim();
|
||||
if db_pass.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "archy-btcpay-db"])
|
||||
.output()
|
||||
.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;
|
||||
}
|
||||
|
||||
async fn repair_indeedhub_network() {
|
||||
super::stacks::repair_indeedhub_network_aliases().await;
|
||||
}
|
||||
|
||||
async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
if !stderr.contains("address already in use") && !stderr.contains("pasta failed") {
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name == "gitea" {
|
||||
cleanup_gitea_stale_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name != "grafana" {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
async fn cleanup_gitea_stale_ports() {
|
||||
for port in ["3001", "2222", "3000"] {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"archy-bitcoin-ui" | "archy-lnd-ui" | "archy-electrs-ui"
|
||||
) && stderr.contains("no container with name or ID")
|
||||
}
|
||||
|
||||
/// Flip the primary package entry's state and return the pre-transition
|
||||
/// state for revert on error. Mirrors `transitional::flip_to_transitional`
|
||||
/// but lives here because the package path keys by `package_id` (which may
|
||||
@@ -733,3 +1098,41 @@ async fn set_package_state(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||
let app_ids = match package_id {
|
||||
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".to_string(), "bitcoin-ui".to_string()],
|
||||
"bitcoin-knots" => vec!["bitcoin-knots".to_string(), "bitcoin-ui".to_string()],
|
||||
"lnd" => vec!["lnd".to_string(), "lnd-ui".to_string()],
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx".to_string(), "electrs-ui".to_string()]
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
for (companion, err) in crate::container::companion::reconcile(&app_ids).await {
|
||||
tracing::warn!(companion = %companion, error = %err, "companion reconcile failed");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".into(), "bitcoin-ui".into()],
|
||||
"bitcoin-knots" => vec!["bitcoin-knots".into(), "bitcoin-ui".into()],
|
||||
"lnd" => vec!["lnd".into(), "lnd-ui".into()],
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx".into(), "electrs-ui".into()]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
"mempool-api".into(),
|
||||
"archy-mempool-web".into(),
|
||||
"archy-mempool-db".into(),
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
|
||||
"btcpay-server".into(),
|
||||
"archy-nbxplorer".into(),
|
||||
"archy-btcpay-db".into(),
|
||||
],
|
||||
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
|
||||
_ => vec![package_id.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::data_model::InstallPhase;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use super::install::install_log;
|
||||
use super::install::{install_log, patch_indeedhub_nostr_provider};
|
||||
|
||||
/// Adopt an existing container stack: start all named containers and return success.
|
||||
/// Returns `Ok(Some(json))` if the primary container was found (adopted),
|
||||
@@ -40,6 +40,8 @@ async fn adopt_stack_if_exists(
|
||||
))
|
||||
.await;
|
||||
|
||||
repair_stack_before_adopt(stack_name).await;
|
||||
|
||||
for container in all_containers {
|
||||
if names.iter().any(|n| n == container) {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
@@ -48,6 +50,16 @@ async fn adopt_stack_if_exists(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let existing: Vec<&str> = all_containers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|container| names.iter().any(|n| n == container))
|
||||
.collect();
|
||||
wait_for_stack_containers(stack_name, &existing, 60).await?;
|
||||
|
||||
if stack_name == "indeedhub" {
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT OK: {} — started existing containers",
|
||||
@@ -61,6 +73,177 @@ async fn adopt_stack_if_exists(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn repair_stack_before_adopt(stack_name: &str) {
|
||||
match stack_name {
|
||||
"btcpay" | "btcpay-server" => {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/btcpay/Main",
|
||||
"/var/lib/archipelago/nbxplorer/Main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
for dir in [
|
||||
"/var/lib/archipelago/btcpay",
|
||||
"/var/lib/archipelago/nbxplorer",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", &format!("{}:{}", user, user), dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"indeedhub" => repair_indeedhub_network_aliases().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc::package) async fn repair_indeedhub_network_aliases() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "indeedhub-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
for (container, alias) in [
|
||||
("indeedhub-postgres", "postgres"),
|
||||
("indeedhub-redis", "redis"),
|
||||
("indeedhub-minio", "minio"),
|
||||
("indeedhub-relay", "relay"),
|
||||
("indeedhub-api", "api"),
|
||||
("indeedhub", "indeedhub"),
|
||||
] {
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", container])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "disconnect", "-f", "indeedhub-net", container])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
alias,
|
||||
"indeedhub-net",
|
||||
container,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_required_stack_command(
|
||||
stack_name: &str,
|
||||
label: &str,
|
||||
cmd: &mut tokio::process::Command,
|
||||
) -> Result<()> {
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("{}: failed to run {}", stack_name, label))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let msg = format!("{} failed: {}{}", label, stdout, stderr);
|
||||
install_log(&format!("INSTALL FAIL: {} - {}", stack_name, msg.trim())).await;
|
||||
Err(anyhow::anyhow!("{} {}", stack_name, msg.trim()))
|
||||
}
|
||||
|
||||
async fn wait_for_stack_containers(
|
||||
stack_name: &str,
|
||||
containers: &[&str],
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let mut pending = Vec::new();
|
||||
for container in containers {
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", container, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await;
|
||||
match status {
|
||||
Ok(output) if output.status.success() => {
|
||||
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
match state.as_str() {
|
||||
"running" => {}
|
||||
"exited" | "dead" => {
|
||||
let logs = stack_container_logs(container, 40).await;
|
||||
install_log(&format!(
|
||||
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(1000).collect::<String>()
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} container {} exited after install. Logs: {}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(500).collect::<String>()
|
||||
));
|
||||
}
|
||||
other => pending.push(format!("{}={}", container, other)),
|
||||
}
|
||||
}
|
||||
Ok(output) => {
|
||||
pending.push(format!(
|
||||
"{}=missing({})",
|
||||
container,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Err(e) => pending.push(format!("{}=inspect-error({})", container, e)),
|
||||
}
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
install_log(&format!(
|
||||
"INSTALL TIMEOUT: {} - containers not running: {}",
|
||||
stack_name,
|
||||
pending.join(", ")
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} containers did not reach running state: {}",
|
||||
stack_name,
|
||||
pending.join(", ")
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn stack_container_logs(container: &str, lines: u32) -> String {
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", &lines.to_string(), container])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
format!("{}{}", stdout, stderr)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn install_stack_via_orchestrator(
|
||||
handler: &RpcHandler,
|
||||
stack_name: &str,
|
||||
@@ -133,8 +316,13 @@ async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
||||
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["pull", image])
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
let output = cmd
|
||||
.arg(image)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman pull")?;
|
||||
@@ -208,10 +396,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("immich", i as u64, n_images).await;
|
||||
self.set_install_progress("immich", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("immich", n_images, n_images).await;
|
||||
self.set_install_progress("immich", n_images, n_images)
|
||||
.await;
|
||||
self.set_install_phase("immich", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
@@ -366,6 +556,12 @@ impl RpcHandler {
|
||||
|
||||
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
|
||||
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"btcpay-server",
|
||||
"btcpay",
|
||||
@@ -376,12 +572,6 @@ impl RpcHandler {
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
// Dependency check: Bitcoin must be running
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("btcpay-server", &deps)?;
|
||||
@@ -626,10 +816,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("mempool", i as u64, n_images).await;
|
||||
self.set_install_progress("mempool", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("mempool", n_images, n_images).await;
|
||||
self.set_install_progress("mempool", n_images, n_images)
|
||||
.await;
|
||||
self.set_install_phase("mempool", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
@@ -901,7 +1093,8 @@ impl RpcHandler {
|
||||
let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await;
|
||||
|
||||
// 1. Postgres
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut postgres_cmd = tokio::process::Command::new("podman");
|
||||
postgres_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -923,12 +1116,12 @@ impl RpcHandler {
|
||||
"indeedhub-postgres-data:/var/lib/postgresql/data",
|
||||
&format!("{}/postgres:16.13-alpine", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create postgres", &mut postgres_cmd).await?;
|
||||
|
||||
// 2. Redis
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut redis_cmd = tokio::process::Command::new("podman");
|
||||
redis_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -944,12 +1137,12 @@ impl RpcHandler {
|
||||
"indeedhub-redis-data:/data",
|
||||
&format!("{}/redis:7.4.8-alpine", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create redis", &mut redis_cmd).await?;
|
||||
|
||||
// 3. MinIO
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut minio_cmd = tokio::process::Command::new("podman");
|
||||
minio_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -971,12 +1164,12 @@ impl RpcHandler {
|
||||
"server",
|
||||
"/data",
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create minio", &mut minio_cmd).await?;
|
||||
|
||||
// 4. Nostr relay
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut relay_cmd = tokio::process::Command::new("podman");
|
||||
relay_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -992,12 +1185,12 @@ impl RpcHandler {
|
||||
"indeedhub-relay-data:/usr/src/app/db",
|
||||
&format!("{}/nostr-rs-relay:0.9.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create relay", &mut relay_cmd).await?;
|
||||
|
||||
// 5. API
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut api_cmd = tokio::process::Command::new("podman");
|
||||
api_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1049,12 +1242,12 @@ impl RpcHandler {
|
||||
"ENVIRONMENT=production",
|
||||
&format!("{}/indeedhub-api:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create api", &mut api_cmd).await?;
|
||||
|
||||
// 6. FFmpeg worker
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut ffmpeg_cmd = tokio::process::Command::new("podman");
|
||||
ffmpeg_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1096,15 +1289,15 @@ impl RpcHandler {
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
|
||||
&format!("{}/indeedhub-ffmpeg:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create ffmpeg worker", &mut ffmpeg_cmd).await?;
|
||||
|
||||
// Wait for backend services to start
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
// 7. Frontend (nginx)
|
||||
let run = tokio::process::Command::new("podman")
|
||||
let mut frontend_cmd = tokio::process::Command::new("podman");
|
||||
frontend_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1114,19 +1307,31 @@ impl RpcHandler {
|
||||
"indeedhub-net",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--tmpfs",
|
||||
"/run:rw,nosuid,nodev,size=16m",
|
||||
"--tmpfs",
|
||||
"/var/cache/nginx:rw,nosuid,nodev,size=32m",
|
||||
"-p",
|
||||
"7778:7777",
|
||||
&format!("{}/indeedhub:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create indeedhub container")?;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create frontend", &mut frontend_cmd).await?;
|
||||
|
||||
if !run.status.success() {
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!("IndeedHub frontend failed: {}", err));
|
||||
}
|
||||
wait_for_stack_containers(
|
||||
"indeedhub",
|
||||
&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase: WaitingHealthy → PostInstall → clear. The actual readiness
|
||||
// gate is the package scanner's next sweep; this just gives the UI a
|
||||
@@ -1136,9 +1341,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done).await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done)
|
||||
.await;
|
||||
self.clear_install_progress("indeedhub").await;
|
||||
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
|
||||
install_log("INSTALL OK: indeedhub stack").await;
|
||||
info!("IndeedHub stack installed");
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -322,8 +322,13 @@ impl RpcHandler {
|
||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", image])
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
let mut child = cmd
|
||||
.arg(image)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -132,11 +132,10 @@ async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
.await
|
||||
.context("getindexinfo")
|
||||
.ok();
|
||||
let zmq_notifications =
|
||||
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
|
||||
@@ -41,6 +41,11 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
||||
/// 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() {
|
||||
match run_service_override_repair().await {
|
||||
Ok(true) => info!("Removed stale Archipelago dev-mode service override"),
|
||||
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
|
||||
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
@@ -56,6 +61,135 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
|
||||
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
Ok(true) => info!("Repaired Bitcoin RPC bind settings and restarted Bitcoin containers"),
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match tighten_secrets_dir().await {
|
||||
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
||||
Ok(_) => debug!("Secrets directory already at expected mode"),
|
||||
Err(e) => warn!("Secrets dir tightening failed (non-fatal): {:#}", e),
|
||||
}
|
||||
// Podman probing MUST be the last bootstrap stage. We used to delete
|
||||
// transient runroot state here when `podman info` failed, but live nodes
|
||||
// can still have rootlessport/conmon processes holding that state. Removing
|
||||
// it automatically makes failures worse: containers lose `.containerenv`,
|
||||
// ports stay bound, and later starts fail. Report the fault instead; repair
|
||||
// must be deliberate/operator-driven.
|
||||
match heal_podman_state().await {
|
||||
Ok(PodmanHealOutcome::Healthy) => debug!("podman runtime state healthy"),
|
||||
Ok(PodmanHealOutcome::Unhealthy) => warn!(
|
||||
"podman runtime state is unhealthy at startup — skipping automatic runroot cleanup"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"podman self-heal failed (non-fatal, will retry next boot): {:#}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum PodmanHealOutcome {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
}
|
||||
|
||||
async fn heal_podman_state() -> Result<PodmanHealOutcome> {
|
||||
if probe_podman_ok().await {
|
||||
return Ok(PodmanHealOutcome::Healthy);
|
||||
}
|
||||
Ok(PodmanHealOutcome::Unhealthy)
|
||||
}
|
||||
|
||||
/// True iff `podman info` returns 0 within 5s. Any timeout, spawn
|
||||
/// failure, or non-zero exit reads as "wedged" and triggers cleanup.
|
||||
async fn probe_podman_ok() -> bool {
|
||||
use std::time::Duration;
|
||||
let probe = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::process::Command::new("podman")
|
||||
.arg("info")
|
||||
.arg("--format=json")
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match probe {
|
||||
Ok(Ok(out)) => out.status.success(),
|
||||
Ok(Err(_)) | Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure /var/lib/archipelago/secrets/ stays 0700 owned by archipelago,
|
||||
/// and every file inside is 0600. The parent dir mode is the load-bearing
|
||||
/// boundary against host-side reads from other UIDs (rootless container
|
||||
/// escapes get mapped to UID >= 100000 and can't traverse a 0700/uid=1000
|
||||
/// directory). The per-file 0600 sweep is defense-in-depth in case some
|
||||
/// installer wrote a 0644 file.
|
||||
async fn tighten_secrets_dir() -> Result<u32> {
|
||||
let dir = Path::new("/var/lib/archipelago/secrets");
|
||||
if !dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.await
|
||||
.with_context(|| format!("chmod 0700 {}", dir.display()))?;
|
||||
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.await
|
||||
.with_context(|| format!("read_dir {}", dir.display()))?;
|
||||
let mut tightened = 0u32;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
if meta.permissions().mode() & 0o777 != 0o600 {
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.with_context(|| format!("chmod 0600 {}", path.display()))?;
|
||||
tightened += 1;
|
||||
}
|
||||
}
|
||||
Ok(tightened)
|
||||
}
|
||||
|
||||
async fn run_service_override_repair() -> Result<bool> {
|
||||
let override_path = Path::new("/etc/systemd/system/archipelago.service.d/override.conf");
|
||||
let Ok(content) = fs::read_to_string(override_path).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !content.contains("ARCHIPELAGO_DEV_MODE=true") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let only_dev_mode_override = content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.all(|line| line == "[Service]" || line == "Environment=ARCHIPELAGO_DEV_MODE=true");
|
||||
if !only_dev_mode_override {
|
||||
warn!(
|
||||
path = %override_path.display(),
|
||||
"Archipelago service override contains ARCHIPELAGO_DEV_MODE=true plus other settings; leaving it untouched"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let path_s = override_path.to_string_lossy().to_string();
|
||||
let status = host_sudo(&["rm", "-f", &path_s])
|
||||
.await
|
||||
.with_context(|| format!("remove {}", override_path.display()))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("remove {} exited with {}", override_path.display(), status);
|
||||
}
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_runtime_assets() -> Result<bool> {
|
||||
@@ -111,7 +245,6 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
@@ -169,6 +302,51 @@ fn path_dot(path: &Path) -> String {
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// 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
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("repair bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => {
|
||||
for name in ["bitcoin-knots", "bitcoin-core", "archy-bitcoin-ui"] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", name])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<bool> {
|
||||
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
||||
// typically a symlink into the git checkout, and writing through it
|
||||
@@ -218,21 +396,14 @@ async fn run() -> Result<bool> {
|
||||
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
|
||||
changed = changed || service_changed || timer_changed;
|
||||
|
||||
// 3. Reload + enable. Only when we actually touched units, or when the
|
||||
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
|
||||
// the doctor entirely).
|
||||
let timer_enabled = is_timer_enabled().await;
|
||||
if service_changed || timer_changed || !timer_enabled {
|
||||
// 3. Reload if units changed. Do not enable/start the timer here: lifecycle
|
||||
// qualification and explicit app operations need deterministic Podman
|
||||
// ownership, and the doctor can race those flows. Operators can enable it
|
||||
// separately when they want periodic host repair.
|
||||
if service_changed || timer_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("daemon-reload failed: {:#}", e);
|
||||
}
|
||||
if let Err(e) =
|
||||
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await
|
||||
{
|
||||
warn!("enable archipelago-doctor.timer failed: {:#}", e);
|
||||
} else if !timer_enabled {
|
||||
info!("Enabled archipelago-doctor.timer");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
@@ -273,15 +444,6 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn is_timer_enabled() -> bool {
|
||||
tokio::process::Command::new("systemctl")
|
||||
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -375,3 +537,14 @@ async fn run_nginx() -> Result<bool> {
|
||||
let _ = host_sudo(&["rm", "-f", &backup]).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
|
||||
let outcome = PodmanHealOutcome::Unhealthy;
|
||||
assert_ne!(outcome, PodmanHealOutcome::Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ pub struct Config {
|
||||
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
|
||||
#[serde(default)]
|
||||
pub nostr_tor_proxy: Option<String>,
|
||||
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
|
||||
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
||||
/// + systemctl --user start) instead of `podman create + start`. Default
|
||||
/// off so the legacy path stays the production path until the harness
|
||||
/// at tests/lifecycle/run-20x.sh has gone green against the new path
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -132,9 +140,12 @@ impl Config {
|
||||
config.log_level = level;
|
||||
}
|
||||
|
||||
// Dev mode configuration
|
||||
if let Ok(dev_mode) = std::env::var("ARCHIPELAGO_DEV_MODE") {
|
||||
config.dev_mode = dev_mode.parse().unwrap_or(false);
|
||||
// Production binaries must not be switched into dev orchestration by
|
||||
// host environment. Several live nodes carried a stale systemd
|
||||
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
|
||||
// mounts into /tmp and prevented real installs from starting.
|
||||
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
|
||||
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
|
||||
}
|
||||
|
||||
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
|
||||
@@ -171,6 +182,17 @@ impl Config {
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
|
||||
// route backend installs through the Quadlet path without requiring a
|
||||
// config.json edit + archipelago.service restart (which would trigger
|
||||
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
|
||||
// the config.json value untouched.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
|
||||
if parse_truthy_env(&v) {
|
||||
config.use_quadlet_backends = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Host IP for container env vars (detect if not set)
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
@@ -218,10 +240,23 @@ impl Default for Config {
|
||||
"wss://relay.nostr.info".into(),
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
use_quadlet_backends: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise the canonical "the user meant true" forms for boolean env
|
||||
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
|
||||
/// trimmed). Anything else — including the typo'd "ture" or the empty
|
||||
/// string — counts as false. Centralised so future env flags stay
|
||||
/// consistent with each other.
|
||||
fn parse_truthy_env(raw: &str) -> bool {
|
||||
matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -425,4 +460,24 @@ mod tests {
|
||||
assert!(!config.nostr_relays.is_empty());
|
||||
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truthy_env_recognises_canonical_forms() {
|
||||
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
|
||||
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
|
||||
}
|
||||
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
|
||||
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_use_quadlet_backends_defaults_off() {
|
||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
|
||||
// harness goes green on .228 and .198. Flipping this default
|
||||
// ahead of that would route every backend install through code
|
||||
// we haven't fleet-validated yet.
|
||||
let config = Config::default();
|
||||
assert!(!config.use_quadlet_backends);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//!
|
||||
//! Step 5 of the rust-orchestrator migration. Spawned once from `main.rs`
|
||||
//! (Step 6) after the initial `adopt_existing()` pass. Every `interval` it
|
||||
//! calls `ProdContainerOrchestrator::reconcile_all()`, which ensures every
|
||||
//! loaded manifest has a running container, installing fresh ones as needed.
|
||||
//! calls `ProdContainerOrchestrator::reconcile_existing()`, which repairs
|
||||
//! containers that already exist without installing every catalog manifest.
|
||||
//!
|
||||
//! Per answered design Q3, `interval` defaults to 30 seconds.
|
||||
//!
|
||||
@@ -29,6 +29,11 @@ pub struct BootReconciler {
|
||||
orchestrator: Arc<ProdContainerOrchestrator>,
|
||||
interval: Duration,
|
||||
shutdown: Arc<Notify>,
|
||||
/// Run the companion-unit repair stage each tick. Default true.
|
||||
/// Tests disable this — companion reconcile shells out to
|
||||
/// `systemctl --user` and `podman`, which both block real time
|
||||
/// and would race the paused-clock test fixtures.
|
||||
companion_stage: bool,
|
||||
}
|
||||
|
||||
impl BootReconciler {
|
||||
@@ -41,28 +46,46 @@ impl BootReconciler {
|
||||
orchestrator,
|
||||
interval,
|
||||
shutdown,
|
||||
companion_stage: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable the companion-unit reconcile stage. Used by unit tests
|
||||
/// that exercise loop cadence without the real systemd / podman
|
||||
/// surface. Production must not call this.
|
||||
#[cfg(test)]
|
||||
pub fn without_companion_stage(mut self) -> Self {
|
||||
self.companion_stage = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the reconcile loop until `shutdown` is notified.
|
||||
///
|
||||
/// Does one reconcile immediately, then sleeps `interval` between
|
||||
/// subsequent passes. A `shutdown.notify_one()` call unblocks the sleep
|
||||
/// and the task returns after the *next* pass completes.
|
||||
///
|
||||
/// Never panics: per-app failures are absorbed into `ReconcileReport` by
|
||||
/// the orchestrator, and `reconcile_all` itself returns infallibly.
|
||||
/// Each pass is two stages:
|
||||
/// 1. App reconcile: `reconcile_all()` keeps every loaded manifest's
|
||||
/// container running.
|
||||
/// 2. Companion reconcile: any expected Quadlet companion unit that
|
||||
/// is missing or inactive is repaired (writes the unit, daemon-
|
||||
/// reloads, starts the service). This is the safety net for the
|
||||
/// "someone deleted my unit file" / "systemd lost the service"
|
||||
/// failure modes.
|
||||
///
|
||||
/// Never panics: per-app failures are absorbed into `ReconcileReport`
|
||||
/// by the orchestrator, and companion failures are logged but never
|
||||
/// propagated.
|
||||
pub async fn run_forever(self) {
|
||||
// Initial pass: no delay.
|
||||
let report = self.orchestrator.reconcile_all().await;
|
||||
Self::log_report(&report);
|
||||
self.tick().await;
|
||||
|
||||
loop {
|
||||
let deadline = Instant::now() + self.interval;
|
||||
tokio::select! {
|
||||
_ = time::sleep_until(deadline) => {
|
||||
let report = self.orchestrator.reconcile_all().await;
|
||||
Self::log_report(&report);
|
||||
self.tick().await;
|
||||
}
|
||||
_ = self.shutdown.notified() => {
|
||||
tracing::info!("boot reconciler: shutdown requested, exiting loop");
|
||||
@@ -72,6 +95,23 @@ impl BootReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) {
|
||||
let report = self.orchestrator.reconcile_existing().await;
|
||||
Self::log_report(&report);
|
||||
|
||||
if !self.companion_stage {
|
||||
return;
|
||||
}
|
||||
let installed = self.orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_report(report: &ReconcileReport) {
|
||||
for (app_id, action) in &report.actions {
|
||||
tracing::debug!(app_id = %app_id, action = ?action, "reconcile action");
|
||||
@@ -200,10 +240,11 @@ mod tests {
|
||||
async fn orch_with_one_running_manifest(
|
||||
rt: Arc<CountingRuntime>,
|
||||
) -> Arc<ProdContainerOrchestrator> {
|
||||
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
|
||||
rt,
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
));
|
||||
let mut orch =
|
||||
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||
let tmp = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(tmp);
|
||||
let orch = Arc::new(orch);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
|
||||
PathBuf::from("/tmp/bk"),
|
||||
@@ -218,7 +259,8 @@ mod tests {
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
// Yield so the spawned task gets CPU to run its initial reconcile.
|
||||
@@ -242,7 +284,8 @@ mod tests {
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
@@ -271,7 +314,8 @@ mod tests {
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
@@ -294,10 +338,13 @@ mod tests {
|
||||
// will run, and the next pass will see a new state. We care about
|
||||
// "loop keeps ticking even when the report has actions".
|
||||
let rt = Arc::new(CountingRuntime::default());
|
||||
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
|
||||
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||
rt.clone(),
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
));
|
||||
);
|
||||
let tmp = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(tmp);
|
||||
let orch = Arc::new(orch);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
|
||||
PathBuf::from("/tmp/bk"),
|
||||
@@ -305,7 +352,8 @@ mod tests {
|
||||
.await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Companion UI container lifecycle, entirely Quadlet-managed.
|
||||
//!
|
||||
//! A "companion" is a small nginx-based container that exposes a
|
||||
//! browser-friendly UI on top of a headless backend service:
|
||||
//!
|
||||
//! | Backend | Companion | Purpose |
|
||||
//! |------------------|--------------------|--------------------------|
|
||||
//! | bitcoin-knots | archy-bitcoin-ui | RPC viewer |
|
||||
//! | bitcoin-core | archy-bitcoin-ui | RPC viewer |
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
//! generated `.service`. systemd owns supervision from that point on
|
||||
//! — archipelago can crash, restart, or be uninstalled without
|
||||
//! touching the companion.
|
||||
//!
|
||||
//! This replaces the old `tokio::spawn { podman run }` block in
|
||||
//! `install.rs` (~165 lines of fire-and-forget shellouts) with a
|
||||
//! single declarative call.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::container::quadlet::{self, BindMount, NetworkMode, QuadletUnit};
|
||||
use archipelago_container::image_uses_insecure_registry;
|
||||
|
||||
const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
/// Static description of one companion. The full list per backend
|
||||
/// app_id lives in `companions_for`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompanionSpec {
|
||||
/// Container + unit name (e.g. "archy-bitcoin-ui").
|
||||
pub name: &'static str,
|
||||
/// Image base name in the lfg2025 registry namespace
|
||||
/// (e.g. "bitcoin-ui" → "146.59.87.168:3000/lfg2025/bitcoin-ui:latest").
|
||||
pub image_base: &'static str,
|
||||
/// Filesystem locations to look for a local Dockerfile (build wins
|
||||
/// over registry pull). Searched in order; first hit wins.
|
||||
pub build_dir_candidates: &'static [&'static str],
|
||||
/// Optional pre-start hook that renders config files referenced
|
||||
/// by `bind_mounts`. Returns Ok(()) on success; bind-mount must
|
||||
/// be present at start time or the companion will 502.
|
||||
pub pre_start: Option<PreStartHook>,
|
||||
/// Bind mounts. Always read-only — companions don't write to
|
||||
/// host paths.
|
||||
pub bind_mounts: &'static [(&'static str, &'static str)],
|
||||
/// Host-to-container TCP ports for non-host-network companions.
|
||||
pub ports: &'static [(u16, u16)],
|
||||
/// Whether the companion must share the host network namespace.
|
||||
pub host_network: bool,
|
||||
}
|
||||
|
||||
pub type PreStartHook = fn() -> futures_util::future::BoxFuture<'static, Result<()>>;
|
||||
|
||||
/// Companions to install when `package_id` lands. Empty for apps
|
||||
/// without a companion UI.
|
||||
pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => BITCOIN_UI,
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
image_base: "bitcoin-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
"/home/archipelago/Projects/archy/docker/bitcoin-ui",
|
||||
],
|
||||
pre_start: Some(render_bitcoin_ui),
|
||||
bind_mounts: &[(
|
||||
"/var/lib/archipelago/bitcoin-ui/nginx.conf",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
)],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-lnd-ui",
|
||||
image_base: "lnd-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/lnd-ui",
|
||||
"/home/archipelago/archy/docker/lnd-ui",
|
||||
"/home/archipelago/Projects/archy/docker/lnd-ui",
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[(18083, 80)],
|
||||
host_network: false,
|
||||
}];
|
||||
|
||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-electrs-ui",
|
||||
image_base: "electrs-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/electrs-ui",
|
||||
"/home/archipelago/archy/docker/electrs-ui",
|
||||
"/home/archipelago/Projects/archy/docker/electrs-ui",
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
crate::container::bitcoin_ui::render(&paths)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.context("render bitcoin-ui nginx.conf")
|
||||
})
|
||||
}
|
||||
|
||||
/// Provision and start every companion for `package_id`. Each
|
||||
/// companion is independent — a failure in one is logged but does
|
||||
/// not abort the others.
|
||||
pub async fn install_for(package_id: &str) -> Vec<(String, anyhow::Error)> {
|
||||
let mut failures = Vec::new();
|
||||
for spec in companions_for(package_id) {
|
||||
if let Err(e) = install_one(spec).await {
|
||||
warn!(companion = spec.name, error = %e, "companion install failed");
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Stop and remove every companion for `package_id`. Best effort:
|
||||
/// errors are logged but do not abort the sequence.
|
||||
pub async fn remove_for(package_id: &str) {
|
||||
let dir = match quadlet::unit_dir().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("companion remove: cannot resolve quadlet dir: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for spec in companions_for(package_id) {
|
||||
if let Err(e) = quadlet::disable_remove(spec.name, &dir).await {
|
||||
warn!(companion = spec.name, error = %e, "companion remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision one companion: pre-start hook → image present → write
|
||||
/// quadlet → daemon-reload → start.
|
||||
pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
if let Some(hook) = spec.pre_start {
|
||||
hook().await.with_context(|| {
|
||||
format!(
|
||||
"pre-start hook failed for {} — companion will not start",
|
||||
spec.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let image = ensure_image_present(spec).await?;
|
||||
let unit = build_unit(spec, &image);
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let changed = quadlet::write_if_changed(&unit, &dir).await?;
|
||||
if changed {
|
||||
info!(companion = spec.name, "wrote quadlet unit");
|
||||
quadlet::daemon_reload_user().await?;
|
||||
}
|
||||
// Start is idempotent — if already running, systemctl returns 0.
|
||||
quadlet::enable_now(&unit.service_name()).await?;
|
||||
info!(companion = spec.name, "companion started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build companion image locally if a Dockerfile exists, otherwise
|
||||
/// pull from the lfg2025 registry. Returns the image ref the quadlet
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
// Prefer local build — companions can carry build-time customizations
|
||||
// (e.g. nginx.conf templates baked in). Search known candidates.
|
||||
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).await {
|
||||
return Ok(local_image);
|
||||
}
|
||||
info!(companion = spec.name, "building locally from {dir}");
|
||||
let out = Command::new("podman")
|
||||
.args(["build", "-t", &local_image, dir])
|
||||
.output()
|
||||
.await
|
||||
.context("spawn podman build")?;
|
||||
if out.status.success() {
|
||||
return Ok(local_image);
|
||||
}
|
||||
warn!(
|
||||
companion = spec.name,
|
||||
"local build failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
// Fall through to registry pull rather than fail outright.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Registry pull. Use insecure flag only for whitelisted hosts.
|
||||
let mut cmd = Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(®istry_image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(®istry_image);
|
||||
let out = cmd.output().await.context("spawn podman pull")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"no local Dockerfile and registry pull failed for {}: {}",
|
||||
spec.name,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(registry_image)
|
||||
}
|
||||
|
||||
async fn image_exists(image: &str) -> bool {
|
||||
Command::new("podman")
|
||||
.args(["image", "exists", image])
|
||||
.status()
|
||||
.await
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
|
||||
QuadletUnit {
|
||||
name: spec.name.into(),
|
||||
description: format!("Archipelago companion UI: {}", spec.name),
|
||||
image: image.into(),
|
||||
network: if spec.host_network {
|
||||
NetworkMode::Host
|
||||
} else {
|
||||
NetworkMode::Bridge("bridge".into())
|
||||
},
|
||||
// Run as root inside the container so nginx can chown its
|
||||
// worker dirs. Rootless podman maps this to a high host UID,
|
||||
// so it is unprivileged on the host.
|
||||
user: Some("0:0".into()),
|
||||
memory_mb: Some(128),
|
||||
cap_drop_all: true,
|
||||
cap_add: vec![
|
||||
"CHOWN".into(),
|
||||
"DAC_OVERRIDE".into(),
|
||||
"NET_BIND_SERVICE".into(),
|
||||
"SETUID".into(),
|
||||
"SETGID".into(),
|
||||
],
|
||||
bind_mounts: spec
|
||||
.bind_mounts
|
||||
.iter()
|
||||
.map(|(host, container)| BindMount {
|
||||
host: PathBuf::from(*host),
|
||||
container: PathBuf::from(*container),
|
||||
read_only: true,
|
||||
})
|
||||
.collect(),
|
||||
ports: spec
|
||||
.ports
|
||||
.iter()
|
||||
.map(|(host, container)| (*host, *container, "tcp".into()))
|
||||
.collect(),
|
||||
extra_podman_args: vec![],
|
||||
depends_on: vec![],
|
||||
// Companions don't use the backend-manifest extension fields;
|
||||
// the renderer skips empty/false directives so the rendered
|
||||
// bytes are unchanged from before quadlet.rs grew the new fields.
|
||||
..QuadletUnit::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a user systemd manager reachable? In production archipelago.service
|
||||
/// inherits XDG_RUNTIME_DIR from systemd; in unit tests / CI sandboxes it
|
||||
/// is unset, in which case `systemctl --user` would fail and write to
|
||||
/// HOME would be an unwanted side effect. The reconciler skips its
|
||||
/// companion stage when this is false.
|
||||
fn user_systemd_available() -> bool {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reconcile companion presence: every expected companion for the
|
||||
/// given installed apps must have its quadlet unit on disk and its
|
||||
/// service active. Returns a list of (companion, error) for anything
|
||||
/// that needed correction and failed.
|
||||
///
|
||||
/// Called from `boot_reconciler` so a deleted unit file or a stopped
|
||||
/// service is repaired within one tick. No-ops if the user systemd
|
||||
/// manager is not reachable (CI / test environments).
|
||||
pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)> {
|
||||
if !user_systemd_available() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for app_id in installed_apps {
|
||||
for spec in companions_for(app_id) {
|
||||
match needs_repair(spec).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"reconcile: companion not active, repairing"
|
||||
);
|
||||
if let Err(e) = install_one(spec).await {
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(companion = spec.name, error = %e, "reconcile probe failed");
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Does this companion need install_one to be re-run? Returns true if
|
||||
/// the unit file is missing 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 svc = format!("{}.service", spec.name);
|
||||
Ok(!quadlet::is_active(&svc).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn companions_for_known_apps_returns_expected_set() {
|
||||
assert_eq!(companions_for("bitcoin-knots").len(), 1);
|
||||
assert_eq!(companions_for("bitcoin-core").len(), 1);
|
||||
assert_eq!(companions_for("bitcoin").len(), 1);
|
||||
assert_eq!(companions_for("lnd").len(), 1);
|
||||
assert_eq!(companions_for("electrumx").len(), 1);
|
||||
assert_eq!(companions_for("electrs").len(), 1);
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_unit_uses_host_network_and_drops_caps() {
|
||||
let spec = &BITCOIN_UI[0];
|
||||
let u = build_unit(spec, "localhost/bitcoin-ui:latest");
|
||||
assert_eq!(u.name, "archy-bitcoin-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Host));
|
||||
assert!(u.cap_drop_all);
|
||||
assert!(u.cap_add.iter().any(|c| c == "NET_BIND_SERVICE"));
|
||||
assert_eq!(u.user.as_deref(), Some("0:0"));
|
||||
assert_eq!(u.memory_mb, Some(128));
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(
|
||||
u.bind_mounts[0].container,
|
||||
PathBuf::from("/etc/nginx/conf.d/default.conf")
|
||||
);
|
||||
assert!(u.bind_mounts[0].read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_ui_uses_port_mapping_not_host_port_80() {
|
||||
let spec = &LND_UI[0];
|
||||
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
||||
assert_eq!(u.name, "archy-lnd-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
|
||||
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,14 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
];
|
||||
|
||||
// First pass: collect UI containers
|
||||
// First pass: collect running UI containers. Custom UI-backed apps must
|
||||
// not advertise a launch URL unless their companion is actually alive.
|
||||
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||
for container in &containers {
|
||||
if container.name.ends_with("-ui") {
|
||||
if !matches!(container.state, ContainerState::Running) {
|
||||
continue;
|
||||
}
|
||||
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
|
||||
let parent_app = container
|
||||
.name
|
||||
@@ -76,10 +80,10 @@ impl DockerPackageScanner {
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(parent_app)
|
||||
.to_string();
|
||||
if !container.ports.is_empty() {
|
||||
if let Some(ui_address) = extract_lan_address(&container.ports) {
|
||||
ui_containers.insert(canonical_id, ui_address);
|
||||
}
|
||||
let ui_address = extract_lan_address(&container.ports)
|
||||
.or_else(|| companion_lan_address(&canonical_id));
|
||||
if let Some(ui_address) = ui_address {
|
||||
ui_containers.insert(canonical_id, ui_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,12 +137,6 @@ impl DockerPackageScanner {
|
||||
// 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())
|
||||
} else if app_id == "bitcoin-knots" {
|
||||
Some("http://localhost:8334".to_string())
|
||||
} else if app_id == "lnd" {
|
||||
Some("http://localhost:8081".to_string())
|
||||
} else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" {
|
||||
Some("http://localhost:50002".to_string())
|
||||
} else {
|
||||
// Dynamic: use actual port bindings from container, fall back to static map
|
||||
extract_lan_address(&container.ports)
|
||||
@@ -633,6 +631,14 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
match app_id {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
|
||||
"electrumx" | "mempool-electrs" | "electrs" => Some("http://localhost:50002".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! lnd config bootstrap helper.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
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";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
pub data_dir: PathBuf,
|
||||
pub conf_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for EnsurePaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
||||
conf_path: PathBuf::from(DEFAULT_CONF_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EnsureOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
|
||||
fs::create_dir_all(&paths.data_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
|
||||
|
||||
if paths.conf_path.exists() {
|
||||
let existing = fs::read_to_string(&paths.conf_path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
|
||||
if has_required_lnd_flags(&existing) {
|
||||
return Ok(EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
let conf = format!(
|
||||
"debuglevel=info\n\
|
||||
maxpendingchannels=10\n\
|
||||
alias=Archipelago Node\n\
|
||||
color=#f7931a\n\
|
||||
listen=0.0.0.0:9735\n\
|
||||
rpclisten=0.0.0.0:10009\n\
|
||||
restlisten=0.0.0.0:8080\n\
|
||||
bitcoin.active=true\n\
|
||||
bitcoin.mainnet=true\n\
|
||||
bitcoin.node=bitcoind\n\
|
||||
bitcoind.rpchost=bitcoin-knots:8332\n\
|
||||
bitcoind.rpcuser=archipelago\n\
|
||||
bitcoind.rpcpass={}\n\
|
||||
bitcoind.rpcpolling=true\n\
|
||||
bitcoind.estimatemode=ECONOMICAL\n",
|
||||
rpc_pass
|
||||
);
|
||||
|
||||
write_config_atomically(paths, &conf).await?;
|
||||
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
}
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["test", "-f", path])
|
||||
.status()
|
||||
.await
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
match fs::read(path).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let out = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("reading {path} via sudo"))?;
|
||||
if out.status.success() {
|
||||
Ok(out.stdout)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"reading {path} failed (direct: {direct_err}; sudo: {})",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenSeedResponse {
|
||||
cipher_seed_mnemonic: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UnlockerResponse<T> {
|
||||
Value(T),
|
||||
WalletAlreadyExists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InitWalletRequest {
|
||||
wallet_password: String,
|
||||
cipher_seed_mnemonic: Vec<String>,
|
||||
}
|
||||
|
||||
async fn init_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 seed: GenSeedResponse = match get_lnd_unlocker_json(&client, "/v1/genseed")
|
||||
.await
|
||||
.context("generating LND wallet seed")?
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if seed.cipher_seed_mnemonic.is_empty() {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
};
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/initwallet",
|
||||
serde_json::to_value(req)?,
|
||||
)
|
||||
.await
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
},
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LND REST {path} unavailable: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||
client: &reqwest::Client,
|
||||
path: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let url = format!("https://127.0.0.1:8080{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
},
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LND REST {path} unavailable: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
resp: reqwest::Response,
|
||||
path: &str,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
let value = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parsing LND REST response from {path}"))?;
|
||||
return Ok(UnlockerResponse::Value(value));
|
||||
}
|
||||
if text.contains("wallet already exists") {
|
||||
return Ok(UnlockerResponse::WalletAlreadyExists);
|
||||
}
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
|
||||
.send()
|
||||
.await
|
||||
.map(|resp| resp.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn wait_for_admin_macaroon(admin_macaroon: &str) -> Result<()> {
|
||||
for _ in 0..60 {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND admin macaroon not created after wallet init")
|
||||
}
|
||||
|
||||
async fn write_config_atomically(paths: &EnsurePaths, conf: &str) -> Result<()> {
|
||||
let tmp = paths.conf_path.with_extension("tmp");
|
||||
match fs::write(&tmp, conf).await {
|
||||
Ok(()) => {
|
||||
fs::rename(&tmp, &paths.conf_path).await.with_context(|| {
|
||||
format!(
|
||||
"renaming {} -> {}",
|
||||
tmp.display(),
|
||||
paths.conf_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let script = format!(
|
||||
"set -eu\ncat > '{}' <<'LNDCONF'\n{}LNDCONF\n",
|
||||
shell_quote(&paths.conf_path.to_string_lossy()),
|
||||
conf
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("writing lnd.conf via sudo")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("writing lnd.conf via sudo exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
fn has_required_lnd_flags(conf: &str) -> bool {
|
||||
[
|
||||
"bitcoin.active=true",
|
||||
"bitcoin.mainnet=true",
|
||||
"bitcoin.node=bitcoind",
|
||||
"bitcoind.rpchost=bitcoin-knots:8332",
|
||||
]
|
||||
.iter()
|
||||
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_writes_required_bitcoin_network_flags() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
let out = ensure_config(&paths, "secret").await.unwrap();
|
||||
assert_eq!(out, EnsureOutcome::Written);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.active=true"));
|
||||
assert!(conf.contains("bitcoin.mainnet=true"));
|
||||
assert!(conf.contains("bitcoin.node=bitcoind"));
|
||||
assert!(conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_is_idempotent() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "first").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "second").await.unwrap(),
|
||||
EnsureOutcome::Unchanged
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoind.rpcpass=first"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_repairs_incomplete_existing_config() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
fs::create_dir_all(&paths.data_dir).await.unwrap();
|
||||
fs::write(&paths.conf_path, "debuglevel=info\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "repaired").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.mainnet=true"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=repaired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod filebrowser;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
pub mod traits;
|
||||
|
||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||
pub use docker_packages::DockerPackageScanner;
|
||||
pub use prod_orchestrator::{
|
||||
compute_container_name, AdoptionReport, ProdContainerOrchestrator, ReconcileAction,
|
||||
ReconcileReport,
|
||||
};
|
||||
pub use prod_orchestrator::ProdContainerOrchestrator;
|
||||
pub use traits::ContainerOrchestrator;
|
||||
|
||||
@@ -8,9 +8,10 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const REGISTRY_FILE: &str = "config/registries.json";
|
||||
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
|
||||
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
|
||||
|
||||
/// A single container registry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,14 +45,14 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||
url: OVH_REGISTRY_URL.to_string(),
|
||||
name: "Server 1 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
url: TX1138_REGISTRY_URL.to_string(),
|
||||
name: "Server 2 (tx1138)".to_string(),
|
||||
tls_verify: true,
|
||||
enabled: true,
|
||||
@@ -139,6 +140,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let before_order: Vec<(String, bool, u32)> = config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| (r.url.clone(), r.enabled, r.priority))
|
||||
.collect();
|
||||
force_ovh_registry_primary(&mut config);
|
||||
changed = changed
|
||||
|| before_order
|
||||
!= config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| (r.url.clone(), r.enabled, r.priority))
|
||||
.collect::<Vec<_>>();
|
||||
if changed {
|
||||
// Persist so the next load doesn't have to re-merge.
|
||||
let _ = save_registries(data_dir, &config).await;
|
||||
@@ -146,6 +160,37 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
|
||||
let defaults = RegistryConfig::default();
|
||||
for def in defaults.registries {
|
||||
if !config.registries.iter().any(|r| r.url == def.url) {
|
||||
config.registries.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
for registry in config.registries.iter_mut() {
|
||||
match registry.url.as_str() {
|
||||
OVH_REGISTRY_URL => {
|
||||
registry.name = "Server 1 (OVH)".to_string();
|
||||
registry.tls_verify = false;
|
||||
registry.enabled = true;
|
||||
registry.priority = 0;
|
||||
}
|
||||
TX1138_REGISTRY_URL => {
|
||||
registry.name = "Server 2 (tx1138)".to_string();
|
||||
registry.tls_verify = true;
|
||||
registry.enabled = true;
|
||||
registry.priority = 10;
|
||||
}
|
||||
_ => {
|
||||
if registry.priority <= 10 {
|
||||
registry.priority = registry.priority.saturating_add(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save registry config to disk.
|
||||
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
|
||||
let dir = data_dir.join("config");
|
||||
|
||||
@@ -412,6 +412,19 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
};
|
||||
}
|
||||
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
.filter(|n| should_auto_start_stopped_container(n))
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
return RecoveryReport {
|
||||
total: 0,
|
||||
recovered: 0,
|
||||
failed: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||
let mut records: Vec<RunningContainerRecord> = names
|
||||
.iter()
|
||||
@@ -430,6 +443,13 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
recover_containers(&records).await
|
||||
}
|
||||
|
||||
fn should_auto_start_stopped_container(name: &str) -> bool {
|
||||
// Keep generic boot recovery narrow. The Rust manifest reconciler owns
|
||||
// managed app stacks; starting every exited Podman container here races
|
||||
// it and resurrects legacy/orphan helper containers.
|
||||
matches!(name, "filebrowser" | "nostr-rs-relay")
|
||||
}
|
||||
|
||||
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||
fn container_boot_tier(name: &str) -> u8 {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
@@ -603,4 +623,13 @@ mod tests {
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() {
|
||||
assert!(should_auto_start_stopped_container("filebrowser"));
|
||||
assert!(should_auto_start_stopped_container("nostr-rs-relay"));
|
||||
assert!(!should_auto_start_stopped_container("bitcoin-knots"));
|
||||
assert!(!should_auto_start_stopped_container("lnd"));
|
||||
assert!(!should_auto_start_stopped_container("indeedhub-postgres"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,9 @@ pub fn spawn_status_cache() {
|
||||
fresh.progress_pct = cached.progress_pct;
|
||||
}
|
||||
fresh.stale = true;
|
||||
fresh.error = Some(
|
||||
fresh
|
||||
.error
|
||||
.unwrap_or_else(|| "ElectrumX is reconnecting; showing last known indexed height.".to_string()),
|
||||
);
|
||||
fresh.error = Some(fresh.error.unwrap_or_else(|| {
|
||||
"ElectrumX is reconnecting; showing last known indexed height.".to_string()
|
||||
}));
|
||||
}
|
||||
*cached = fresh;
|
||||
drop(cached);
|
||||
@@ -373,16 +371,16 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
Err(e) => match electrumx_log_indexed_height().await {
|
||||
Ok(h) if h > 0 => h,
|
||||
_ => {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
// ElectrumX is starting up or busy — estimate from data size
|
||||
let progress_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
|
||||
return ElectrsSyncStatus {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
// ElectrumX is starting up or busy — estimate from data size
|
||||
let progress_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
@@ -396,21 +394,21 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
// Genuine unexpected error
|
||||
warn!("ElectrumX status: unexpected error: {}", err_msg);
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
stale: false,
|
||||
error: Some(format!("ElectrumX: {}", err_msg)),
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
// Genuine unexpected error
|
||||
warn!("ElectrumX status: unexpected error: {}", err_msg);
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
stale: false,
|
||||
error: Some(format!("ElectrumX: {}", err_msg)),
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// handles "created" state containers, resets dependent counters when deps recover,
|
||||
// and sends WebSocket notifications to the UI on failure.
|
||||
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::data_model::{Notification, NotificationLevel, PackageState};
|
||||
use crate::state::StateManager;
|
||||
use crate::webhooks::{self, WebhookEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -67,14 +67,14 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
match id {
|
||||
// Bitcoin-dependent chain
|
||||
"lnd" => &["bitcoin-knots"],
|
||||
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin-knots"],
|
||||
"nbxplorer" => &["bitcoin-knots"],
|
||||
"lnd" => &["bitcoin"],
|
||||
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin"],
|
||||
"nbxplorer" => &["bitcoin"],
|
||||
"btcpay-server" => &["btcpay-db", "nbxplorer"],
|
||||
"mempool-api" => &["mempool-db", "electrumx"],
|
||||
"mempool-web" => &["mempool-api"],
|
||||
"fedimint" => &["bitcoin-knots"],
|
||||
"fedimint-gateway" => &["bitcoin-knots", "fedimint"],
|
||||
"fedimint" => &["bitcoin"],
|
||||
"fedimint-gateway" => &["bitcoin", "fedimint"],
|
||||
|
||||
// IndeedHub stack
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
|
||||
@@ -88,7 +88,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
"penpot-frontend" => &["penpot-backend"],
|
||||
|
||||
// UI containers
|
||||
"bitcoin-ui" => &["bitcoin-knots"],
|
||||
"bitcoin-ui" => &["bitcoin"],
|
||||
"lnd-ui" => &["lnd"],
|
||||
"electrs-ui" => &["electrumx"],
|
||||
|
||||
@@ -103,6 +103,16 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
|
||||
return true;
|
||||
}
|
||||
for dep in deps {
|
||||
if *dep == "bitcoin" {
|
||||
let bitcoin_running = containers.iter().any(|c| {
|
||||
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
|
||||
matches!(c_id, "bitcoin" | "bitcoin-knots" | "bitcoin-core") && c.state == "running"
|
||||
});
|
||||
if !bitcoin_running {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Check both plain name and archy- prefixed name
|
||||
let dep_running = containers.iter().any(|c| {
|
||||
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
|
||||
@@ -115,6 +125,24 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn conflicting_bitcoin_variant(name: &str) -> Option<&'static str> {
|
||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||
"bitcoin-core" => Some("bitcoin-knots"),
|
||||
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_running_bitcoin_conflict(name: &str, containers: &[ContainerHealth]) -> bool {
|
||||
let Some(conflict) = conflicting_bitcoin_variant(name) else {
|
||||
return false;
|
||||
};
|
||||
containers.iter().any(|c| {
|
||||
let id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
|
||||
id == conflict && c.state == "running"
|
||||
})
|
||||
}
|
||||
|
||||
/// Track restart attempts per container with exponential backoff and stability reset.
|
||||
struct RestartTracker {
|
||||
attempts: HashMap<String, u32>,
|
||||
@@ -539,6 +567,16 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
debug!("Skipping uninstalled container: {}", container.name);
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
pkg.state,
|
||||
PackageState::Starting | PackageState::Stopping | PackageState::Restarting
|
||||
) {
|
||||
debug!(
|
||||
"Skipping container during package lifecycle transition: {} ({:?})",
|
||||
container.name, pkg.state
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Orphan: container exists in podman but archipelago has
|
||||
// no package_data entry for it. Common after a variant
|
||||
@@ -650,6 +688,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if has_running_bitcoin_conflict(&container.name, &containers) {
|
||||
debug!(
|
||||
"Skipping auto-restart for {} because the other Bitcoin implementation is running",
|
||||
container.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// When transitioning to a higher tier, wait briefly for previous tier to stabilize
|
||||
if let Some(prev) = prev_tier {
|
||||
if tier > prev {
|
||||
@@ -916,7 +962,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_container_dependencies() {
|
||||
assert!(container_dependencies("lnd").contains(&"bitcoin-knots"));
|
||||
assert!(container_dependencies("lnd").contains(&"bitcoin"));
|
||||
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-postgres"));
|
||||
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-redis"));
|
||||
assert!(container_dependencies("mempool-api").contains(&"mempool-db"));
|
||||
@@ -957,6 +1003,59 @@ mod tests {
|
||||
assert!(!deps_are_running("indeedhub-api", &partial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitcoin_dependency_accepts_core_or_knots() {
|
||||
let core = vec![ContainerHealth {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(deps_are_running("lnd", &core));
|
||||
|
||||
let knots = vec![ContainerHealth {
|
||||
name: "bitcoin-knots".into(),
|
||||
app_id: "bitcoin-knots".into(),
|
||||
state: "running".into(),
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(deps_are_running("fedimint", &knots));
|
||||
|
||||
let stopped = vec![ContainerHealth {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
healthy: false,
|
||||
}];
|
||||
assert!(!deps_are_running("electrumx", &stopped));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitcoin_conflict_detection() {
|
||||
let containers = vec![ContainerHealth {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
healthy: true,
|
||||
}];
|
||||
|
||||
assert!(has_running_bitcoin_conflict("bitcoin-knots", &containers));
|
||||
assert!(!has_running_bitcoin_conflict("bitcoin-core", &containers));
|
||||
assert!(!has_running_bitcoin_conflict("lnd", &containers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitcoin_conflict_ignores_stopped_sibling() {
|
||||
let containers = vec![ContainerHealth {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
healthy: false,
|
||||
}];
|
||||
|
||||
assert!(!has_running_bitcoin_conflict("bitcoin-knots", &containers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_tier_core() {
|
||||
assert_eq!(container_tier("bitcoin-knots"), StartupTier::CoreInfra);
|
||||
|
||||
@@ -137,44 +137,39 @@ async fn main() -> Result<()> {
|
||||
// Write PID marker early so we can detect crashes on next startup
|
||||
crash_recovery::write_pid_marker(&config.data_dir).await?;
|
||||
|
||||
// Crash recovery runs in background so health endpoint is available immediately
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
// Check if previous instance shut down cleanly
|
||||
match crash_recovery::check_for_crash(&data_dir).await {
|
||||
Ok(Some(containers)) => {
|
||||
info!(
|
||||
"🔧 Recovering {} containers from previous crash...",
|
||||
containers.len()
|
||||
);
|
||||
let report = crash_recovery::recover_containers(&containers).await;
|
||||
info!(
|
||||
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
|
||||
report.recovered, report.total, report.failed
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Crash recovery check failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Start any stopped containers (handles clean reboot)
|
||||
// Skips user-stopped containers, uses tier ordering
|
||||
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
|
||||
if boot_report.total > 0 {
|
||||
info!(
|
||||
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
|
||||
boot_report.recovered, boot_report.total, boot_report.failed
|
||||
);
|
||||
}
|
||||
|
||||
// Signal to health monitor that boot recovery is done
|
||||
crash_recovery::mark_recovery_complete();
|
||||
});
|
||||
// Run crash recovery before starting the manifest reconciler. Both paths
|
||||
// mutate Podman; running them concurrently can corrupt transient runtime
|
||||
// state and leave netavark/conmon unable to start containers.
|
||||
match crash_recovery::check_for_crash(&config.data_dir).await {
|
||||
Ok(Some(containers)) => {
|
||||
info!(
|
||||
"🔧 Recovering {} containers from previous crash...",
|
||||
containers.len()
|
||||
);
|
||||
let report = crash_recovery::recover_containers(&containers).await;
|
||||
info!(
|
||||
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
|
||||
report.recovered, report.total, report.failed
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Crash recovery check failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Start any stopped containers (handles clean reboot). This remains
|
||||
// synchronous for the same reason: no concurrent reconciler during Podman
|
||||
// startup/recovery operations.
|
||||
let boot_report = crash_recovery::start_stopped_containers(&config.data_dir).await;
|
||||
if boot_report.total > 0 {
|
||||
info!(
|
||||
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
|
||||
boot_report.recovered, boot_report.total, boot_report.failed
|
||||
);
|
||||
}
|
||||
crash_recovery::mark_recovery_complete();
|
||||
|
||||
// Construct the container orchestrator once. In prod mode we load the
|
||||
// on-disk app manifests, do an initial adoption pass, and spawn the
|
||||
// BootReconciler loop (Step 5/6 of the rust-orchestrator migration).
|
||||
|
||||
@@ -13,8 +13,8 @@ use std::path::Path;
|
||||
const RESERVED_PORTS: &[u16] = &[
|
||||
80, 443, 81, // HTTP/HTTPS
|
||||
8332, 8333, 8334, // Bitcoin RPC/P2P
|
||||
9735, 10009, 8080, // LND P2P, gRPC, REST
|
||||
8081, // LND UI (archy-lnd-ui)
|
||||
9735, 10009, 8080, // LND P2P, gRPC, REST
|
||||
18083, // LND UI (archy-lnd-ui)
|
||||
4080, 8999, 50001, // Mempool stack
|
||||
23000, // BTCPay
|
||||
8173, 8174, 8175, // Fedimint
|
||||
|
||||
@@ -313,6 +313,7 @@ impl Server {
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
let state = state_manager.clone();
|
||||
let identity_clone = identity.clone();
|
||||
let data_dir = config.data_dir.clone();
|
||||
let scan_kick = api_handler.rpc_handler().scan_kick();
|
||||
let scan_tick = api_handler.rpc_handler().scan_tick();
|
||||
|
||||
@@ -334,6 +335,7 @@ impl Server {
|
||||
&scanner,
|
||||
&state,
|
||||
identity_clone.as_ref(),
|
||||
&data_dir,
|
||||
&mut absence_tracker,
|
||||
&mut transitional_since,
|
||||
)
|
||||
@@ -371,6 +373,7 @@ impl Server {
|
||||
&scanner,
|
||||
&state,
|
||||
identity_clone.as_ref(),
|
||||
&data_dir,
|
||||
&mut absence_tracker,
|
||||
&mut transitional_since,
|
||||
)
|
||||
@@ -865,8 +868,19 @@ fn merge_preserving_transitional(
|
||||
existing: &crate::data_model::PackageDataEntry,
|
||||
fresh: &crate::data_model::PackageDataEntry,
|
||||
) -> crate::data_model::PackageDataEntry {
|
||||
let state = match (&existing.state, &fresh.state) {
|
||||
// Removing with a live running container is stale: uninstall either
|
||||
// failed or Archipelago restarted before the spawned task could revert
|
||||
// state. Let the scanner recover the UI immediately instead of
|
||||
// keeping the app wedged in Removing for 20 minutes.
|
||||
(crate::data_model::PackageState::Removing, crate::data_model::PackageState::Running) => {
|
||||
fresh.state.clone()
|
||||
}
|
||||
_ => existing.state.clone(),
|
||||
};
|
||||
|
||||
crate::data_model::PackageDataEntry {
|
||||
state: existing.state.clone(),
|
||||
state,
|
||||
// install_progress and uninstall_stage are also owned by the
|
||||
// initiating op (same reason as state) — keep them.
|
||||
install_progress: existing.install_progress.clone(),
|
||||
@@ -885,10 +899,18 @@ async fn scan_and_update_packages(
|
||||
scanner: &DockerPackageScanner,
|
||||
state: &StateManager,
|
||||
identity: &NodeIdentity,
|
||||
data_dir: &std::path::Path,
|
||||
absence_tracker: &mut HashMap<String, u32>,
|
||||
transitional_since: &mut HashMap<String, Instant>,
|
||||
) -> Result<()> {
|
||||
let packages = scanner.scan_containers().await?;
|
||||
let mut packages = scanner.scan_containers().await?;
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
||||
for (id, pkg) in packages.iter_mut() {
|
||||
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
||||
pkg.state = crate::data_model::PackageState::Stopped;
|
||||
pkg.exit_code = None;
|
||||
}
|
||||
}
|
||||
|
||||
let (current_data, _) = state.get_snapshot().await;
|
||||
let tor_addr = docker_packages::read_tor_address("archipelago").await;
|
||||
@@ -992,6 +1014,18 @@ async fn scan_and_update_packages(
|
||||
// owner (spawn_task) is responsible for clearing state, not us.
|
||||
if let Some(entry) = merged.get(&id) {
|
||||
if is_transitional(&entry.state) {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
|
||||
id,
|
||||
entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
|
||||
);
|
||||
merged.remove(&id);
|
||||
transitional_since.remove(&id);
|
||||
changed = true;
|
||||
}
|
||||
absence_tracker.remove(&id);
|
||||
continue;
|
||||
}
|
||||
@@ -1170,6 +1204,15 @@ mod merge_tests {
|
||||
assert_eq!(merged.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_removing_recovers_when_container_is_running() {
|
||||
let existing = make_entry(PackageState::Removing, Some("unknown"));
|
||||
let fresh = make_entry(PackageState::Running, Some("healthy"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh);
|
||||
assert_eq!(merged.state, PackageState::Running);
|
||||
assert_eq!(merged.health.as_deref(), Some("healthy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_transitional_covers_all_variants() {
|
||||
for s in [
|
||||
|
||||
@@ -163,12 +163,40 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let before_order: Vec<String> = list.iter().map(|m| m.url.clone()).collect();
|
||||
force_ovh_update_primary(&mut list);
|
||||
changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>();
|
||||
if changed {
|
||||
let _ = save_mirrors(data_dir, &list).await;
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
let defaults = default_mirrors();
|
||||
for def in &defaults {
|
||||
if !list.iter().any(|m| m.url == def.url) {
|
||||
list.push(def.clone());
|
||||
}
|
||||
}
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
mirror.label = "Server 2 (tx1138)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
|
||||
fs::create_dir_all(data_dir)
|
||||
.await
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
use crate::manifest::{AppManifest, Dependency};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DependencyError {
|
||||
#[error("Circular dependency detected: {0}")]
|
||||
CircularDependency(String),
|
||||
#[error("Missing dependency: {0}")]
|
||||
MissingDependency(String),
|
||||
#[error("Version conflict: {0}")]
|
||||
VersionConflict(String),
|
||||
}
|
||||
|
||||
pub struct DependencyResolver {
|
||||
manifests: IndexMap<String, AppManifest>,
|
||||
}
|
||||
|
||||
impl DependencyResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manifests: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_manifest(&mut self, manifest: AppManifest) {
|
||||
self.manifests.insert(manifest.app.id.clone(), manifest);
|
||||
}
|
||||
|
||||
pub fn resolve_dependencies(&self, app_id: &str) -> Result<Vec<String>, DependencyError> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut visiting = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
self.resolve_recursive(app_id, &mut visited, &mut visiting, &mut result)?;
|
||||
|
||||
// Result is already in installation order (dependencies first)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve_recursive(
|
||||
&self,
|
||||
app_id: &str,
|
||||
visited: &mut HashSet<String>,
|
||||
visiting: &mut HashSet<String>,
|
||||
result: &mut Vec<String>,
|
||||
) -> Result<(), DependencyError> {
|
||||
if visited.contains(app_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if visiting.contains(app_id) {
|
||||
return Err(DependencyError::CircularDependency(format!(
|
||||
"Circular dependency detected involving: {}",
|
||||
app_id
|
||||
)));
|
||||
}
|
||||
|
||||
visiting.insert(app_id.to_string());
|
||||
|
||||
let manifest = self.manifests.get(app_id).ok_or_else(|| {
|
||||
DependencyError::MissingDependency(format!("App not found: {}", app_id))
|
||||
})?;
|
||||
|
||||
// Resolve all dependencies first
|
||||
for dep in &manifest.app.dependencies {
|
||||
match dep {
|
||||
Dependency::App {
|
||||
app_id: dep_id,
|
||||
version: _,
|
||||
} => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
Dependency::Storage { storage: _ } => {
|
||||
// Storage dependencies are checked but don't require other apps
|
||||
}
|
||||
Dependency::Simple(dep_id) => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.remove(app_id);
|
||||
visited.insert(app_id.to_string());
|
||||
|
||||
if !result.contains(&app_id.to_string()) {
|
||||
result.push(app_id.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_conflicts(&self, app_id: &str) -> Result<(), DependencyError> {
|
||||
let manifest = self.manifests.get(app_id).ok_or_else(|| {
|
||||
DependencyError::MissingDependency(format!("App not found: {}", app_id))
|
||||
})?;
|
||||
|
||||
// Check for port conflicts
|
||||
let mut port_usage: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
for (id, m) in &self.manifests {
|
||||
if id == app_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
for port in &m.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(format!(
|
||||
"Port {} already used by {}",
|
||||
port.host, existing
|
||||
)));
|
||||
}
|
||||
port_usage.insert(port.host, id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new app's ports
|
||||
for port in &manifest.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(format!(
|
||||
"Port {} already used by {}",
|
||||
port.host, existing
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn calculate_resources(&self, app_ids: &[String]) -> ResourceRequirements {
|
||||
let mut total = ResourceRequirements {
|
||||
cpu: 0,
|
||||
memory_mb: 0,
|
||||
disk_gb: 0,
|
||||
};
|
||||
|
||||
for app_id in app_ids {
|
||||
if let Some(manifest) = self.manifests.get(app_id) {
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
total.cpu += cpu;
|
||||
}
|
||||
|
||||
if let Some(memory) = &manifest.app.resources.memory_limit {
|
||||
// Parse memory string (e.g., "1Gi", "512Mi")
|
||||
if let Ok(mb) = parse_memory(memory) {
|
||||
total.memory_mb += mb;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(disk) = &manifest.app.resources.disk_limit {
|
||||
// Parse disk string (e.g., "10Gi", "500Mi")
|
||||
if let Ok(gb) = parse_disk(disk) {
|
||||
total.disk_gb += gb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_memory(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else if s.ends_with("mi") {
|
||||
let num: f64 = s.trim_end_matches("mi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_disk(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else if s.ends_with("ti") {
|
||||
let num: f64 = s.trim_end_matches("ti").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceRequirements {
|
||||
pub cpu: u32,
|
||||
pub memory_mb: u32,
|
||||
pub disk_gb: u32,
|
||||
}
|
||||
|
||||
impl Default for DependencyResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::{AppDefinition, AppManifest, ContainerConfig};
|
||||
|
||||
fn create_test_manifest(id: &str, deps: Vec<Dependency>) -> AppManifest {
|
||||
AppManifest {
|
||||
app: AppDefinition {
|
||||
id: id.to_string(),
|
||||
name: format!("Test {}", id),
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
container: ContainerConfig {
|
||||
image: Some(format!("test/{}:latest", id)),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![],
|
||||
secret_env: vec![],
|
||||
data_uid: None,
|
||||
},
|
||||
dependencies: deps,
|
||||
resources: Default::default(),
|
||||
security: Default::default(),
|
||||
ports: vec![],
|
||||
volumes: vec![],
|
||||
environment: vec![],
|
||||
health_check: None,
|
||||
devices: vec![],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest("app1", vec![]));
|
||||
resolver.add_manifest(create_test_manifest(
|
||||
"app2",
|
||||
vec![Dependency::Simple("app1".to_string())],
|
||||
));
|
||||
|
||||
let deps = resolver.resolve_dependencies("app2").unwrap();
|
||||
assert_eq!(deps, vec!["app1", "app2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest(
|
||||
"app1",
|
||||
vec![Dependency::Simple("app2".to_string())],
|
||||
));
|
||||
resolver.add_manifest(create_test_manifest(
|
||||
"app2",
|
||||
vec![Dependency::Simple("app1".to_string())],
|
||||
));
|
||||
|
||||
let result = resolver.resolve_dependencies("app1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod bitcoin_simulator;
|
||||
pub mod dependency_resolver;
|
||||
pub mod health_monitor;
|
||||
pub mod manifest;
|
||||
pub mod podman_client;
|
||||
@@ -7,13 +6,14 @@ pub mod port_manager;
|
||||
pub mod runtime;
|
||||
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use dependency_resolver::DependencyResolver;
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, HealthCheck, HostFacts,
|
||||
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
|
||||
Volume,
|
||||
};
|
||||
pub use podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
||||
pub use podman_client::{
|
||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||
};
|
||||
pub use port_manager::{PortError, PortManager};
|
||||
pub use runtime::{AutoRuntime, ContainerRuntime, DockerRuntime, PodmanRuntime};
|
||||
|
||||
@@ -619,6 +619,14 @@ impl ContainerConfig {
|
||||
let mut out = Vec::with_capacity(self.secret_env.len());
|
||||
for e in &self.secret_env {
|
||||
let v = provider.read(&e.secret_file)?;
|
||||
// An empty secret produces e.g. `-rpcpassword=` and crashes
|
||||
// the container on auth before logs are useful. Fail loud.
|
||||
if v.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"secret_env {} resolved to empty value (file: {})",
|
||||
e.key, e.secret_file
|
||||
)));
|
||||
}
|
||||
out.push(format!("{}={}", e.key, v));
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1051,6 +1059,36 @@ app:
|
||||
assert_eq!(out[1], "FM_GATEWAY_PASSWORD=supersecret2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_env_rejects_empty_value() {
|
||||
let c = ContainerConfig {
|
||||
image: Some("x:latest".to_string()),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![],
|
||||
secret_env: vec![SecretEnv {
|
||||
key: "BITCOIN_RPC_PASS".to_string(),
|
||||
secret_file: "bitcoin-rpc-password".to_string(),
|
||||
}],
|
||||
data_uid: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]),
|
||||
};
|
||||
let err = c.resolve_secret_env(&p).unwrap_err();
|
||||
match err {
|
||||
ManifestError::Invalid(msg) => assert!(
|
||||
msg.contains("BITCOIN_RPC_PASS") && msg.contains("bitcoin-rpc-password"),
|
||||
"msg should name the env key + file: {msg}"
|
||||
),
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_every_real_manifest() {
|
||||
let app_manifests = list_repo_manifests();
|
||||
|
||||
@@ -109,7 +109,7 @@ impl PodmanClient {
|
||||
pub fn lan_address_for(name: &str) -> Option<String> {
|
||||
let url = match name {
|
||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
|
||||
"homeassistant" => "http://localhost:8123",
|
||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||
"btcpay-server" => "http://localhost:23000",
|
||||
@@ -257,7 +257,11 @@ impl PodmanClient {
|
||||
pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
|
||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull").arg(image);
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(image);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600), // 10 min for large images
|
||||
@@ -357,20 +361,12 @@ impl PodmanClient {
|
||||
);
|
||||
}
|
||||
|
||||
let net_mode = if let Some(n) = manifest.app.container.network.as_ref() {
|
||||
if n.is_empty() {
|
||||
"bridge"
|
||||
} else {
|
||||
n.as_str()
|
||||
}
|
||||
} else {
|
||||
match manifest.app.security.network_policy.as_str() {
|
||||
"host" => "host",
|
||||
_ => "bridge",
|
||||
}
|
||||
};
|
||||
let (net_mode, custom_network) = podman_network_settings(
|
||||
manifest.app.container.network.as_deref(),
|
||||
manifest.app.security.network_policy.as_str(),
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"name": name,
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
@@ -378,7 +374,10 @@ impl PodmanClient {
|
||||
"env": env_map,
|
||||
"entrypoint": manifest.app.container.entrypoint.clone(),
|
||||
"command": manifest.app.container.custom_args.clone(),
|
||||
"hostadd": ["host.containers.internal:host-gateway"],
|
||||
"hostadd": [
|
||||
"host.containers.internal:host-gateway",
|
||||
"host.archipelago:10.89.0.1",
|
||||
],
|
||||
"devices": manifest.app.devices.iter().map(|d| {
|
||||
serde_json::json!({"path": d})
|
||||
}).collect::<Vec<_>>(),
|
||||
@@ -393,6 +392,14 @@ impl PodmanClient {
|
||||
"nsmode": net_mode
|
||||
},
|
||||
});
|
||||
if let Some(network) = custom_network {
|
||||
body.as_object_mut()
|
||||
.expect("container create body is a JSON object")
|
||||
.insert(
|
||||
"networks".to_string(),
|
||||
serde_json::json!({ network: { "aliases": [name] } }),
|
||||
);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.api_request("POST", "libpod/containers/create", Some(body), LONG_TIMEOUT)
|
||||
@@ -601,6 +608,35 @@ impl PodmanClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registries we ship with as `--tls-verify=false` because they're internal
|
||||
/// HTTP mirrors. Add a host:port here only if it's a controlled mirror that
|
||||
/// the fleet trusts and operators won't ever paste a malicious URL into.
|
||||
const INSECURE_REGISTRY_HOSTS: &[&str] = &["146.59.87.168:3000"];
|
||||
|
||||
pub fn image_uses_insecure_registry(image: &str) -> bool {
|
||||
image
|
||||
.split('/')
|
||||
.next()
|
||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||
}
|
||||
|
||||
fn podman_network_settings(
|
||||
network: Option<&str>,
|
||||
network_policy: &str,
|
||||
) -> (&'static str, Option<String>) {
|
||||
match network {
|
||||
Some("") => ("bridge", None),
|
||||
Some("host") => ("host", None),
|
||||
Some("bridge") => ("bridge", None),
|
||||
Some("none") => ("none", None),
|
||||
Some("slirp4netns") => ("slirp4netns", None),
|
||||
Some("private") => ("private", None),
|
||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||
None if network_policy == "host" => ("host", None),
|
||||
None => ("bridge", None),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||
@@ -673,6 +709,49 @@ fn parse_memory_limit(limit: &str) -> Option<i64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insecure_registry_detection_matches_http_mirrors_only() {
|
||||
assert!(image_uses_insecure_registry(
|
||||
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
|
||||
));
|
||||
// The legacy Hetzner mirror at 23.182.128.160 was decommissioned and
|
||||
// is no longer trusted — it must NOT bypass TLS even if a stale
|
||||
// registry config still references it.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
|
||||
));
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"git.tx1138.com/lfg2025/bitcoin-knots:latest"
|
||||
));
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"docker.io/library/nginx:latest"
|
||||
));
|
||||
// Spoofing immune: an attacker host that prefixes the trusted IP
|
||||
// string into its own URL still has the attacker host in the
|
||||
// registry-host slot, so it does NOT match.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"evil.example:80/146.59.87.168:3000/lfg2025/x:latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||
assert_eq!(
|
||||
podman_network_settings(Some("archy-net"), "isolated"),
|
||||
("bridge", Some("archy-net".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
podman_network_settings(Some("host"), "isolated"),
|
||||
("host", None)
|
||||
);
|
||||
assert_eq!(
|
||||
podman_network_settings(Some(""), "isolated"),
|
||||
("bridge", None)
|
||||
);
|
||||
assert_eq!(podman_network_settings(None, "host"), ("host", None));
|
||||
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_iec_binary_suffixes() {
|
||||
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
||||
|
||||
@@ -104,7 +104,20 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
|
||||
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||
self.client.list_containers().await
|
||||
match self.client.list_containers().await {
|
||||
Ok(containers) => Ok(containers),
|
||||
Err(api_err) => {
|
||||
let output = self.podman_cli(&["ps", "-a", "--format", "json"]).await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(
|
||||
api_err.context(format!("podman ps fallback failed: {}", stderr.trim()))
|
||||
);
|
||||
}
|
||||
parse_podman_ps_json(&output.stdout)
|
||||
.with_context(|| format!("podman API list failed: {api_err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
|
||||
@@ -147,6 +160,83 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_podman_ps_json(stdout: &[u8]) -> Result<Vec<ContainerStatus>> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
if text.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&text)?;
|
||||
Ok(containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let name = c
|
||||
.get("Names")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| c.get("Names").and_then(|v| v.as_str()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let status = c.get("Status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
ContainerStatus {
|
||||
id: c
|
||||
.get("Id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
name: name.clone(),
|
||||
state: ContainerState::from(state),
|
||||
health: parse_health_from_status(status),
|
||||
exit_code: c.get("ExitCode").and_then(|v| v.as_i64()).map(|c| c as i32),
|
||||
started_at: c
|
||||
.get("StartedAt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
image: c
|
||||
.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_podman_ps_ports(c.get("Ports")),
|
||||
lan_address: PodmanClient::lan_address_for(&name),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_podman_ps_ports(ports: Option<&serde_json::Value>) -> Vec<String> {
|
||||
ports
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|ports| {
|
||||
ports
|
||||
.iter()
|
||||
.filter_map(|port| {
|
||||
let host = port.get("host_port").and_then(|v| v.as_u64())?;
|
||||
let container = port.get("container_port").and_then(|v| v.as_u64())?;
|
||||
let proto = port
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("tcp");
|
||||
Some(format!("0.0.0.0:{host}->{container}/{proto}"))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// Build the argv for `podman build` from a BuildConfig.
|
||||
///
|
||||
/// Extracted so it can be unit-tested without actually invoking podman.
|
||||
@@ -646,4 +736,36 @@ mod tests {
|
||||
let args = build_args_for_podman(&c);
|
||||
assert_eq!(args.last().unwrap(), "/final/context");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_podman_ps_json_handles_cli_output() {
|
||||
let stdout = br#"[
|
||||
{
|
||||
"Id": "abc123",
|
||||
"Names": ["mempool"],
|
||||
"Image": "docker.io/mempool/frontend:latest",
|
||||
"State": "running",
|
||||
"Status": "Up 2 minutes (healthy)",
|
||||
"Created": "2026-05-03T00:00:00Z",
|
||||
"StartedAt": "2026-05-03T00:01:00Z",
|
||||
"ExitCode": 0,
|
||||
"Ports": [
|
||||
{
|
||||
"host_port": 4080,
|
||||
"container_port": 8080,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
let containers = parse_podman_ps_json(stdout).unwrap();
|
||||
assert_eq!(containers.len(), 1);
|
||||
assert_eq!(containers[0].id, "abc123");
|
||||
assert_eq!(containers[0].name, "mempool");
|
||||
assert_eq!(containers[0].state, ContainerState::Running);
|
||||
assert_eq!(containers[0].health.as_deref(), Some("healthy"));
|
||||
assert_eq!(containers[0].exit_code, Some(0));
|
||||
assert_eq!(containers[0].ports, vec!["0.0.0.0:4080->8080/tcp"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ See the Architecture documentation for detailed system information.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Debian Linux Base**: Stable Debian 13 (Trixie) distribution
|
||||
- **Debian Linux Base**: Debian 13 (Trixie) with security updates applied during ISO/install creation
|
||||
- **Podman**: Container runtime for apps (rootless by default)
|
||||
- **Archipelago Backend**: Rust-based API server
|
||||
- **Archipelago Frontend**: Vue.js web interface
|
||||
@@ -44,7 +44,7 @@ See the Architecture documentation for detailed system information.
|
||||
|
||||
## Build Output
|
||||
|
||||
- `results/archipelago-debian-13-x86_64.iso` - Bootable hybrid ISO image
|
||||
- `results/archipelago-installer-x86_64.iso` - Bootable hybrid ISO image
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ RUN echo "deb http://deb.debian.org/debian trixie main non-free-firmware" > /etc
|
||||
rm -f /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
# Install all packages we need including nginx, podman, tor, and openssl (for self-signed certs)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends \
|
||||
${LINUX_IMAGE_PKG} \
|
||||
${GRUB_EFI_PKG} \
|
||||
${GRUB_EFI_SIGNED_PKG} \
|
||||
@@ -359,13 +359,13 @@ RUN find /usr/share/doc -depth -type f ! -name copyright -delete 2>/dev/null ||
|
||||
# Install Tailscale from official repo
|
||||
RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null && \
|
||||
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list && \
|
||||
apt-get update && apt-get install -y --no-install-recommends tailscale && \
|
||||
apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends tailscale && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install FIPS mesh daemon from the .deb built in stage 1. apt-get install
|
||||
# resolves dependencies from trixie so a cross-dist build still lands cleanly.
|
||||
COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends /tmp/fips.deb && \
|
||||
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends /tmp/fips.deb && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb
|
||||
|
||||
# Configure locale
|
||||
@@ -693,6 +693,7 @@ mount --bind /proc /installer/proc
|
||||
mount --bind /sys /installer/sys
|
||||
mount --bind /dev /installer/dev
|
||||
chroot /installer apt-get update -qq
|
||||
chroot /installer apt-get -y -qq full-upgrade
|
||||
chroot /installer apt-get install -y --no-install-recommends live-boot live-boot-initramfs-tools
|
||||
chroot /installer apt-get clean
|
||||
umount /installer/dev 2>/dev/null || true
|
||||
|
||||
@@ -157,6 +157,9 @@ EOF
|
||||
echo "📥 Updating package lists..."
|
||||
chroot /mnt/archipelago apt-get update
|
||||
|
||||
echo "🔒 Applying Debian security updates..."
|
||||
chroot /mnt/archipelago apt-get -y full-upgrade
|
||||
|
||||
echo "📦 Installing kernel and bootloader..."
|
||||
chroot /mnt/archipelago apt-get install -y linux-image-amd64 grub-efi-amd64 grub-efi-amd64-signed shim-signed
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Archipelago Debian installer ISO.
|
||||
#
|
||||
# The historical ISO builder remains archived because OTA tarballs are the
|
||||
# normal release path. This wrapper keeps the documented ISO command working
|
||||
# by running a temporary active-layout copy of that builder with fixed paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ARCHIVED_BUILDER="$SCRIPT_DIR/_archived/build-auto-installer-iso.sh"
|
||||
TMP_DIR="$(mktemp -d -t archipelago-iso-builder.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ ! -f "$ARCHIVED_BUILDER" ]; then
|
||||
echo "Archived ISO builder not found: $ARCHIVED_BUILDER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_BUILDER="$TMP_DIR/build-auto-installer-iso.sh"
|
||||
cp "$ARCHIVED_BUILDER" "$TMP_BUILDER"
|
||||
|
||||
# The archived builder lived one directory deeper at image-recipe/_archived/.
|
||||
# Rewrite only path expressions that were relative to that old location.
|
||||
perl -0pi -e 's#SCRIPT_DIR="\$\(cd "\$\(dirname "\$0"\)" && pwd\)"#SCRIPT_DIR="__ARCHIPELAGO_IMAGE_RECIPE_DIR__"#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./\.\./scripts#\$SCRIPT_DIR/../scripts#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./configs#\$SCRIPT_DIR/configs#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./docker#\$SCRIPT_DIR/../docker#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./neode-ui#\$SCRIPT_DIR/../neode-ui#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./web#\$SCRIPT_DIR/../web#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./demo#\$SCRIPT_DIR/../demo#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./apps#\$SCRIPT_DIR/../apps#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#\$SCRIPT_DIR/\.\./core#\$SCRIPT_DIR/../core#g' "$TMP_BUILDER"
|
||||
perl -0pi -e 's#"\$\(dirname "\$0"\)/\.\./\.\./scripts#"$(dirname "$0")/../scripts#g' "$TMP_BUILDER"
|
||||
|
||||
perl -0pi -e "s#__ARCHIPELAGO_IMAGE_RECIPE_DIR__#${SCRIPT_DIR}#g" "$TMP_BUILDER"
|
||||
|
||||
chmod +x "$TMP_BUILDER"
|
||||
exec bash "$TMP_BUILDER" "$@"
|
||||
@@ -544,7 +544,7 @@ server {
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -102,7 +102,7 @@ location /app/endurain/ {
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -20,13 +20,18 @@ if [ -z "$1" ]; then
|
||||
fi
|
||||
|
||||
USB_DISK="$1"
|
||||
ISO_FILE="$SCRIPT_DIR/results/archipelago-debian-13-x86_64.iso"
|
||||
ISO_FILE="${ARCHIPELAGO_ISO:-}"
|
||||
if [ -z "$ISO_FILE" ]; then
|
||||
ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-x86_64.iso"
|
||||
[ -f "$ISO_FILE" ] || ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-unbundled-x86_64.iso"
|
||||
fi
|
||||
WORK_DIR="$SCRIPT_DIR/build/usb-extract"
|
||||
|
||||
if [ ! -f "$ISO_FILE" ]; then
|
||||
echo "❌ ISO not found: $ISO_FILE"
|
||||
echo ""
|
||||
echo "Build the ISO first with: ./build-debian-iso.sh"
|
||||
echo "Or set ARCHIPELAGO_ISO=/path/to/archipelago-installer-x86_64.iso"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,12 +17,17 @@ fi
|
||||
|
||||
USB_DISK="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ISO_FILE="$SCRIPT_DIR/results/archipelago-debian-13-x86_64.iso"
|
||||
ISO_FILE="${ARCHIPELAGO_ISO:-}"
|
||||
if [ -z "$ISO_FILE" ]; then
|
||||
ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-x86_64.iso"
|
||||
[ -f "$ISO_FILE" ] || ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-unbundled-x86_64.iso"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ISO_FILE" ]; then
|
||||
echo "❌ ISO not found: $ISO_FILE"
|
||||
echo ""
|
||||
echo "Build the ISO first with: ./build-debian-iso.sh"
|
||||
echo "Or set ARCHIPELAGO_ISO=/path/to/archipelago-installer-x86_64.iso"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dist-ssr
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
._*
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const APP_ID = process.env.ARCHY_APP_ID ?? 'lnd'
|
||||
const APP_TITLE = process.env.ARCHY_APP_TITLE ?? APP_ID
|
||||
const APP_CARD_TITLE = process.env.ARCHY_APP_CARD_TITLE ?? APP_TITLE
|
||||
const EXPECTED_URL = process.env.ARCHY_EXPECTED_LAUNCH_URL
|
||||
const EXPECTED_URL_PATTERN = process.env.ARCHY_EXPECTED_LAUNCH_URL_PATTERN
|
||||
const EXPECTED_BODY_PATTERN = process.env.ARCHY_EXPECTED_BODY_PATTERN ?? 'Connect Your Wallet|lndconnect|REST|gRPC'
|
||||
const EXPECTED_MODE = process.env.ARCHY_EXPECTED_LAUNCH_MODE ?? 'popup'
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]').first().click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
|
||||
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
|
||||
|
||||
await login(page)
|
||||
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const appCard = page.locator('[data-controller-container]', {
|
||||
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
|
||||
}).first()
|
||||
await appCard.waitFor({ timeout: 30_000 })
|
||||
const launchButton = appCard.locator('[data-controller-launch-btn], button:has-text("Launch")').first()
|
||||
await launchButton.waitFor({ timeout: 20_000 })
|
||||
|
||||
if (EXPECTED_MODE === 'panel') {
|
||||
await launchButton.click()
|
||||
const expected = new URL(EXPECTED_URL!, baseURL)
|
||||
const frameSelector = `iframe[src^="${expected.toString().replace(/\/$/, '')}"]`
|
||||
await expect(page.locator(frameSelector).first()).toBeVisible({ timeout: 20_000 })
|
||||
const frame = page.frameLocator(frameSelector).first()
|
||||
await expect(frame.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 30_000 })
|
||||
return
|
||||
}
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 15_000 })
|
||||
await launchButton.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded', { timeout: 20_000 })
|
||||
|
||||
assertLaunchUrl(popup.url(), baseURL)
|
||||
await expect(popup.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 20_000 })
|
||||
})
|
||||
|
||||
function assertLaunchUrl(actual: string, baseURL: string | undefined) {
|
||||
if (EXPECTED_URL_PATTERN) {
|
||||
expect(actual).toMatch(new RegExp(EXPECTED_URL_PATTERN))
|
||||
} else {
|
||||
const expected = new URL(EXPECTED_URL!, baseURL)
|
||||
expect(actual).toBe(expected.toString())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.50-alpha",
|
||||
"version": "1.7.53-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.50-alpha",
|
||||
"version": "1.7.53-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
@@ -2966,9 +2966,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
@@ -2998,9 +2998,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
@@ -3019,9 +3019,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
@@ -3045,10 +3045,37 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz",
|
||||
"integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.18.6",
|
||||
"@rollup/pluginutils": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@types/babel__core": "^7.1.9",
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/babel__core": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
||||
"integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
|
||||
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3070,19 +3097,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-terser": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
|
||||
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
|
||||
"node_modules/@rollup/plugin-replace": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
|
||||
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"serialize-javascript": "^6.0.1",
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
"magic-string": "^0.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-terser": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
|
||||
"integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"smob": "^1.0.0",
|
||||
"terser": "^5.17.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.0.0||^3.0.0||^4.0.0"
|
||||
@@ -3124,9 +3173,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3486,27 +3535,20 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||
"integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==",
|
||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||
"version": "3.0.0-pre1",
|
||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||
"integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ejs": "^3.1.6",
|
||||
"json5": "^2.2.0",
|
||||
"magic-string": "^0.25.0",
|
||||
"string.prototype.matchall": "^4.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
"ejs": "^3.1.10",
|
||||
"json5": "^2.2.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"string.prototype.matchall": "^4.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
@@ -4238,9 +4280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4802,9 +4844,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4921,15 +4963,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
"es-define-property": "^1.0.0",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6117,9 +6159,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -6194,9 +6236,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docker-modem": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
|
||||
"integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==",
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
|
||||
"integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -6210,16 +6252,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dockerode": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz",
|
||||
"integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==",
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
|
||||
"integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"@grpc/grpc-js": "^1.11.1",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"docker-modem": "^5.0.6",
|
||||
"docker-modem": "^5.0.7",
|
||||
"protobufjs": "^7.3.2",
|
||||
"tar-fs": "^2.1.4",
|
||||
"uuid": "^10.0.0"
|
||||
@@ -6229,9 +6271,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -6349,9 +6391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.1",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||
"integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
"integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6570,6 +6612,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eta": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz",
|
||||
"integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/bgub/eta?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -8214,13 +8269,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
@@ -8466,9 +8514,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
@@ -8763,9 +8811,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -8799,9 +8847,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8904,9 +8952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -9086,23 +9134,23 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
|
||||
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
@@ -9323,16 +9371,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -9661,15 +9699,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
"integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
|
||||
"integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.2",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"call-bind": "^1.0.9",
|
||||
"call-bound": "^1.0.4",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"isarray": "^2.0.5"
|
||||
},
|
||||
@@ -9811,13 +9849,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
|
||||
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
@@ -10175,14 +10213,6 @@
|
||||
"webidl-conversions": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/sourcemap-codec": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
|
||||
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
|
||||
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
@@ -10731,9 +10761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10832,9 +10862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11328,9 +11358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11490,9 +11520,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11576,9 +11606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11891,30 +11921,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-background-sync": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
|
||||
"integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz",
|
||||
"integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^7.0.1",
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-broadcast-update": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz",
|
||||
"integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz",
|
||||
"integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz",
|
||||
"integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz",
|
||||
"integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11922,39 +11952,39 @@
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/runtime": "^7.11.2",
|
||||
"@rollup/plugin-babel": "^5.2.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-replace": "^2.4.1",
|
||||
"@rollup/plugin-terser": "^0.4.3",
|
||||
"@surma/rollup-plugin-off-main-thread": "^2.2.3",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-replace": "^6.0.3",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1",
|
||||
"ajv": "^8.6.0",
|
||||
"common-tags": "^1.8.0",
|
||||
"eta": "^4.5.1",
|
||||
"fast-json-stable-stringify": "^2.1.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"glob": "^11.0.1",
|
||||
"lodash": "^4.17.20",
|
||||
"pretty-bytes": "^5.3.0",
|
||||
"rollup": "^2.79.2",
|
||||
"rollup": "^4.53.3",
|
||||
"source-map": "^0.8.0-beta.0",
|
||||
"stringify-object": "^3.3.0",
|
||||
"strip-comments": "^2.0.1",
|
||||
"tempy": "^0.6.0",
|
||||
"upath": "^1.2.0",
|
||||
"workbox-background-sync": "7.4.0",
|
||||
"workbox-broadcast-update": "7.4.0",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-expiration": "7.4.0",
|
||||
"workbox-google-analytics": "7.4.0",
|
||||
"workbox-navigation-preload": "7.4.0",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"workbox-range-requests": "7.4.0",
|
||||
"workbox-recipes": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0",
|
||||
"workbox-streams": "7.4.0",
|
||||
"workbox-sw": "7.4.0",
|
||||
"workbox-window": "7.4.0"
|
||||
"workbox-background-sync": "7.4.1",
|
||||
"workbox-broadcast-update": "7.4.1",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-expiration": "7.4.1",
|
||||
"workbox-google-analytics": "7.4.1",
|
||||
"workbox-navigation-preload": "7.4.1",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-range-requests": "7.4.1",
|
||||
"workbox-recipes": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"workbox-streams": "7.4.1",
|
||||
"workbox-sw": "7.4.1",
|
||||
"workbox-window": "7.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -11970,69 +12000,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
"integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.10.4",
|
||||
"@rollup/pluginutils": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@types/babel__core": "^7.1.9",
|
||||
"rollup": "^1.20.0||^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/babel__core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/@rollup/plugin-replace": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
|
||||
"integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^3.1.0",
|
||||
"magic-string": "^0.25.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0 || ^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/@rollup/pluginutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
|
||||
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "0.0.39",
|
||||
"estree-walker": "^1.0.1",
|
||||
"picomatch": "^2.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/@types/estree": {
|
||||
"version": "0.0.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
|
||||
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -12044,9 +12011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12056,13 +12023,6 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/estree-walker": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
|
||||
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/glob": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||
@@ -12114,16 +12074,6 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/magic-string": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
@@ -12170,157 +12120,141 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/rollup": {
|
||||
"version": "2.80.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-cacheable-response": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
|
||||
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz",
|
||||
"integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-core": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
|
||||
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
||||
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-expiration": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
|
||||
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz",
|
||||
"integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^7.0.1",
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-google-analytics": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz",
|
||||
"integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz",
|
||||
"integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-background-sync": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0"
|
||||
"workbox-background-sync": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-navigation-preload": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz",
|
||||
"integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz",
|
||||
"integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-precaching": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
|
||||
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
||||
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0"
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-range-requests": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz",
|
||||
"integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz",
|
||||
"integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-recipes": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz",
|
||||
"integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz",
|
||||
"integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-expiration": "7.4.0",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0"
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-expiration": "7.4.1",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-routing": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
|
||||
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
||||
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-strategies": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
|
||||
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
||||
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-streams": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz",
|
||||
"integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz",
|
||||
"integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-routing": "7.4.0"
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-routing": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-sw": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz",
|
||||
"integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz",
|
||||
"integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-window": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
|
||||
"integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz",
|
||||
"integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2",
|
||||
"workbox-core": "7.4.0"
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.50-alpha",
|
||||
"version": "1.7.53-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://192.168.1.228',
|
||||
baseURL: process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'off',
|
||||
|
||||
|
After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 241 KiB |
@@ -85,7 +85,7 @@
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||
"icon": "/assets/img/app-icons/electrumx.webp",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
|
||||
@@ -29,6 +29,11 @@ describe('useAppLauncherStore', () => {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 1024,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts closed with empty state', () => {
|
||||
@@ -50,6 +55,40 @@ describe('useAppLauncherStore', () => {
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses route-based app sessions on mobile instead of panel mode', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.openSession('indeedhub')
|
||||
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' } })
|
||||
})
|
||||
|
||||
it('normalizes localhost launch URLs to current host before resolving', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://localhost:4080', title: 'Mempool' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('mempool')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes localhost IndeeHub URLs to current host before resolving', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://localhost:7778', title: 'IndeeHub' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe('indeedhub')
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes BTCPay (port 23000) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ function inferAppIdFromTitle(title?: string): string | null {
|
||||
function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
let rewrittenLocalhost = false
|
||||
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||
u.hostname = window.location.hostname
|
||||
rewrittenLocalhost = true
|
||||
}
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const normalizedPath = u.pathname === '/' ? '' : u.pathname
|
||||
const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}`
|
||||
@@ -60,7 +65,7 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
return rebuilt('81')
|
||||
}
|
||||
|
||||
return urlStr
|
||||
return rewrittenLocalhost ? u.toString() : urlStr
|
||||
} catch {
|
||||
return urlStr
|
||||
}
|
||||
@@ -73,7 +78,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'3000': 'grafana',
|
||||
'3002': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'8081': 'lnd',
|
||||
'18083': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
'8083': 'filebrowser',
|
||||
'8085': 'nextcloud',
|
||||
@@ -143,7 +148,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function openSession(appId: string) {
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mode === 'panel') {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (mode === 'panel' && !isMobile) {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
|
||||
@@ -23,7 +23,7 @@ const CONTAINER_NAME_MAP: Record<string, string[]> = {
|
||||
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
|
||||
'lnd': ['lnd', 'archy-lnd-ui'],
|
||||
'btcpay-server': ['btcpay-server'],
|
||||
'mempool': ['archy-mempool-web'],
|
||||
'mempool': ['mempool', 'archy-mempool-web'],
|
||||
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
|
||||
description: 'Lightning Network Daemon for fast Bitcoin payments',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 8081, container: 80 }],
|
||||
ports: [{ host: 18083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
|
||||
category: 'lightning',
|
||||
},
|
||||
|
||||
@@ -2062,9 +2062,9 @@ html:has(body.video-background-active)::before {
|
||||
scroll-snap-align: start;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px 12px;
|
||||
padding: 8px 4px 16px;
|
||||
min-height: 0;
|
||||
gap: 18px 10px;
|
||||
padding: 8px 2px 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.app-icon-item {
|
||||
@@ -2085,7 +2085,7 @@ html:has(body.video-background-active)::before {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -2094,17 +2094,19 @@ html:has(body.video-background-active)::before {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* Status dot — top-right of icon */
|
||||
.app-icon-status {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #000;
|
||||
border: 2px solid rgba(0, 0, 0, 0.85);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16), 0 2px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.app-icon-status-running {
|
||||
background: #22c55e;
|
||||
@@ -2143,22 +2145,30 @@ html:has(body.video-background-active)::before {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px;
|
||||
padding: 2px 0 8px;
|
||||
}
|
||||
|
||||
.app-icon-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
min-width: 8px !important;
|
||||
min-height: 8px !important;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
.app-icon-dot-active {
|
||||
background: rgba(247, 147, 26, 0.9);
|
||||
transform: scale(1.3);
|
||||
border-color: rgba(247, 147, 26, 0.65);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ===== End App Icon Grid ===== */
|
||||
@@ -2574,4 +2584,3 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
||||
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
@@ -139,9 +144,7 @@ const appId = computed(() => {
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
// On mobile (Android WebView), all apps load in the iframe — X-Frame-Options
|
||||
// doesn't apply since the WebView is the top-level browsing context.
|
||||
const mustOpenNewTab = computed(() => isMobile ? false : NEW_TAB_APPS.has(appId.value))
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
const appUrl = computed(() => {
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined)
|
||||
@@ -501,6 +504,17 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||
@media (max-width: 767px) {
|
||||
.app-session-root {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-inline {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-overlay,
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -511,14 +525,11 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: none;
|
||||
background: black;
|
||||
}
|
||||
/* Iframe frame: push content below status bar on mobile */
|
||||
.app-session-frame-safe {
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
}
|
||||
/* Iframe within padded container: fill remaining space */
|
||||
.app-session-frame-safe iframe {
|
||||
top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
height: calc(100% - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,24 +540,38 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.app-session-mobile-bar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2600;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
min-height: var(--app-session-mobile-bar-height, 84px);
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.app-session-inline .app-session-mobile-bar {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
||||
.app-session-bar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
border-radius: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -201,6 +201,8 @@ const appLauncher = useAppLauncherStore()
|
||||
|
||||
const selectedCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: 'All' },
|
||||
@@ -392,6 +394,11 @@ function launchInstalledApp(app: MarketplaceApp) {
|
||||
}
|
||||
|
||||
function handleInstall(app: MarketplaceApp) {
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
if (app.source === 'local') {
|
||||
installApp(app)
|
||||
} else {
|
||||
@@ -432,6 +439,23 @@ onBeforeUnmount(() => {
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
function queueInstall(app: MarketplaceApp) {
|
||||
serverStore.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
@@ -492,6 +516,7 @@ onMounted(() => {
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
const catalogFeatured = ref<CatalogFeatured | null>(null)
|
||||
@@ -512,4 +537,3 @@ async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -83,9 +83,11 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
const pkgs = store.data?.['package-data'] || {}
|
||||
const apps: KioskApp[] = []
|
||||
|
||||
// App URL mappings — use nginx proxy paths for local apps
|
||||
// App URL mappings. Bitcoin UI uses its direct host-network port; loading it
|
||||
// through /app/bitcoin-ui/ can render a blank shell because its assets are
|
||||
// rooted at /.
|
||||
const urlMap: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
|
||||
'lnd': '/app/lnd/',
|
||||
'mempool': '/app/mempool/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
:starting-up="isStartingUp(app.id)"
|
||||
:containers-scanned="containersScanned"
|
||||
:tier-label="getAppTier(app.id)"
|
||||
:install-blocked-reason="installBlockedReason(app.id)"
|
||||
@view="viewAppDetails"
|
||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||
@launch="launchInstalledApp"
|
||||
@@ -157,6 +158,7 @@ const categories = computed(() => [
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
|
||||
// so it works regardless of which page is active
|
||||
@@ -174,6 +176,7 @@ const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
|
||||
// Nostr community marketplace state
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
@@ -309,8 +312,26 @@ onMounted(() => {
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
@@ -379,6 +400,11 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
|
||||
async function installApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
@@ -399,6 +425,11 @@ async function installApp(app: MarketplaceApp) {
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -94,7 +95,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +150,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -159,7 +161,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -189,6 +191,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
|
||||
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
|
||||
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
@@ -375,9 +381,11 @@ import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -389,6 +397,8 @@ const installing = ref(false)
|
||||
const installingDeps = ref(false)
|
||||
const installError = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const bitcoinPruned = ref(false)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
@@ -471,6 +481,13 @@ const dependencies = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const installBlockedReason = computed(() => {
|
||||
const id = app.value?.id
|
||||
if (!bitcoinPruned.value || !id) return ''
|
||||
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
|
||||
return electrumxArchiveWarning
|
||||
})
|
||||
|
||||
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
@@ -495,8 +512,20 @@ onMounted(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
|
||||
})
|
||||
@@ -533,6 +562,11 @@ async function installDependencies() {
|
||||
if (installingDeps.value) return
|
||||
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
|
||||
if (!missingDeps.length) return
|
||||
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
|
||||
installError.value = electrumxArchiveWarning
|
||||
toast.error(electrumxArchiveWarning)
|
||||
return
|
||||
}
|
||||
|
||||
installingDeps.value = true
|
||||
installError.value = null
|
||||
@@ -561,6 +595,11 @@ async function installDependencies() {
|
||||
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value) return
|
||||
if (installBlockedReason.value) {
|
||||
installError.value = installBlockedReason.value
|
||||
toast.error(installBlockedReason.value)
|
||||
return
|
||||
}
|
||||
if (!app.value.manifestUrl && !app.value.dockerImage) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<img
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
@@ -117,7 +117,7 @@
|
||||
<div class="md:hidden">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<img
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
@@ -226,18 +226,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
pkg: Record<string, any>
|
||||
const props = defineProps<{
|
||||
pkg: PackageDataEntry
|
||||
appId: string
|
||||
packageKey: string
|
||||
canLaunch: boolean
|
||||
isWebOnly: boolean
|
||||
}>()
|
||||
|
||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||
|
||||
defineEmits<{
|
||||
launch: []
|
||||
start: []
|
||||
|
||||
@@ -16,7 +16,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool-web',
|
||||
mempool: 'mempool',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
@@ -88,7 +88,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
|
||||
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
|
||||
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
|
||||
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
|
||||
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
|
||||
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
|
||||
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
|
||||
describe('appSessionConfig', () => {
|
||||
it('keeps new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('grafana')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves direct app ports against the current browser host', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,8 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'lnd': 8081,
|
||||
'archy-lnd-ui': 8081,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool': 4080,
|
||||
'mempool-web': 4080,
|
||||
'archy-mempool-web': 4080,
|
||||
@@ -34,6 +34,7 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'nginx-proxy-manager': 81,
|
||||
'gitea': 3001,
|
||||
'portainer': 9000,
|
||||
'tailscale': 8240,
|
||||
'uptime-kuma': 3002,
|
||||
'fedimint': 8175,
|
||||
'fedimintd': 8175,
|
||||
@@ -52,43 +53,8 @@ export const PROXY_APPS: Record<string, string> = {
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
}
|
||||
|
||||
/** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
|
||||
* On HTTP, direct port access is used instead (faster, no proxy). */
|
||||
/** App launches use direct ports. Do not route through /app/... path proxies. */
|
||||
export const HTTPS_PROXY_PATHS: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'bitcoin-core': '/app/bitcoin-ui/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
'lnd': '/app/lnd/',
|
||||
'electrumx': '/app/electrumx/',
|
||||
'electrs': '/app/electrumx/',
|
||||
'archy-electrs-ui': '/app/electrumx/',
|
||||
'mempool-electrs': '/app/electrumx/',
|
||||
'mempool': '/app/mempool/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'archy-mempool-web': '/app/mempool/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimintd': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'searxng': '/app/searxng/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'ollama': '/app/ollama/',
|
||||
'onlyoffice': '/app/onlyoffice/',
|
||||
'immich': '/app/immich/',
|
||||
'immich_server': '/app/immich/',
|
||||
'portainer': '/app/portainer/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'endurain': '/app/endurain/',
|
||||
'dwn': '/app/dwn/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'grafana': '/app/grafana/',
|
||||
'botfights': '/app/botfights/',
|
||||
'gitea': '/app/gitea/',
|
||||
}
|
||||
|
||||
/** External HTTPS apps -- always loaded directly */
|
||||
@@ -99,7 +65,6 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
'tailscale': 'https://login.tailscale.com/admin/machines',
|
||||
}
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
@@ -137,24 +102,18 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
// Bitcoin apps always go through nginx proxy so browser basic-auth prompts never appear.
|
||||
// Bitcoin UI is a host-network companion on :8334. Do not launch it via
|
||||
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
return window.location.protocol + '//' + window.location.hostname + '/app/bitcoin-ui/'
|
||||
return 'http://' + window.location.hostname + ':8334'
|
||||
}
|
||||
|
||||
// HTTPS pages cannot embed plain HTTP port origins (mixed-content).
|
||||
if (window.location.protocol === 'https:') {
|
||||
const proxyPath = HTTPS_PROXY_PATHS[id]
|
||||
if (proxyPath) {
|
||||
return window.location.protocol + '//' + window.location.hostname + proxyPath
|
||||
}
|
||||
}
|
||||
|
||||
// Local apps on HTTP pages launch by host port.
|
||||
// Local apps launch by host port.
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
let base = window.location.protocol + '//' + window.location.hostname + ':' + String(port)
|
||||
let base = 'http://' + window.location.hostname + ':' + String(port)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ import { computed, ref } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { canLaunch, handleImageError, resolveAppIcon } from './appsConfig'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { canLaunch, handleImageError, opensInTab, resolveAppIcon } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
@@ -119,6 +120,13 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (canLaunch(pkg)) {
|
||||
if (opensInTab(id)) {
|
||||
const appUrl = resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
appLauncher.openSession(id)
|
||||
} else {
|
||||
emit('goToApp', id)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import AppIconGrid from '../AppIconGrid.vue'
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
interfaces: { main: { ui: true } },
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppIconGrid', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.198' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens LND companion UI in the app panel', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
||||
})
|
||||
})
|
||||
@@ -79,6 +79,6 @@ describe('appsConfig service filtering', () => {
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.png')
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,7 +125,9 @@ export function opensInTab(id: string): boolean {
|
||||
return TAB_LAUNCH_APPS.has(id)
|
||||
}
|
||||
|
||||
|
||||
const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
gitea: '/assets/img/app-icons/gitea.svg',
|
||||
}
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const icon = (pkg["static-files"]?.icon || "").trim()
|
||||
@@ -137,7 +139,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
||||
) {
|
||||
return icon
|
||||
}
|
||||
return curatedIcon || `/assets/img/app-icons/${id}.png`
|
||||
return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png`
|
||||
}
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
|
||||
@@ -140,10 +140,9 @@ const uiMode = useUIModeStore()
|
||||
|
||||
const mobileTabBar = ref<HTMLElement | null>(null)
|
||||
|
||||
// Hide tab bar when an app session is open (fullscreen on mobile)
|
||||
const isAppSessionActive = computed(() => {
|
||||
return route.name === 'app-session' || !!appLauncher.panelAppId
|
||||
})
|
||||
// App sessions own their mobile controls. Normal mobile launches use the route
|
||||
// session; keeping this guard also protects any desktop-panel state on resize.
|
||||
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
|
||||
@@ -96,7 +96,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
|
||||
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
|
||||
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
|
||||
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
|
||||
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
|
||||
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-controller-container
|
||||
:data-controller-install="!(installed || installing) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||
:data-controller-install="!(installed || installing || installBlockedReason) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
class="glass-card p-6 hover:bg-orange-500/5 hover:border-orange-500/15 transition-all cursor-pointer flex flex-col"
|
||||
@@ -122,6 +122,14 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="!installed && installBlockedReason"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-100 text-sm font-medium"
|
||||
:title="installBlockedReason"
|
||||
@click.stop="$emit('install', app)"
|
||||
>
|
||||
Bitcoin Pruned
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
|
||||
data-controller-install-btn
|
||||
@@ -159,6 +167,7 @@ const props = defineProps<{
|
||||
startingUp: boolean
|
||||
containersScanned: boolean
|
||||
tierLabel: string
|
||||
installBlockedReason?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -297,7 +297,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
title: 'ElectrumX',
|
||||
version: '1.18.0',
|
||||
description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.',
|
||||
icon: '/assets/img/app-icons/electrumx.webp',
|
||||
icon: '/assets/img/app-icons/electrumx.png',
|
||||
author: 'Luke Childs',
|
||||
dockerImage: `${REGISTRY}/electrumx:v1.18.0`,
|
||||
manifestUrl: undefined,
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
root: '.',
|
||||
passWithNoTests: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
exclude: ['e2e/**', 'node_modules/**', '**/._*'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary'],
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"version": "1.7.50-alpha",
|
||||
"release_date": "2026-05-01",
|
||||
"version": "1.7.53-alpha",
|
||||
"release_date": "2026-05-05",
|
||||
"changelog": [
|
||||
"OTA now carries app manifests, scripts, Docker contexts, and doctor units inside the frontend payload so older nodes restore /opt/archipelago runtime assets after updating.",
|
||||
"Startup bootstrap promotes the embedded runtime payload into /opt/archipelago and reloads the doctor timer without requiring rsync.",
|
||||
"Update checks now continue past stale mirrors so nodes can discover a newer manifest from the next configured mirror."
|
||||
"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."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.49-alpha",
|
||||
"new_version": "1.7.50-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.50-alpha/archipelago",
|
||||
"sha256": "494f71d58608787a4f485ec6e295057e70045e905d745837d1cd6089e484197d",
|
||||
"size_bytes": 41779280
|
||||
"current_version": "1.7.53-alpha",
|
||||
"new_version": "1.7.53-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.53-alpha/archipelago",
|
||||
"sha256": "86cf408ed84c7a7a72d1b5529aa97561dd02db38aab57c523999d1f5e7bf48b7",
|
||||
"size_bytes": 42352112
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.50-alpha.tar.gz",
|
||||
"current_version": "1.7.49-alpha",
|
||||
"new_version": "1.7.50-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.50-alpha/archipelago-frontend-1.7.50-alpha.tar.gz",
|
||||
"sha256": "6684d5f1083fd2d1db236fcc4a94efee7986ab54602b3231b031f3e9f7dec361",
|
||||
"size_bytes": 165153241
|
||||
"name": "archipelago-frontend-1.7.53-alpha.tar.gz",
|
||||
"current_version": "1.7.53-alpha",
|
||||
"new_version": "1.7.53-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.53-alpha/archipelago-frontend-1.7.53-alpha.tar.gz",
|
||||
"sha256": "87590acd32cb79866d39d87f37c7a91d85774d06aa318352b24d2b2177ccac31",
|
||||
"size_bytes": 166460672
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Production app catalog image smoke test.
|
||||
|
||||
Parses local app manifests, then probes images on a target production node via
|
||||
SSH. This catches catalog/image mismatches before a user clicks Install.
|
||||
|
||||
Checks:
|
||||
- manifest YAML loads and required app/container fields exist
|
||||
- production node health endpoint responds
|
||||
- each non-local image can be pulled on the node
|
||||
- shell-entrypoint apps reference commands that exist inside the image
|
||||
|
||||
Usage:
|
||||
scripts/app-catalog-image-smoke-test.py \
|
||||
--target archipelago@192.168.1.198 \
|
||||
--ssh-key /home/archipelago/.ssh/id_ed25519
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
INSECURE_REGISTRIES = ("146.59.87.168:3000", "23.182.128.160:3000")
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, target: str, ssh_key: str | None, extra: list[str]) -> None:
|
||||
self.base = [
|
||||
"ssh",
|
||||
"-F",
|
||||
"/dev/null",
|
||||
"-o",
|
||||
"ConnectTimeout=8",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
]
|
||||
if ssh_key:
|
||||
self.base.extend(["-i", ssh_key])
|
||||
self.base.extend(extra)
|
||||
self.target = target
|
||||
|
||||
def sh(self, script: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
|
||||
return run(self.base + [self.target, script], timeout=timeout)
|
||||
|
||||
|
||||
def load_manifests(apps_dir: Path) -> list[dict]:
|
||||
manifests = []
|
||||
for path in sorted(apps_dir.glob("*/manifest.yml")):
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict):
|
||||
app = None
|
||||
container = None
|
||||
elif isinstance(data.get("app"), dict):
|
||||
app = data["app"]
|
||||
container = app.get("container")
|
||||
else:
|
||||
app = data
|
||||
container = data.get("container") if isinstance(data.get("container"), dict) else data
|
||||
manifests.append({"path": path, "app": app, "container": container})
|
||||
return manifests
|
||||
|
||||
|
||||
def insecure(image: str) -> bool:
|
||||
return image.startswith(INSECURE_REGISTRIES)
|
||||
|
||||
|
||||
def shell_probe_for(app_id: str, command: str) -> str | None:
|
||||
if app_id in {"bitcoin-core", "bitcoin-knots"}:
|
||||
return "command -v bitcoind || find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1"
|
||||
|
||||
match = re.search(r"\bexec\s+([\"']?)([A-Za-z0-9_./-]+)\1", command)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
binary = match.group(2)
|
||||
if binary.startswith("$"):
|
||||
return None
|
||||
if "/" in binary:
|
||||
return f"test -x {shlex.quote(binary)} && echo {shlex.quote(binary)}"
|
||||
return f"command -v {shlex.quote(binary)}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--target", required=True)
|
||||
parser.add_argument("--ssh-key", default=os.environ.get("ARCHIPELAGO_SSH_KEY"))
|
||||
parser.add_argument("--apps-dir", default="apps")
|
||||
parser.add_argument("--pull", action="store_true", help="pull missing images before probing")
|
||||
parser.add_argument("--ssh-option", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
apps_dir = Path(args.apps_dir)
|
||||
remote = Remote(args.target, args.ssh_key, sum((["-o", x] for x in args.ssh_option), []))
|
||||
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
passes = 0
|
||||
|
||||
health = remote.sh("curl -fsS --max-time 5 http://127.0.0.1:5678/health", timeout=15)
|
||||
if health.returncode != 0:
|
||||
failures.append(f"target health failed: {health.stderr.strip() or health.stdout.strip()}")
|
||||
print(json.dumps({"passes": passes, "warnings": 0, "failures": len(failures)}, sort_keys=True))
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
return 1
|
||||
else:
|
||||
passes += 1
|
||||
print(f"PASS target health {health.stdout.strip()}")
|
||||
|
||||
manifests = load_manifests(apps_dir)
|
||||
print(f"INFO loaded {len(manifests)} manifests from {apps_dir}")
|
||||
|
||||
for item in manifests:
|
||||
path = item["path"]
|
||||
app = item["app"]
|
||||
container = item["container"]
|
||||
if not isinstance(app, dict) or not isinstance(container, dict):
|
||||
failures.append(f"{path}: missing app.container")
|
||||
continue
|
||||
|
||||
app_id = str(app.get("id") or "")
|
||||
image = str(container.get("image") or app.get("image") or "")
|
||||
if not app_id:
|
||||
failures.append(f"{path}: missing app id")
|
||||
continue
|
||||
if not image and container.get("build"):
|
||||
warnings.append(f"{app_id}: skipped locally built image")
|
||||
continue
|
||||
if not image:
|
||||
failures.append(f"{path}: missing container image")
|
||||
continue
|
||||
passes += 1
|
||||
|
||||
if image.startswith("localhost/") or image.startswith("archipelago/"):
|
||||
warnings.append(f"{app_id}: skipped local/unpublished image {image}")
|
||||
continue
|
||||
|
||||
pull_args = ["pull"]
|
||||
if insecure(image):
|
||||
pull_args.append("--tls-verify=false")
|
||||
pull_args.append(image)
|
||||
|
||||
if args.pull:
|
||||
pull_cmd = "timeout 300s podman " + " ".join(shlex.quote(x) for x in pull_args)
|
||||
pulled = remote.sh(pull_cmd, timeout=330)
|
||||
if pulled.returncode != 0:
|
||||
failures.append(f"{app_id}: pull failed for {image}: {(pulled.stderr or pulled.stdout).strip()[-500:]}")
|
||||
continue
|
||||
print(f"PASS {app_id}: pulled {image}")
|
||||
passes += 1
|
||||
else:
|
||||
exists = remote.sh(f"podman image exists {shlex.quote(image)}", timeout=30)
|
||||
if exists.returncode != 0:
|
||||
warnings.append(f"{app_id}: image not present on target, rerun with --pull: {image}")
|
||||
continue
|
||||
|
||||
custom_args = container.get("custom_args") or []
|
||||
entrypoint = container.get("entrypoint") or []
|
||||
if entrypoint == ["sh", "-lc"] and custom_args:
|
||||
command = str(custom_args[0])
|
||||
probe = shell_probe_for(app_id, command)
|
||||
if probe:
|
||||
remote_script = (
|
||||
"timeout 45s podman run --rm "
|
||||
f"--entrypoint sh {shlex.quote(image)} -c {shlex.quote(probe)}"
|
||||
)
|
||||
checked = remote.sh(remote_script, timeout=60)
|
||||
found = checked.stdout.strip().splitlines()[-1:] or [""]
|
||||
if checked.returncode == 0 and found[0]:
|
||||
print(f"PASS {app_id}: command probe found {found[0]}")
|
||||
passes += 1
|
||||
else:
|
||||
failures.append(
|
||||
f"{app_id}: command probe failed in {image}: {(checked.stderr or checked.stdout).strip()[-500:]}"
|
||||
)
|
||||
|
||||
print(json.dumps({"passes": passes, "warnings": len(warnings), "failures": len(failures)}, sort_keys=True))
|
||||
for warning in warnings:
|
||||
print(f"WARN {warning}")
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1191,7 +1189,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q tailscale; then
|
||||
-v /var/lib/archipelago/tailscale:/var/lib/tailscale \
|
||||
-e TS_STATE_DIR=/var/lib/tailscale \
|
||||
"$TAILSCALE_IMAGE" \
|
||||
sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled' 2>>"$LOG" || true
|
||||
sh -c 'tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait' 2>>"$LOG" || true
|
||||
fi
|
||||
track_container "tailscale"
|
||||
|
||||
|
||||