#!/usr/bin/env bash # Generate releases/app-catalog.json — the REMOTE per-app version catalog that # decouples app updates from the binary OTA (see # core/.../container/app_catalog.rs and docs/dht-distribution-design.md). # # Nodes fetch this file over HTTP from the OVH origin (same host as the OTA # manifest), compare each app's catalog version against the running container # tag, and light up the per-app "Update" button — no node release required. # # The app_id -> image-variable mapping below MIRRORS # core/archipelago/src/container/image_versions.rs (image_var_for_app + # containers_for_stack). image_versions.rs is the canonical mapping; keep this in # sync when you add an app there. # # Usage: # scripts/generate-app-catalog.sh [output-path] # EMBED_MANIFESTS=0 scripts/generate-app-catalog.sh # version/image only (legacy) # # then publish: push releases/app-catalog.json to the OVH gitea (raw URL). # # EMBED_MANIFESTS (default ON, 2026-06-23): embed each app's full # apps//manifest.yml into its catalog entry's `manifest` field, so nodes # install from the signed registry alone (no OTA-shipped disk manifest). Consumed # by container::app_catalog + the orchestrator's load_manifests overlay # (origin-wins, disk = fallback). See docs/registry-manifest-design.md. The # migration window is over — every regen now embeds; set EMBED_MANIFESTS=0 only # to reproduce the old version/image-only catalog. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT="${1:-$ROOT/releases/app-catalog.json}" # Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them. set -a # shellcheck disable=SC1091 source "$ROOT/scripts/image-versions.sh" set +a UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \ EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY' import glob import json, os try: import yaml except ImportError: yaml = None def img(var): v = os.environ.get(var) return v if v else None def tag(image): # version = tag after the LAST colon that follows the last slash if not image: return None tail = image.rsplit('/', 1)[-1] return tail.rsplit(':', 1)[1] if ':' in tail else 'latest' # Single-container apps: app_id -> primary image variable. SINGLE = { "bitcoin-knots": "BITCOIN_KNOTS_IMAGE", "lnd": "LND_IMAGE", "electrumx": "ELECTRUMX_IMAGE", "bitcoin-ui": "BITCOIN_UI_IMAGE", "lnd-ui": "LND_UI_IMAGE", "electrs-ui": "ELECTRS_UI_IMAGE", "homeassistant": "HOMEASSISTANT_IMAGE", "grafana": "GRAFANA_IMAGE", "uptime-kuma": "UPTIME_KUMA_IMAGE", "jellyfin": "JELLYFIN_IMAGE", "photoprism": "PHOTOPRISM_IMAGE", "ollama": "OLLAMA_IMAGE", "vaultwarden": "VAULTWARDEN_IMAGE", "nextcloud": "NEXTCLOUD_IMAGE", "searxng": "SEARXNG_IMAGE", "cryptpad": "CRYPTPAD_IMAGE", "filebrowser": "FILEBROWSER_IMAGE", "nginx-proxy-manager": "NPM_IMAGE", "portainer": "PORTAINER_IMAGE", "tailscale": "TAILSCALE_IMAGE", "fedimint": "FEDIMINT_IMAGE", "fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE", "nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE", "nostr-vpn": "NOSTR_VPN_IMAGE", "fips": "FIPS_IMAGE", "routstr": "ROUTSTR_IMAGE", "adguardhome": "ADGUARDHOME_IMAGE", } # Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the # primary (its version drives the badge); it is also emitted as `image`. STACK = { "indeedhub": { "indeedhub": "INDEEDHUB_IMAGE", "indeedhub-api": "INDEEDHUB_API_IMAGE", "indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE", }, "immich": { "immich_server": "IMMICH_SERVER_IMAGE", "immich_postgres": "IMMICH_POSTGRES_IMAGE", "immich_redis": "REDIS_IMAGE", }, "penpot": { "penpot-frontend": "PENPOT_FRONTEND_IMAGE", "penpot-backend": "PENPOT_BACKEND_IMAGE", "penpot-exporter": "PENPOT_EXPORTER_IMAGE", "penpot-postgres": "PENPOT_POSTGRES_IMAGE", "penpot-valkey": "PENPOT_VALKEY_IMAGE", }, "mempool": { "archy-mempool-web": "MEMPOOL_WEB_IMAGE", "mempool-api": "MEMPOOL_BACKEND_IMAGE", "archy-mempool-db": "MARIADB_IMAGE", }, "btcpay": { "btcpay-server": "BTCPAY_IMAGE", "archy-nbxplorer": "NBXPLORER_IMAGE", "archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE", }, } apps = {} for app_id, var in SINGLE.items(): image = img(var) if image: apps[app_id] = {"version": tag(image), "image": image} for app_id, comps in STACK.items(): images = {name: img(var) for name, var in comps.items() if img(var)} if not images: continue primary_name = next(iter(comps)) # first listed = primary primary_image = img(comps[primary_name]) entry = {"version": tag(primary_image)} if primary_image: entry["image"] = primary_image entry["images"] = images apps[app_id] = entry # Opt-in (EMBED_MANIFESTS): embed each app's full manifest so nodes install from # the registry alone. The whole manifest document is embedded under `manifest` # (top-level `app:` preserved) — that is exactly what the Rust side deserializes # into an AppManifest. Apps not already in SINGLE/STACK get a new entry whose # version comes from the manifest. A bad embed is harmless: the node validates and # falls back to its disk manifest. embedded = 0 apps_dir = os.environ.get("APPS_DIR") if os.environ.get("EMBED_MANIFESTS") and apps_dir: if yaml is None: raise SystemExit("EMBED_MANIFESTS set but PyYAML is not available") for path in sorted(glob.glob(os.path.join(apps_dir, "*", "manifest.yml"))): with open(path) as fh: data = yaml.safe_load(fh) if not isinstance(data, dict) or not isinstance(data.get("app"), dict): continue app = data["app"] app_id = app.get("id") if not app_id: continue entry = apps.setdefault(str(app_id), {}) entry.setdefault("version", str(app.get("version", "")) or "0") entry["manifest"] = data embedded += 1 catalog = { "schema": 1, "updated": os.environ["UPDATED"], "apps": dict(sorted(apps.items())), } with open(os.environ["OUT"], "w") as f: json.dump(catalog, f, indent=2) f.write("\n") suffix = f" (embedded {embedded} manifests)" if embedded else "" print(f"Wrote {os.environ['OUT']} with {len(apps)} apps{suffix}") PY