From 2f7ba65eeff9206784472252de3a32c9339d562e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 14:33:04 +0200 Subject: [PATCH] #218: mirror procedure into an internal registry Airgapped sites pull from their own registry (ADR 0024). The image list is GENERATED (deploy/scripts/list-images.sh resolves the compose file incl. the caddy profile) so a mirror can never silently miss a service; third-party images gain a configurable ${REGISTRY_PREFIX:-} in the compose file (digest pins unchanged - Docker verifies the same sha256 regardless of which registry serves it), own images keep IMAGE_PREFIX; no image reference is ever edited per site. Step-by-step procedure in deploy/stages.md 5b: generate list, copy digest-preservingly (docker buildx imagetools create; plain pull/tag/push as the documented fallback - the digest comparison closes the loop either way), verify the digest in the mirror against the pin, point the deployment via REGISTRY_PREFIX/IMAGE_PREFIX. Executed once end-to-end and recorded as assessor-facing evidence (docs/vs-nfd/95-mirror-protokoll.md): all four third-party images mirrored digest-identically into a local registry:2, plus dorfteich-api:v0.12.0 (sha256:576f1646... identical on both sides; the imagetools stall against the Gitea registry is recorded with its workaround). Operations manual's airgap section now lists the mirror part as available. Refs #218. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- deploy/compose/.env.example | 6 ++++ deploy/compose/docker-compose.yml | 8 ++--- deploy/scripts/list-images.sh | 17 +++++++++ deploy/stages.md | 58 ++++++++++++++++++++++++++++++ docs/vs-nfd/20-massnahmenplan.md | 2 +- docs/vs-nfd/70-betriebshandbuch.md | 20 ++++++++--- docs/vs-nfd/95-mirror-protokoll.md | 55 ++++++++++++++++++++++++++++ 7 files changed, 156 insertions(+), 10 deletions(-) create mode 100755 deploy/scripts/list-images.sh create mode 100644 docs/vs-nfd/95-mirror-protokoll.md diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 1abe43f..82eb6ee 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -19,6 +19,12 @@ COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string IMAGE_PREFIX=dorfteich # Image tag to run: a git SHA, `test`, `int`, or a release tag like v1.2.0. TAG=latest +# Registry prefix for THIRD-PARTY images (postgres, pandoc, gotenberg, +# caddy) — for airgapped sites pulling from an internal mirror +# (issue #218, ADR 0024). Must end with a slash, e.g. +# `registry.example.gov/mirror/`. Empty = public registries. Own images +# are covered by IMAGE_PREFIX above, which may equally carry a registry. +REGISTRY_PREFIX= # --- ports (localhost only; the host reverse proxy routes to these) --------- # Suggested per stage on the shared host (ONE): test 8100/8101/8102, diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 5a9d83a..1315d2d 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -193,7 +193,7 @@ services: <<: *logging db: - image: postgres:17.5-alpine@sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa + image: ${REGISTRY_PREFIX:-}postgres:17.5-alpine@sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa restart: unless-stopped environment: POSTGRES_USER: dorfteich @@ -213,7 +213,7 @@ services: # on the internal network only — never exposed. Pinned image; the api reaches # it at http://pandoc:3030. `wget` ships in the (busybox-based) image. pandoc: - image: pandoc/core:3.6@sha256:5b8a29d9b70d5d8ca766e5d1dcfc41916b23ab79276a80527be70516110f4c1e + image: ${REGISTRY_PREFIX:-}pandoc/core:3.6@sha256:5b8a29d9b70d5d8ca766e5d1dcfc41916b23ab79276a80527be70516110f4c1e command: ['server'] restart: unless-stopped networks: [internal] @@ -228,7 +228,7 @@ services: # on the internal network only — never exposed. Pinned image; the api reaches # it at http://gotenberg:3000 and posts export HTML to its Chromium route. gotenberg: - image: gotenberg/gotenberg:8@sha256:67097317623a503ba2a6a7e9ae8db6929a1f7e1bbd88077bacf2d325fbdab923 + image: ${REGISTRY_PREFIX:-}gotenberg/gotenberg:8@sha256:67097317623a503ba2a6a7e9ae8db6929a1f7e1bbd88077bacf2d325fbdab923 restart: unless-stopped networks: [internal] healthcheck: @@ -244,7 +244,7 @@ services: # `localhost` default uses Caddy's internal CA — handy for smoke tests). # Instances behind an existing host proxy simply never enable the profile. caddy: - image: caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d + image: ${REGISTRY_PREFIX:-}caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d profiles: [caddy] restart: unless-stopped ports: diff --git a/deploy/scripts/list-images.sh b/deploy/scripts/list-images.sh new file mode 100755 index 0000000..e292de6 --- /dev/null +++ b/deploy/scripts/list-images.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# The complete image list of the deploy stack, GENERATED from the compose +# file (issue #218, ADR 0024) — a mirror procedure that works from a +# hand-maintained list will silently miss a service one day. +# +# Usage: +# TAG=v1.2.0 IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich \ +# deploy/scripts/list-images.sh +# +# REGISTRY_PREFIX is deliberately left empty here: the list names the +# UPSTREAM references (with digests) that a mirror copies FROM. +set -eu +cd "$(dirname "$0")/../compose" +COMPOSE_PROJECT_NAME=dorfteich-list \ +POSTGRES_PASSWORD=unused COLLAB_TOKEN_SECRET=unused \ +REGISTRY_PREFIX= \ +docker compose -f docker-compose.yml --profile caddy config --images | sort -u diff --git a/deploy/stages.md b/deploy/stages.md index 02d1ab8..7d1b614 100644 --- a/deploy/stages.md +++ b/deploy/stages.md @@ -151,6 +151,64 @@ deliberately not digest-pinned. 4. Verify after rollout: `docker inspect --format '{{.Image}}' ` must print the pinned digest (or check `RepoDigests` on the image). +## 5b. Mirroring into an internal registry (issue #218, ADR 0024) + +Airgapped sites pull every image from their own registry. The procedure +is written for the customer's operations team — nothing in it needs this +project's infrastructure. + +1. **Generate the image list** (never hand-maintain it — a manual list + silently misses a service one day). On a machine with this repository + and Docker: + + ```bash + TAG=v0.12.0 IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich \ + deploy/scripts/list-images.sh + ``` + + This resolves `deploy/compose/docker-compose.yml` (including the + optional caddy profile) and prints every reference: our four images + at the release tag plus the digest-pinned third-party images. + +2. **Copy each image digest-preservingly.** The copy tool is + `docker buildx imagetools create` — it moves manifests + registry-to-registry without re-encoding, so the digest in the mirror + is byte-identical to the pinned one: + + ```bash + docker buildx imagetools create \ + --tag registry.example.gov/mirror/postgres:17.5-alpine \ + postgres:17.5-alpine@sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa + ``` + + (One command per listed image; our own images copy the same way from + the release registry. Fallback when `imagetools create` stalls against + a registry — observed once with the Gitea registry: plain + `docker pull` + `docker tag` + `docker push` is equally valid, because + step 3's digest comparison closes the loop either way.) + +3. **Verify the digest after the copy** — the copy is only evidence once + checked: + + ```bash + docker buildx imagetools inspect registry.example.gov/mirror/postgres:17.5-alpine + # → Digest: must equal the pinned sha256 from the compose file + ``` + +4. **Point the deployment at the mirror** — configuration only, no image + reference is ever edited per site: + - third-party images: `REGISTRY_PREFIX=registry.example.gov/mirror/` + in the stage `.env` (must end with `/`), + - own images: `IMAGE_PREFIX=registry.example.gov/mirror/dorfteich`. + + The digest pins still apply — Docker verifies the pulled content + against the same `sha256` regardless of which registry serves it. + +Executed end-to-end on 2026-07-31 against a local `registry:2` (all four +third-party images plus `dorfteich-api:v0.12.0`; every mirrored digest +identical to the pin) — protocol: +`docs/vs-nfd/95-mirror-protokoll.md`. + ## 6. Verification checklist - [ ] `https://test.dorfteich.cloud/healthz` → `ok` diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 71486fb..36bcb44 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -70,7 +70,7 @@ Hochgezogen, weil das eine Frage im **ersten** Behördengespräch ist. „Sollte gehen" ist dort eine schlechtere Antwort als „getestet, hier ist die Anleitung". - [x] Alle Images auf Digest pinnen (schließt den `gotenberg:8`-Punkt ein) · 1 AT · #203 -- [ ] Mirror-Verfahren in interne Registry dokumentieren · 1 AT · #218 +- [x] Mirror-Verfahren in interne Registry dokumentieren · 1 AT · #218 - [ ] Build ohne Netz reproduzierbar (pnpm Offline-Store / reine Prebuilt-Images) · 2–3 AT · #219 - [ ] Testlauf in netzisolierter Umgebung, Protokoll als Beleg · 2 AT · #220 diff --git a/docs/vs-nfd/70-betriebshandbuch.md b/docs/vs-nfd/70-betriebshandbuch.md index 061ec78..ea19299 100644 --- a/docs/vs-nfd/70-betriebshandbuch.md +++ b/docs/vs-nfd/70-betriebshandbuch.md @@ -46,11 +46,21 @@ Belegstufe: ✅ erprobt — die Stages Test/Int/Prod auf ONE sind exakt nach dieser Prozedur aufgesetzt und laufen produktiv (`deploy/stages.md` dokumentiert die realen Instanzen). -**Airgap-/Offline-Variante:** ⏳ offen — Mirror-Verfahren (#218), -netzloser Build (#219), Testlauf in isolierter Umgebung (#220), -Offline-Update-Pfad (#221); Meilenstein M28. Bereits vorhanden als -Grundlage: alle Dritt-Images digest-gepinnt (#203), ein authoritativer -Node-Pin (#236), SBOMs je Release (#202). +**Airgap-/Offline-Variante:** teilweise verfügbar — + +- **Mirror-Verfahren (#218): ✅ verfügbar und einmal end-to-end + ausgeführt.** Schrittfolge: Image-Liste generieren + (`deploy/scripts/list-images.sh` — nie von Hand pflegen), + digest-erhaltend kopieren (`docker buildx imagetools create`), Digest + im Spiegel gegen den Compose-Pin verifizieren, Deployment per + `REGISTRY_PREFIX` (Dritt-Images) bzw. `IMAGE_PREFIX` (eigene Images) + auf den Spiegel zeigen. Vollständige Prozedur: + `deploy/stages.md` §5b; Ausführungsnachweis: + `95-mirror-protokoll.md`. +- ⏳ offen: netzloser Build (#219), Testlauf in isolierter Umgebung + (#220), Offline-Update-Pfad (#221); Meilenstein M28. Bereits vorhanden + als Grundlage: alle Dritt-Images digest-gepinnt (#203), ein + authoritativer Node-Pin (#236), SBOMs je Release (#202). ## 2 Update und Rollback diff --git a/docs/vs-nfd/95-mirror-protokoll.md b/docs/vs-nfd/95-mirror-protokoll.md new file mode 100644 index 0000000..c6310e2 --- /dev/null +++ b/docs/vs-nfd/95-mirror-protokoll.md @@ -0,0 +1,55 @@ +# Mirror-Protokoll — Spiegelung in eine interne Registry (Issue #218) + +Nachweis der einmaligen End-to-End-Ausführung des Mirror-Verfahrens aus +`deploy/stages.md` §5b (ADR 0024). Prüfer-taugliche Belegstufe: **live +verifiziert** — jede Zeile unten ist die tatsächliche Ausgabe des +Verifikationsschritts, nicht eine Erwartung. + +- **Datum:** 2026-07-31 +- **Umgebung:** Dritt-Images: macOS-Arbeitsplatz (OrbStack-Docker) mit + Ziel-Registry `registry:2` auf `localhost:5001`; eigenes Image: Host + ONE mit Ziel-Registry `registry:2` auf `127.0.0.1:5002` — + stellvertretend für die interne Registry der Behörde; das Verfahren + ist registry-agnostisch. +- **Quelle der Image-Liste:** `deploy/scripts/list-images.sh` mit + `TAG=v0.12.0` (der aktuelle Prod-Stand), generiert aus + `deploy/compose/docker-compose.yml` inkl. caddy-Profil. +- **Kopierwerkzeug:** Dritt-Images per `docker buildx imagetools +create` (kopiert Manifeste registry-zu-registry ohne Re-Encoding — + digest-erhaltend). Eigenes Image per `docker pull` + `tag` + `push`; + der Digest-Vergleich schließt die Schleife (Beobachtung 31.07.: + `imagetools create` stallte vom Arbeitsplatz gegen die Gitea-Registry, + während die Registry-API sofort antwortete — der pull/push-Weg ist der + robuste Ausweich, solange der Digest verifiziert wird). + +## Ergebnis je Image + +Verifikation jeweils per `docker buildx imagetools inspect +localhost:5001/mirror/` — der Digest im Spiegel muss dem Pin aus +der Compose-Datei gleichen. + +| Image | Gepinnter Digest (Compose) | Digest im Spiegel | Ergebnis | +| ----------------------- | ------------------------------------------------------------------------- | ----------------- | -------- | +| `caddy:2.10-alpine` | `sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d` | identisch | OK | +| `postgres:17.5-alpine` | `sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa` | identisch | OK | +| `pandoc/core:3.6` | `sha256:5b8a29d9b70d5d8ca766e5d1dcfc41916b23ab79276a80527be70516110f4c1e` | identisch | OK | +| `gotenberg/gotenberg:8` | `sha256:67097317623a503ba2a6a7e9ae8db6929a1f7e1bbd88077bacf2d325fbdab923` | identisch | OK | +| `dorfteich-api:v0.12.0` | `sha256:576f16467563871a9655059fc9cdcce49e3951b54a8697065081a9a6832d2294` | identisch | OK | + +(Die drei übrigen eigenen Images `web`/`collab`/`backup` folgen exakt +demselben Kommando mit anderem Namen; das Verfahren ist je Image +identisch und die Liste kommt generiert aus dem Skript — stellvertretend +wurde `api` als größtes eigenes Image kopiert.) + +## Abweichungen + +Alle kopierten Digests stimmen mit den Pins bzw. dem Quell-Digest +überein. Einzige Beobachtung: der Werkzeug-Stall von `imagetools +create` gegen die Gitea-Registry (oben) — dokumentiert samt Ausweichweg +in `deploy/stages.md` §5b. + +## Pflege + +Bei jedem Digest-Update (`deploy/stages.md` §5a) und jedem Release +wiederholt der Betreiber die Schritte 1–3 aus §5b für die geänderten +Images; die Liste kommt IMMER frisch aus `list-images.sh`. -- 2.45.2