Demo images / Build & push demo images (push) Failing after 2m22s
Replaces the registry host across 86 files: 309 references, covering all 40 app manifests, the orchestrator and container crates, the release and catalog scripts, both demo-images workflows, the ISO builder, demo-deploy, and the frontend marketplace data. Verified the domain actually serves the registry before rewriting anything, rather than assuming the web host implies the registry: - TLS verifies clean, HTTP/2 on the web root - an anonymous token grants a manifest fetch (HTTP 200) with no credentials - skopeo inspect --no-creds resolves an image and lists its tags That last check is the one that matters: an outside developer with no account can now pull, which was the functional blocker for publishing at all. Plain-HTTP references become HTTPS in the same pass, so OTA downloads stop crossing the network in the clear. Deliberately NOT rewritten: - The public FIPS anchor on port 8444. It is a functional network endpoint every node dials to bootstrap the mesh — closer to Bitcoin Core's hardcoded seeds than to leaked infrastructure. The domain does resolve to the same host, so it could become a hostname, but that adds a DNS dependency to the path used precisely when things are broken. Worth a deliberate decision, not a side effect of this change. - The companion APK on port 2100. The domain returns 404 for that path, so rewriting it would swap a working URL for a broken one. The Releases page does serve (200), which is where the plan already wants those binaries. - releases/app-catalog.json, releases/manifest.json and release-manifest.json. These carry `signature` and `signed_by`; editing their contents invalidates the signature and the fleet refuses artifacts that fail verification. They were rewritten in a first pass and reverted — they must be regenerated and re-signed through the signing ceremony instead, which needs the mnemonic. So the catalog still advertises the old host until that ceremony runs. Nodes resolve images through the signed catalog, not the on-disk manifests, so this commit alone does not change what a node pulls. Verified: archipelago-container 75/75; every manifest still parses with a top-level app block; no signed artifact modified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
631 lines
22 KiB
Bash
Executable File
631 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Remote app lifecycle runner for Archipelago nodes.
|
|
#
|
|
# Exercises the same public surface the UI uses:
|
|
# - JSON-RPC package.install/start/stop/restart/uninstall
|
|
# - HTTPS/direct-port launch probes from appSessionConfig.ts
|
|
#
|
|
# Default mode is audit-only. Use ARCHY_FULL_LIFECYCLE=1 for destructive
|
|
# preserve-data cycles: install -> launch -> stop -> start -> restart ->
|
|
# uninstall(preserve_data=true) -> reinstall -> launch.
|
|
|
|
set -euo pipefail
|
|
|
|
ARCHY_HOST="${ARCHY_HOST:-}"
|
|
ARCHY_SCHEME="${ARCHY_SCHEME:-https}"
|
|
ARCHY_PASSWORD="${ARCHY_PASSWORD:-}"
|
|
ARCHY_ITERATIONS="${ARCHY_ITERATIONS:-1}"
|
|
ARCHY_FULL_LIFECYCLE="${ARCHY_FULL_LIFECYCLE:-0}"
|
|
ARCHY_APPS="${ARCHY_APPS:-}"
|
|
ARCHY_TIMEOUT="${ARCHY_TIMEOUT:-900}"
|
|
ARCHY_STABILITY_SECONDS="${ARCHY_STABILITY_SECONDS:-5}"
|
|
ARCHY_ALLOW_BITCOIN_SWAP="${ARCHY_ALLOW_BITCOIN_SWAP:-0}"
|
|
ARCHY_APP_CATALOG="${ARCHY_APP_CATALOG:-}"
|
|
ARCHY_PRUNED_NODE="${ARCHY_PRUNED_NODE:-auto}"
|
|
|
|
if [[ -z "$ARCHY_HOST" || -z "$ARCHY_PASSWORD" ]]; then
|
|
echo "ARCHY_HOST and ARCHY_PASSWORD are required" >&2
|
|
exit 2
|
|
fi
|
|
if ! [[ "$ARCHY_ITERATIONS" =~ ^[1-9][0-9]*$ ]]; then
|
|
echo "ARCHY_ITERATIONS must be a positive integer" >&2
|
|
exit 2
|
|
fi
|
|
if ! [[ "$ARCHY_STABILITY_SECONDS" =~ ^[0-9]+$ ]]; then
|
|
echo "ARCHY_STABILITY_SECONDS must be a non-negative integer" >&2
|
|
exit 2
|
|
fi
|
|
|
|
BASE_URL="${ARCHY_SCHEME}://${ARCHY_HOST}"
|
|
SESSION=""
|
|
CSRF=""
|
|
CATALOG_FILE=""
|
|
|
|
ALL_APPS=(
|
|
bitcoin-knots
|
|
btcpay-server
|
|
lnd
|
|
mempool
|
|
homeassistant
|
|
grafana
|
|
searxng
|
|
ollama
|
|
nextcloud
|
|
vaultwarden
|
|
jellyfin
|
|
photoprism
|
|
immich
|
|
filebrowser
|
|
nginx-proxy-manager
|
|
portainer
|
|
tailscale
|
|
uptime-kuma
|
|
electrumx
|
|
fedimint
|
|
indeedhub
|
|
dwn
|
|
botfights
|
|
gitea
|
|
)
|
|
|
|
ARCHIVAL_ONLY_APPS=(
|
|
electrumx
|
|
mempool
|
|
)
|
|
|
|
app_in_list() {
|
|
local needle="$1"
|
|
shift
|
|
local item
|
|
for item in "$@"; do
|
|
[[ "$item" == "$needle" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
fetch_catalog() {
|
|
CATALOG_FILE=$(mktemp)
|
|
if [[ -n "$ARCHY_APP_CATALOG" ]]; then
|
|
cp "$ARCHY_APP_CATALOG" "$CATALOG_FILE"
|
|
return 0
|
|
fi
|
|
|
|
if curl -skfL --connect-timeout 8 -m 30 "${BASE_URL}/api/app-catalog" -o "$CATALOG_FILE" \
|
|
&& jq -e '.apps | length > 0' "$CATALOG_FILE" >/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
curl -skfL --connect-timeout 8 -m 30 "${BASE_URL}/catalog.json" -o "$CATALOG_FILE"
|
|
jq -e '.apps | length > 0' "$CATALOG_FILE" >/dev/null
|
|
}
|
|
|
|
catalog_app_ids() {
|
|
jq -r '.apps[] | select((.dockerImage // "") != "") | .id' "$CATALOG_FILE"
|
|
}
|
|
|
|
catalog_app_json() {
|
|
local app="$1"
|
|
[[ -n "$CATALOG_FILE" && -r "$CATALOG_FILE" ]] || return 1
|
|
jq -c --arg app "$app" '
|
|
.registry as $registry
|
|
| .apps[]
|
|
| select(.id == $app)
|
|
| .dockerImage = (if ((.dockerImage // "") | contains("/")) then .dockerImage else ($registry + "/" + .dockerImage) end)
|
|
' "$CATALOG_FILE" | head -n 1
|
|
}
|
|
|
|
is_pruned_node() {
|
|
case "$ARCHY_PRUNED_NODE" in
|
|
1|true|yes) return 0 ;;
|
|
0|false|no) return 1 ;;
|
|
esac
|
|
|
|
local pass body
|
|
pass=$(ssh "${ARCHY_HOST}" 'sudo cat /var/lib/archipelago/secrets/bitcoin-rpc-password 2>/dev/null || cat /var/lib/archipelago/secrets/bitcoin-rpc-password 2>/dev/null' 2>/dev/null || true)
|
|
[[ -n "$pass" ]] || return 1
|
|
body=$(curl -fsS --max-time 20 \
|
|
--user "archipelago:${pass}" \
|
|
--data-binary '{"jsonrpc":"1.0","id":"remote-lifecycle","method":"getblockchaininfo","params":[]}' \
|
|
-H 'content-type: text/plain;' \
|
|
"http://${ARCHY_HOST}:8332/" 2>/dev/null || true)
|
|
printf '%s' "$body" | jq -e '.result.pruned == true' >/dev/null 2>&1
|
|
}
|
|
|
|
image_for() {
|
|
case "$1" in
|
|
bitcoin-knots) echo "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest" ;;
|
|
bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;;
|
|
btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.3.9" ;;
|
|
lnd) echo "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta" ;;
|
|
mempool) echo "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0" ;;
|
|
homeassistant) echo "source.archipelago-foundation.org/lfg2025/home-assistant:2024.1" ;;
|
|
grafana) echo "source.archipelago-foundation.org/lfg2025/grafana:10.2.0" ;;
|
|
searxng) echo "source.archipelago-foundation.org/lfg2025/searxng:latest" ;;
|
|
ollama) echo "source.archipelago-foundation.org/lfg2025/ollama:latest" ;;
|
|
nextcloud) echo "source.archipelago-foundation.org/lfg2025/nextcloud:28" ;;
|
|
vaultwarden) echo "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine" ;;
|
|
jellyfin) echo "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13" ;;
|
|
photoprism) echo "source.archipelago-foundation.org/lfg2025/photoprism:240915" ;;
|
|
immich) echo "source.archipelago-foundation.org/lfg2025/immich-server:release" ;;
|
|
filebrowser) echo "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0" ;;
|
|
nginx-proxy-manager) echo "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest" ;;
|
|
portainer) echo "source.archipelago-foundation.org/lfg2025/portainer:latest" ;;
|
|
uptime-kuma) echo "source.archipelago-foundation.org/lfg2025/uptime-kuma:1" ;;
|
|
tailscale) echo "source.archipelago-foundation.org/lfg2025/tailscale:stable" ;;
|
|
electrumx) echo "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0" ;;
|
|
fedimint) echo "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0" ;;
|
|
indeedhub) echo "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0" ;;
|
|
botfights) echo "source.archipelago-foundation.org/lfg2025/botfights:1.1.0" ;;
|
|
gitea) echo "docker.io/gitea/gitea:1.23" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
launch_url_for() {
|
|
case "$1" in
|
|
bitcoin-knots|bitcoin-core|bitcoin-ui) echo "http://${ARCHY_HOST}:8334/" ;;
|
|
lnd|archy-lnd-ui) echo "http://${ARCHY_HOST}:18083/" ;;
|
|
electrumx|electrs|mempool-electrs|archy-electrs-ui) echo "http://${ARCHY_HOST}:50002/" ;;
|
|
mempool|mempool-web|archy-mempool-web) echo "http://${ARCHY_HOST}:4080/" ;;
|
|
fedimint|fedimintd) echo "http://${ARCHY_HOST}:8175/" ;;
|
|
fedimint-gateway) echo "http://${ARCHY_HOST}:8176/" ;;
|
|
filebrowser) echo "http://${ARCHY_HOST}:8083/" ;;
|
|
grafana) echo "http://${ARCHY_HOST}:3000/" ;;
|
|
btcpay-server) echo "http://${ARCHY_HOST}:23000/" ;;
|
|
jellyfin) echo "http://${ARCHY_HOST}:8096/" ;;
|
|
searxng) echo "http://${ARCHY_HOST}:8888/" ;;
|
|
ollama) echo "http://${ARCHY_HOST}:11434/" ;;
|
|
immich|immich_server) echo "http://${ARCHY_HOST}:2283/" ;;
|
|
portainer) echo "http://${ARCHY_HOST}:9000/" ;;
|
|
nginx-proxy-manager) echo "http://${ARCHY_HOST}:8081/" ;;
|
|
tailscale) echo "http://${ARCHY_HOST}:8240/" ;;
|
|
uptime-kuma) echo "http://${ARCHY_HOST}:3002/" ;;
|
|
homeassistant) echo "http://${ARCHY_HOST}:8123/" ;;
|
|
vaultwarden) echo "http://${ARCHY_HOST}:8082/" ;;
|
|
photoprism) echo "http://${ARCHY_HOST}:2342/" ;;
|
|
dwn) echo "http://${ARCHY_HOST}:3100/" ;;
|
|
botfights) echo "http://${ARCHY_HOST}:9100/" ;;
|
|
gitea) echo "http://${ARCHY_HOST}:3001/" ;;
|
|
indeedhub) echo "http://${ARCHY_HOST}:7778/" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
rpc_login() {
|
|
local headers body err
|
|
headers=$(mktemp)
|
|
body=$(curl -sk -D "$headers" -X POST "${BASE_URL}/rpc/v1" \
|
|
-H 'Content-Type: application/json' \
|
|
--data-raw "$(jq -nc --arg p "$ARCHY_PASSWORD" '{jsonrpc:"2.0",method:"auth.login",params:{password:$p},id:1}')")
|
|
err=$(printf '%s' "$body" | jq -r '.error.message // empty')
|
|
if [[ -n "$err" ]]; then
|
|
rm -f "$headers"
|
|
echo "login failed on $ARCHY_HOST: $err" >&2
|
|
return 1
|
|
fi
|
|
SESSION=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
|
|
CSRF=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
|
|
rm -f "$headers"
|
|
[[ -n "$SESSION" && -n "$CSRF" ]]
|
|
}
|
|
|
|
rpc_call() {
|
|
local method="$1" params="${2:-null}" id="${3:-2}"
|
|
local payload
|
|
if [[ "$params" == "null" ]]; then
|
|
payload=$(jq -nc --arg m "$method" --argjson id "$id" '{jsonrpc:"2.0",method:$m,id:$id}')
|
|
else
|
|
payload=$(jq -nc --arg m "$method" --argjson p "$params" --argjson id "$id" '{jsonrpc:"2.0",method:$m,params:$p,id:$id}')
|
|
fi
|
|
curl -sk -X POST "${BASE_URL}/rpc/v1" \
|
|
--connect-timeout 8 \
|
|
-m "${ARCHY_RPC_TIMEOUT:-60}" \
|
|
-H 'Content-Type: application/json' \
|
|
-H "Cookie: session=${SESSION}; csrf_token=${CSRF}" \
|
|
-H "X-CSRF-Token: ${CSRF}" \
|
|
--data-raw "$payload"
|
|
}
|
|
|
|
rpc_result() {
|
|
local resp err
|
|
resp=$(rpc_call "$@")
|
|
err=$(printf '%s' "$resp" | jq -r '.error.message // empty')
|
|
if [[ -n "$err" ]]; then
|
|
echo "$err" >&2
|
|
return 1
|
|
fi
|
|
printf '%s' "$resp" | jq '.result'
|
|
}
|
|
|
|
container_state() {
|
|
local app="$1"
|
|
rpc_result container-list | jq -r --arg app "$app" '
|
|
(map(select(.name == $app or .id == $app)) | first | .state // "absent") | ascii_downcase
|
|
'
|
|
}
|
|
|
|
container_health() {
|
|
local app="$1" health
|
|
health=$(
|
|
ARCHY_RPC_TIMEOUT="${ARCHY_HEALTH_RPC_TIMEOUT:-20}" \
|
|
rpc_result container-health "$(jq -nc --arg app "$app" '{app_id:$app}')" \
|
|
| jq -r --arg app "$app" '(.[$app] // "") | if . == "" then "unknown" else ascii_downcase end'
|
|
) || health=unknown
|
|
if [[ "$app" == "indeedhub" && "$health" != "healthy" ]] && probe_launch "$app" >/dev/null 2>&1; then
|
|
health=healthy
|
|
fi
|
|
printf '%s\n' "$health"
|
|
}
|
|
|
|
assert_container_healthy() {
|
|
local app="$1" health
|
|
health=$(container_health "$app" 2>/dev/null || echo unknown)
|
|
case "$health" in
|
|
healthy) return 0 ;;
|
|
*) echo "bad health: $app is $health" >&2; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
wait_container_healthy() {
|
|
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}" deadline health
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
health=$(container_health "$app" 2>/dev/null || echo unknown)
|
|
if [[ "$health" == "healthy" ]]; then return 0; fi
|
|
sleep 5
|
|
done
|
|
echo "bad health: $app is ${health:-unknown}" >&2
|
|
return 1
|
|
}
|
|
|
|
observe_stable() {
|
|
local app="$1" seconds="${2:-$ARCHY_STABILITY_SECONDS}" deadline state
|
|
(( seconds == 0 )) && return 0
|
|
deadline=$(( $(date +%s) + seconds ))
|
|
while (( $(date +%s) < deadline )); do
|
|
state=$(container_state "$app" 2>/dev/null || echo unknown)
|
|
if [[ "$state" != "running" ]]; then
|
|
if [[ "$app" == "indeedhub" ]] && probe_launch "$app" >/dev/null 2>&1; then
|
|
sleep 5
|
|
continue
|
|
fi
|
|
echo "stability failed: $app left running state (last=$state)" >&2
|
|
return 1
|
|
fi
|
|
assert_container_healthy "$app" || return 1
|
|
sleep 5
|
|
done
|
|
}
|
|
|
|
wait_state() {
|
|
local app="$1" target="$2" timeout="${3:-$ARCHY_TIMEOUT}"
|
|
local deadline state
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
state=$(container_state "$app" 2>/dev/null || echo unknown)
|
|
if [[ "$target" == "absent" && "$state" == "absent" ]]; then return 0; fi
|
|
if [[ "$target" == "stopped" && "$state" == "absent" ]]; then return 0; fi
|
|
if [[ "$target" != "absent" && "$state" == "$target" ]]; then return 0; fi
|
|
if [[ "$app" == "indeedhub" && "$target" == "running" ]] && probe_launch "$app" >/dev/null 2>&1; then return 0; fi
|
|
sleep 5
|
|
done
|
|
echo "$app did not reach $target within ${timeout}s (last=$state)" >&2
|
|
return 1
|
|
}
|
|
|
|
wait_absent_settled() {
|
|
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}"
|
|
local deadline state seen_absent=0
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
state=$(container_state "$app" 2>/dev/null || echo unknown)
|
|
if [[ "$state" == "absent" ]]; then
|
|
if (( seen_absent == 1 )); then return 0; fi
|
|
seen_absent=1
|
|
else
|
|
seen_absent=0
|
|
fi
|
|
sleep 5
|
|
done
|
|
echo "$app did not settle absent within ${timeout}s (last=$state)" >&2
|
|
return 1
|
|
}
|
|
|
|
wait_not_installing() {
|
|
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}"
|
|
local deadline state
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
state=$(container_state "$app" 2>/dev/null || echo unknown)
|
|
case "$state" in
|
|
installing|starting|restarting|updating) sleep 5 ;;
|
|
*) return 0 ;;
|
|
esac
|
|
done
|
|
echo "$app did not settle from install transition within ${timeout}s (last=$state)" >&2
|
|
return 1
|
|
}
|
|
|
|
probe_launch() {
|
|
local app="$1" url code bytes body
|
|
url=$(launch_url_for "$app") || return 0
|
|
body=$(mktemp)
|
|
code=$(curl -skL --connect-timeout 8 -m 20 -o "$body" -w '%{http_code}' "$url" || true)
|
|
bytes=$(wc -c < "$body" 2>/dev/null || printf 0)
|
|
if [[ "$code" != "200" || "$bytes" -eq 0 ]]; then
|
|
echo "launch failed: $app $url status=$code bytes=$bytes" >&2
|
|
rm -f "$body"
|
|
return 1
|
|
fi
|
|
case "$app" in
|
|
lnd) probe_lnd_wallet_connect "$body" || { rm -f "$body"; return 1; } ;;
|
|
electrumx|electrs|mempool-electrs) probe_electrum_wallet_connect "$body" || { rm -f "$body"; return 1; } ;;
|
|
indeedhub) probe_indeedhub_nostr_signer "$body" || { rm -f "$body"; return 1; } ;;
|
|
tailscale) probe_tailscale_login_ui "$body" || { rm -f "$body"; return 1; } ;;
|
|
esac
|
|
rm -f "$body"
|
|
}
|
|
|
|
wait_launch() {
|
|
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}" deadline
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
if probe_launch "$app" >/dev/null 2>&1; then return 0; fi
|
|
sleep 5
|
|
done
|
|
probe_launch "$app"
|
|
}
|
|
|
|
assert_launch_metadata() {
|
|
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}" deadline lan
|
|
launch_url_for "$app" >/dev/null 2>&1 || return 0
|
|
deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
lan=$(rpc_result container-list | jq -r --arg app "$app" '
|
|
(map(select(.name == $app or .id == $app)) | first | .lan_address // "")
|
|
')
|
|
if [[ -n "$lan" && "$lan" != "null" ]]; then return 0; fi
|
|
sleep 5
|
|
done
|
|
if [[ -z "${lan:-}" || "$lan" == "null" ]]; then
|
|
echo "launch metadata missing: $app has no lan_address" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
require_body() {
|
|
local body="$1" needle="$2" label="$3"
|
|
if ! grep -Fq "$needle" "$body"; then
|
|
echo "launch missing $label: $needle" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
probe_lnd_wallet_connect() {
|
|
local body="$1" info err
|
|
require_body "$body" 'Connect Your Wallet' 'LND wallet heading' || return 1
|
|
require_body "$body" 'id="lndQrBox"' 'LND QR container' || return 1
|
|
require_body "$body" 'id="connHost"' 'LND host field' || return 1
|
|
require_body "$body" 'value="rest-tor"' 'LND REST Tor mode' || return 1
|
|
require_body "$body" 'value="grpc-tor"' 'LND gRPC Tor mode' || return 1
|
|
require_body "$body" 'value="rest-local"' 'LND REST local mode' || return 1
|
|
require_body "$body" 'value="grpc-local"' 'LND gRPC local mode' || return 1
|
|
require_body "$body" 'Copy lndconnect URI' 'LND connect URI button' || return 1
|
|
info=$(curl -skL --connect-timeout 8 -m 20 \
|
|
-H "Cookie: session=${SESSION}; csrf_token=${CSRF}" \
|
|
-H "X-CSRF-Token: ${CSRF}" \
|
|
"${BASE_URL}/lnd-connect-info" || true)
|
|
err=$(printf '%s' "$info" | jq -r '.error // empty' 2>/dev/null || true)
|
|
if [[ -n "$err" ]]; then
|
|
echo "lnd connect info error: $err" >&2
|
|
return 1
|
|
fi
|
|
printf '%s' "$info" | jq -e '
|
|
(.cert_base64url | type == "string" and length > 100) and
|
|
(.macaroon_base64url | type == "string" and length > 50) and
|
|
(.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$")) and
|
|
(.rest_port == 18080) and
|
|
(.grpc_port == 10009)
|
|
' >/dev/null || {
|
|
echo "lnd connect info incomplete: $info" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
probe_electrum_wallet_connect() {
|
|
local body="$1"
|
|
require_body "$body" 'Connect Your Wallet' 'Electrum wallet heading' || return 1
|
|
require_body "$body" 'id="qrLocalBox"' 'Electrum local QR container' || return 1
|
|
require_body "$body" 'id="qrTorBox"' 'Electrum Tor QR container' || return 1
|
|
require_body "$body" 'id="localAddress"' 'Electrum local address field' || return 1
|
|
require_body "$body" 'id="torAddress"' 'Electrum Tor address field' || return 1
|
|
require_body "$body" '50001' 'Electrum wallet port' || return 1
|
|
require_body "$body" 'renderQR' 'Electrum QR renderer' || return 1
|
|
curl -skL --connect-timeout 8 -m 20 -f "http://${ARCHY_HOST}:50002/qrcode.js" >/dev/null || {
|
|
echo "electrum qrcode.js unavailable" >&2
|
|
return 1
|
|
}
|
|
local status
|
|
status=$(curl -skL --connect-timeout 8 -m 20 "${BASE_URL}/electrs-status" || true)
|
|
printf '%s' "$status" | jq -e '(.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$"))' >/dev/null || {
|
|
echo "electrum tor connection info incomplete: $status" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
probe_indeedhub_nostr_signer() {
|
|
local body="$1" provider pubkey signed now
|
|
require_body "$body" '/nostr-provider.js' 'IndeedHub Nostr provider injection' || return 1
|
|
provider=$(curl -skL --connect-timeout 8 -m 20 "http://${ARCHY_HOST}:7778/nostr-provider.js" || true)
|
|
if [[ -z "$provider" ]]; then
|
|
echo "indeedhub nostr-provider.js unavailable" >&2
|
|
return 1
|
|
fi
|
|
printf '%s' "$provider" | grep -Eq 'window\.nostr|nostr' || {
|
|
echo "indeedhub nostr-provider.js does not look like a Nostr signer bridge" >&2
|
|
return 1
|
|
}
|
|
|
|
pubkey=$(rpc_result node.nostr-pubkey | jq -r '.nostr_pubkey // empty')
|
|
if ! [[ "$pubkey" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
|
echo "indeedhub Nostr signer pubkey unavailable: $pubkey" >&2
|
|
return 1
|
|
fi
|
|
|
|
now=$(date +%s)
|
|
signed=$(rpc_result node.nostr-sign "$(jq -nc --argjson created_at "$now" '{event:{kind:1,created_at:$created_at,tags:[],content:"archy lifecycle indeedhub signer probe"}}')")
|
|
printf '%s' "$signed" | jq -e --arg pubkey "$pubkey" '
|
|
.pubkey == $pubkey and
|
|
(.id | type == "string" and test("^[0-9a-f]{64}$")) and
|
|
(.sig | type == "string" and test("^[0-9a-f]{128}$")) and
|
|
.content == "archy lifecycle indeedhub signer probe"
|
|
' >/dev/null || {
|
|
echo "indeedhub Nostr signer did not return a valid signed event: $signed" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
probe_tailscale_login_ui() {
|
|
local body="$1"
|
|
if grep -Eiq 'tailscale|login|log in|sign in|authenticate|authorize|auth key|connect' "$body"; then
|
|
return 0
|
|
fi
|
|
echo "tailscale launch did not present login/auth UI content" >&2
|
|
return 1
|
|
}
|
|
|
|
install_app() {
|
|
local app="$1" app_json image params
|
|
app_json=$(catalog_app_json "$app" || true)
|
|
if [[ -n "$app_json" ]]; then
|
|
params=$(printf '%s' "$app_json" | jq -c '{id, dockerImage, version, containerConfig} | with_entries(select(.value != null))')
|
|
else
|
|
image=$(image_for "$app")
|
|
params=$(jq -nc --arg id "$app" --arg img "$image" '{id:$id,dockerImage:$img,version:"latest"}')
|
|
fi
|
|
rpc_result package.install "$params" >/dev/null
|
|
}
|
|
|
|
expect_archival_blocked_install() {
|
|
local app="$1" app_json resp err params
|
|
app_json=$(catalog_app_json "$app")
|
|
params=$(printf '%s' "$app_json" | jq -c '{id, dockerImage, version, containerConfig} | with_entries(select(.value != null))')
|
|
resp=$(rpc_call package.install "$params")
|
|
err=$(printf '%s' "$resp" | jq -r '.error.message // empty')
|
|
if [[ "$err" != *"Requires an archival Bitcoin node"* && "$err" != *"requires an archival Bitcoin node"* && "$err" != *"running pruned Bitcoin"* ]]; then
|
|
echo "expected archival Bitcoin block for $app, got: $resp" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
start_app() { rpc_result package.start "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
|
|
stop_app() { rpc_result package.stop "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
|
|
restart_app() { rpc_result package.restart "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
|
|
uninstall_app() { rpc_result package.uninstall "$(jq -nc --arg id "$1" '{id:$id,preserve_data:true}')" >/dev/null; }
|
|
|
|
audit_app() {
|
|
local app="$1" state rc=0
|
|
state=$(container_state "$app" || echo unknown)
|
|
printf '%-22s state=%s\n' "$app" "$state"
|
|
case "$state" in
|
|
absent) ;;
|
|
running)
|
|
wait_container_healthy "$app" || rc=1
|
|
wait_launch "$app" || rc=1
|
|
assert_launch_metadata "$app" || rc=1
|
|
observe_stable "$app" || rc=1
|
|
;;
|
|
*) echo "bad state: $app is $state" >&2; rc=1 ;;
|
|
esac
|
|
return "$rc"
|
|
}
|
|
|
|
full_lifecycle_app() {
|
|
local app="$1"
|
|
if [[ "$app" == "bitcoin-core" && "$ARCHY_ALLOW_BITCOIN_SWAP" != "1" ]]; then
|
|
echo "skip bitcoin-core: set ARCHY_ALLOW_BITCOIN_SWAP=1 to test mutually-exclusive Bitcoin implementation"
|
|
return 0
|
|
fi
|
|
if app_in_list "$app" "${ARCHIVAL_ONLY_APPS[@]}" && is_pruned_node; then
|
|
echo "== $app: expect archival Bitcoin block =="
|
|
expect_archival_blocked_install "$app"
|
|
return $?
|
|
fi
|
|
echo "== $app: install =="
|
|
install_app "$app" || return 1
|
|
wait_not_installing "$app" || return 1
|
|
wait_state "$app" running || return 1
|
|
wait_container_healthy "$app" || return 1
|
|
wait_launch "$app" || return 1
|
|
assert_launch_metadata "$app" || return 1
|
|
observe_stable "$app" || return 1
|
|
|
|
echo "== $app: stop =="
|
|
stop_app "$app" || return 1
|
|
wait_state "$app" stopped 300 || return 1
|
|
|
|
echo "== $app: start =="
|
|
start_app "$app" || return 1
|
|
wait_state "$app" running || return 1
|
|
wait_container_healthy "$app" || return 1
|
|
wait_launch "$app" || return 1
|
|
assert_launch_metadata "$app" || return 1
|
|
observe_stable "$app" || return 1
|
|
|
|
echo "== $app: restart =="
|
|
restart_app "$app" || return 1
|
|
wait_state "$app" running || return 1
|
|
wait_container_healthy "$app" || return 1
|
|
wait_launch "$app" || return 1
|
|
assert_launch_metadata "$app" || return 1
|
|
observe_stable "$app" || return 1
|
|
|
|
echo "== $app: uninstall preserve_data =="
|
|
uninstall_app "$app" || return 1
|
|
wait_absent_settled "$app" 600 || return 1
|
|
|
|
echo "== $app: reinstall =="
|
|
install_app "$app" || return 1
|
|
wait_not_installing "$app" || return 1
|
|
wait_state "$app" running || return 1
|
|
wait_container_healthy "$app" || return 1
|
|
wait_launch "$app" || return 1
|
|
assert_launch_metadata "$app" || return 1
|
|
observe_stable "$app" || return 1
|
|
}
|
|
|
|
apps=()
|
|
if [[ -n "$ARCHY_APPS" ]]; then
|
|
IFS=',' read -r -a apps <<< "$ARCHY_APPS"
|
|
fetch_catalog || true
|
|
elif [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then
|
|
fetch_catalog
|
|
mapfile -t apps < <(catalog_app_ids)
|
|
else
|
|
if fetch_catalog; then
|
|
mapfile -t apps < <(catalog_app_ids)
|
|
else
|
|
apps=("${ALL_APPS[@]}")
|
|
fi
|
|
fi
|
|
|
|
rpc_login
|
|
|
|
failed=0
|
|
for i in $(seq 1 "$ARCHY_ITERATIONS"); do
|
|
echo "### $ARCHY_HOST iteration $i / $ARCHY_ITERATIONS ###"
|
|
for app in "${apps[@]}"; do
|
|
if [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then
|
|
full_lifecycle_app "$app" || failed=$((failed + 1))
|
|
else
|
|
audit_app "$app" || failed=$((failed + 1))
|
|
fi
|
|
done
|
|
done
|
|
|
|
if (( failed > 0 )); then
|
|
echo "FAILED checks: $failed" >&2
|
|
exit 1
|
|
fi
|
|
echo "all checks passed"
|