archy/scripts/generate-app-catalog.sh
archipelago 7bfbe8fe40 feat(registry-manifest): phase 2 — publisher embeds manifests into signed catalog
generate-app-catalog.sh gains opt-in EMBED_MANIFESTS=1: embeds each
apps/<id>/manifest.yml into its catalog entry's `manifest` field (whole document,
top-level app: preserved — exactly what the Rust side deserializes). Default off
so routine catalog regen is unchanged during the migration window; turn on
deliberately, then sign via the existing release-root ceremony. Verified: default
embeds 0; EMBED_MANIFESTS=1 embeds 40 manifests (generated_secrets preserved).

Adds a round-trip guard test: every shipped apps/*/manifest.yml must deserialize
+ validate through catalog_manifest_to_overlay (image apps accepted, build apps
defer to disk) — catches schema drift between disk manifests and the catalog path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:46:17 -04:00

177 lines
6.3 KiB
Bash
Executable File

#!/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=1 scripts/generate-app-catalog.sh # also embed full manifests
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
#
# EMBED_MANIFESTS (opt-in, default off): also embed each app's full
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes can
# 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. Kept
# opt-in during the migration window so a routine catalog regen never changes
# what phase-1 nodes install until we deliberately turn it on.
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:-}" 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