From db4f517e440f38092d7f6664e1c9895bf46780fb Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 05:13:13 +0200 Subject: [PATCH] #203: pin all third-party deploy images by digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four third-party images in the deploy compose (postgres, pandoc, gotenberg — previously a floating MAJOR tag —, caddy) are now name:tag@sha256 pins; the tag stays for readability, the digest decides what runs. The pinned digests are exactly what the stages already run (verified against the live containers' RepoDigests on ONE), so the next recreation is byte-identical. A new early CI step fails on any third-party compose image without a digest; compose.dev.yml is a local convenience and deliberately exempt (its node helpers now follow the #236 pin). Update + rollout procedure in deploy/stages.md — CD does not sync stage composes, so the hand rollout to test/int/prod is part of this issue's definition of done. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- .gitea/workflows/ci.yml | 16 ++++++++++++++++ deploy/compose/compose.dev.yml | 6 +++--- deploy/compose/docker-compose.yml | 8 ++++---- deploy/stages.md | 29 +++++++++++++++++++++++++++++ docs/vs-nfd/20-massnahmenplan.md | 2 +- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0b201a4..e974a1f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -91,6 +91,22 @@ jobs: fi exit "$bad" + # Third-party deploy images are pinned by digest (issue #203): every + # image in the deploy compose that is not one of our own + # (${IMAGE_PREFIX}…) must carry @sha256 — the tag stays for + # readability, the digest decides what runs. Update procedure: + # deploy/stages.md §Third-party image digests. compose.dev.yml is a + # local convenience, deliberately not held to this. + - name: Third-party compose images are digest-pinned + run: | + set -euo pipefail + bad=$(grep -hE '^ *image: ' deploy/compose/docker-compose.yml | grep -v 'IMAGE_PREFIX' | grep -v '@sha256:' || true) + if [ -n "$bad" ]; then + echo "third-party image reference(s) without a digest:" + echo "$bad" + exit 1 + fi + - name: Set up pnpm uses: pnpm/action-setup@v4 diff --git a/deploy/compose/compose.dev.yml b/deploy/compose/compose.dev.yml index 3c504d5..90a4107 100644 --- a/deploy/compose/compose.dev.yml +++ b/deploy/compose/compose.dev.yml @@ -14,7 +14,7 @@ services: web: - image: node:22.15-alpine + image: node:22.15.1-alpine build: !reset null working_dir: /repo command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/web dev -- --host 0.0.0.0" @@ -33,7 +33,7 @@ services: depends_on: !reset [] api: - image: node:22.15-alpine + image: node:22.15.1-alpine build: !reset null working_dir: /repo command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/shared build && pnpm --filter @dorfteich/api start:dev" @@ -53,7 +53,7 @@ services: - pnpm-store:/root/.local/share/pnpm/store collab: - image: node:22.15-alpine + image: node:22.15.1-alpine build: !reset null working_dir: /repo command: sh -c "npm i -g pnpm@11 && pnpm install && pnpm --filter @dorfteich/shared build && pnpm --filter @dorfteich/collab start:dev" diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 723e129..5a9d83a 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 + image: 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 + image: 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 + image: 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 + image: caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d profiles: [caddy] restart: unless-stopped ports: diff --git a/deploy/stages.md b/deploy/stages.md index 50b393c..02d1ab8 100644 --- a/deploy/stages.md +++ b/deploy/stages.md @@ -122,6 +122,35 @@ The pipeline pushes images to the Gitea container registry - On the host, `docker login gitea.101010.cloud` for the `deploy` user with a `read:package` token, so `compose pull` works. +## 5a. Third-party image digests (issue #203, ADR 0024) + +Every third-party image in `deploy/compose/docker-compose.yml` is pinned +as `name:tag@sha256:…` — the tag stays for readability, the digest +decides what runs, so the deployed artefact is exactly the reviewed one. +An early CI step fails on any third-party `image:` reference without a +digest. Our own images are pinned per release by the deploy pipeline +(`TAG` in the stage `.env`); `compose.dev.yml` is a local convenience and +deliberately not digest-pinned. + +**Updating a digest** (e.g. to take a rebased base image or a new tag): + +1. Resolve the new digest — this prints the manifest-list digest every + platform pulls: + + ```bash + docker buildx imagetools inspect # → Digest: sha256:… + ``` + +2. Update the reference in `deploy/compose/docker-compose.yml` to + `@sha256:…` and let CI confirm. +3. **Roll out by hand**: CD does NOT sync stage composes — apply the same + change to `/srv/DOCKER/dorfteich-{test,int,prod}/docker-compose.yml` + on ONE. The next `compose pull && up -d` (any CD run for test/int, the + next release deploy for prod) recreates the containers from the pinned + digest. +4. Verify after rollout: `docker inspect --format '{{.Image}}' ` + must print the pinned digest (or check `RepoDigests` on the image). + ## 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 eab130d..cf4ef74 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -69,7 +69,7 @@ Digest-Pinning (#203) läuft vorgezogen in `M25`_ Hochgezogen, weil das eine Frage im **ersten** Behördengespräch ist. „Sollte gehen" ist dort eine schlechtere Antwort als „getestet, hier ist die Anleitung". -- [ ] Alle Images auf Digest pinnen (schließt den `gotenberg:8`-Punkt ein) · 1 AT · #203 +- [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 - [ ] Build ohne Netz reproduzierbar (pnpm Offline-Store / reine Prebuilt-Images) · 2–3 AT · #219 -- 2.45.2