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