Files
archy/scripts/generate-app-catalog.sh
T
archipelagoandClaude Opus 5 8e814ca06a
Demo images / Build & push demo images (push) Failing after 2m22s
feat(registry): move image and OTA references to the public domain
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>
2026-08-07 11:31:20 -04:00

243 lines
10 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=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/<id>/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
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
# curated, bounded `versions[]` a runner may install or switch to. The entry
# marked default:true MUST equal the app's top-level catalog `version` (the
# manifest version for embedded apps) so selecting it un-pins / tracks latest.
#
# ONLY list versions whose tagged image is actually published to the registry —
# an unbuilt tag 404s on install. Extend each list as scripts/build-bitcoin-
# image.sh (Phase 0) publishes more tagged images, e.g.:
# {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"},
# {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"},
REGISTRY = os.environ.get("ARCHY_REGISTRY", "source.archipelago-foundation.org/lfg2025")
VERSIONS = {
# Curated Core set (latest patch per major, current → 25). Images built +
# verified (SHA-256 + OpenPGP, fail-closed) and pushed by
# scripts/build-bitcoin-image.sh. `28.4.0` is the default (== the manifest's
# top-level version) so existing/new installs are undisturbed; runners switch
# up to 31.0 (e.g. for BIP-110 signalling) or down to 25.2 from the app's
# "Version & Updates" card. Add the next release by building its image then
# prepending it here.
"bitcoin-core": [
{"version": "latest", "image": f"{REGISTRY}/bitcoin:latest", "default": True},
{"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"},
{"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"},
{"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"},
{"version": "29.2", "image": f"{REGISTRY}/bitcoin:29.2"},
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4"},
{"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"},
{"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True},
{"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True},
],
# Knots: a real tagged build is now published, so it's selectable + pinnable
# in the Knots app interface. `latest` is the default (== the manifest's
# floating tag) so selecting it un-pins / tracks latest — this MUST match the
# top-level catalog version (L167-168) or the card can't reach "latest" and
# selecting the highlighted default would instead pin+recreate. Pinning
# 29.3.knots20260508 moves a runner off the floating tag.
# `latest` is the default and points at the NEWEST published dated image
# (not the bare :latest tag) so "Always use the latest version" installs the
# newest build on fixed-binary nodes, while UNPINNED nodes still resolve via
# the manifest's floating :latest tag (kept on the legacy image until the
# entrypoint-render fix is fleet-deployed — see
# docs/bitcoin-version-bulletproof-rollout.md).
"bitcoin-knots": [
{"version": "latest",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "default": True},
{"version": "29.3.knots20260508",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508"},
{"version": "29.3.knots20260507",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260507"},
{"version": "29.3.knots20260210",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210"},
{"version": "29.2.knots20251110",
"image": f"{REGISTRY}/bitcoin-knots:29.2.knots20251110"},
],
}
for app_id, versions in VERSIONS.items():
if app_id in apps and versions:
apps[app_id]["versions"] = versions
# The default/latest entry MUST equal the app's top-level catalog
# `version` (commit 169ff2e2) so selecting the highlighted default
# un-pins / tracks latest instead of pinning+recreating. Enforce it here
# rather than relying on the manifest version matching.
default_entry = next((v for v in versions if v.get("default")), None)
if default_entry:
apps[app_id]["version"] = default_entry["version"]
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