From eaca611842a0e4e20e898c5b9b75cefe3001b645 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 24 Jun 2026 09:25:53 +0200 Subject: [PATCH 01/12] ci(infra): ACL Dockerfile + full compose stack for smoke test (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ACL multi-stage Dockerfile and .dockerignore, and expands infra/docker-compose.yml from the BFF-only stub to the full development stack (OpenZaak, NRC, Keycloak, Flowable, ACL, BFF). Without these files a fresh checkout cannot satisfy `make smoke`'s `docker compose up --build --wait` step, so `make ci` could never go green. `make lint && make build && make unit` verified green locally. `make smoke` requires Docker Compose v2 (`--wait` flag); on this dev box only podman-compose is available — smoke will be verified on the respellion-linux CI runner once it is registered (see docs/runbooks/ci.md). Co-Authored-By: Claude Sonnet 4.6 --- infra/docker-compose.yml | 285 ++++++++++++++++++++++++++++++++++++- services/acl/.dockerignore | 3 + services/acl/Dockerfile | 32 +++++ 3 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 services/acl/.dockerignore create mode 100644 services/acl/Dockerfile diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 1c5e980..8b2deb4 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,9 +1,279 @@ -# Local development stack. Grows service-by-service with each slice. -# S-00-b: the placeholder BFF with a /health check. +# Development stack — boots all infra services plus the ACL and BFF. +# +# Consolidates infra/openzaak/, infra/opennotificaties/, infra/keycloak/, +# and infra/flowable/ and adds the ACL and BFF services. +# +# Port map (host): +# 8000 OpenZaak ZGW API (admin: admin / admin) +# 8001 Open Notificaties (admin: admin / admin) +# 8080 BFF GET /health → Healthy +# 8090 Flowable REST http://localhost:8090/flowable-rest/service/ +# 8100 ACL GET /health → Healthy POST /zaken +# 8180 Keycloak (admin: admin / admin) # # docker compose -f infra/docker-compose.yml up -d --build --wait -# curl http://localhost:8080/health # -> Healthy +# +# After first boot, seed the BIG catalogus and note the zaaktype URL: +# python infra/openzaak/seed_catalogus.py +# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl +# service: +# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/ +# docker compose -f infra/docker-compose.yml up -d acl + services: + + # ── OpenZaak (S-01) ────────────────────────────────────────────────────── + oz-db: + image: docker.io/postgis/postgis:17-3.5 + environment: + POSTGRES_USER: openzaak + POSTGRES_PASSWORD: openzaak + POSTGRES_DB: openzaak + command: postgres -c max_connections=300 + volumes: + - oz-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + oz-redis: + image: docker.io/library/redis:7 + networks: [cg] + + oz-init: + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + environment: &oz-env + DJANGO_SETTINGS_MODULE: openzaak.conf.docker + SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} + DB_HOST: oz-db + DB_NAME: openzaak + DB_USER: openzaak + DB_PASSWORD: openzaak + IS_HTTPS: "no" + ALLOWED_HOSTS: "*" + CACHE_DEFAULT: oz-redis:6379/0 + CACHE_AXES: oz-redis:6379/0 + CELERY_BROKER_URL: redis://oz-redis:6379/1 + CELERY_RESULT_BACKEND: redis://oz-redis:6379/1 + DISABLE_2FA: "true" + NOTIFICATIONS_DISABLED: "true" + OPENZAAK_SUPERUSER_USERNAME: admin + DJANGO_SUPERUSER_PASSWORD: admin + OPENZAAK_SUPERUSER_EMAIL: admin@localhost + RUN_SETUP_CONFIG: "true" + command: /setup_configuration.sh + volumes: + - ./openzaak/setup_configuration:/app/setup_configuration:ro,z + depends_on: + oz-db: + condition: service_healthy + oz-redis: + condition: service_started + networks: [cg] + + openzaak: + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + environment: *oz-env + healthcheck: + test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + ports: + - "8000:8000" + depends_on: + oz-init: + condition: service_completed_successfully + networks: [cg] + + oz-celery: + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + environment: *oz-env + command: /celery_worker.sh + depends_on: + oz-init: + condition: service_completed_successfully + networks: [cg] + + # ── Open Notificaties / NRC (S-01-c) ───────────────────────────────────── + nrc-db: + image: docker.io/postgis/postgis:17-3.5 + environment: + POSTGRES_USER: opennotificaties + POSTGRES_PASSWORD: opennotificaties + POSTGRES_DB: opennotificaties + command: postgres -c max_connections=300 + volumes: + - nrc-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + nrc-redis: + image: docker.io/library/redis:7 + networks: [cg] + + nrc-init: + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + environment: &nrc-env + DJANGO_SETTINGS_MODULE: nrc.conf.docker + SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} + DB_HOST: nrc-db + DB_NAME: opennotificaties + DB_USER: opennotificaties + DB_PASSWORD: opennotificaties + IS_HTTPS: "no" + ALLOWED_HOSTS: "*" + CACHE_DEFAULT: nrc-redis:6379/0 + CACHE_AXES: nrc-redis:6379/0 + CELERY_BROKER_URL: redis://nrc-redis:6379/1 + CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1 + DISABLE_2FA: "true" + OPENNOTIFICATIES_SUPERUSER_USERNAME: admin + DJANGO_SUPERUSER_PASSWORD: admin + OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost + RUN_SETUP_CONFIG: "true" + command: /setup_configuration.sh + volumes: + - ./opennotificaties/setup_configuration:/app/setup_configuration:ro,z + depends_on: + nrc-db: + condition: service_healthy + nrc-redis: + condition: service_started + openzaak: + condition: service_healthy + networks: [cg] + + nrc-web: + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + environment: *nrc-env + healthcheck: + test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + ports: + - "8001:8000" + depends_on: + nrc-init: + condition: service_completed_successfully + networks: [cg] + + nrc-celery: + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + environment: *nrc-env + command: /celery_worker.sh + depends_on: + nrc-init: + condition: service_completed_successfully + networks: [cg] + + # ── Keycloak (S-02) ────────────────────────────────────────────────────── + keycloak: + image: quay.io/keycloak/keycloak:26.1 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_HTTP_ENABLED: "true" + ports: + - "8180:8080" + volumes: + - ./keycloak/realms:/opt/keycloak/data/import:ro,z + networks: [cg] + + # ── Flowable (S-03) ────────────────────────────────────────────────────── + flowable-db: + image: docker.io/library/postgres:16 + environment: + POSTGRES_USER: flowable + POSTGRES_PASSWORD: flowable + POSTGRES_DB: flowable + volumes: + - flowable-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + flowable-rest: + image: docker.io/flowable/flowable-rest:latest + environment: + SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver + SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable + SPRING_DATASOURCE_USERNAME: flowable + SPRING_DATASOURCE_PASSWORD: flowable + ports: + - "8090:8080" + depends_on: + flowable-db: + condition: service_healthy + networks: [cg] + + flowable-init: + image: docker.io/curlimages/curl:latest + restart: "no" + volumes: + - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z + command: + - sh + - -c + - | + base=http://flowable-rest:8080/flowable-rest/service/repository/deployments + until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done + if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then + echo "registratie already deployed; skip" + else + curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie" + fi + depends_on: + flowable-rest: + condition: service_started + networks: [cg] + + # ── ACL ────────────────────────────────────────────────────────────────── + acl: + build: + context: ../services/acl + dockerfile: Dockerfile + image: register-referentie/acl:dev + environment: + Acl__OpenZaak__BaseUrl: http://openzaak:8000/ + Acl__OpenZaak__ClientId: big-reference-seed + Acl__OpenZaak__Secret: insecure-dev-secret-change-me + Acl__Defaults__Bronorganisatie: "517439943" + Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" + Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar + # Override with the real zaaktype URL after running seed_catalogus.py. + Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000} + ports: + - "8100:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + depends_on: + openzaak: + condition: service_healthy + networks: [cg] + + # ── BFF ────────────────────────────────────────────────────────────────── bff: build: context: ../services/bff @@ -17,3 +287,12 @@ services: timeout: 3s retries: 5 start_period: 10s + networks: [cg] + +volumes: + oz-db: + nrc-db: + flowable-db: + +networks: + cg: diff --git a/services/acl/.dockerignore b/services/acl/.dockerignore new file mode 100644 index 0000000..39d8eb6 --- /dev/null +++ b/services/acl/.dockerignore @@ -0,0 +1,3 @@ +**/bin +**/obj +**/*.user diff --git a/services/acl/Dockerfile b/services/acl/Dockerfile new file mode 100644 index 0000000..464d080 --- /dev/null +++ b/services/acl/Dockerfile @@ -0,0 +1,32 @@ +# Multi-stage build for the ACL service (.NET 10). +# Build context is services/acl (see infra/docker-compose.yml). +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore first (cached unless .csproj files change). +COPY Acl.Api/Acl.Api.csproj Acl.Api/ +COPY Acl.Application/Acl.Application.csproj Acl.Application/ +COPY Acl.Infrastructure/Acl.Infrastructure.csproj Acl.Infrastructure/ +RUN dotnet restore Acl.Api/Acl.Api.csproj + +COPY Acl.Api/ Acl.Api/ +COPY Acl.Application/ Acl.Application/ +COPY Acl.Infrastructure/ Acl.Infrastructure/ +RUN dotnet publish Acl.Api/Acl.Api.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=5 \ + CMD curl -fsS http://localhost:8080/health || exit 1 + +ENTRYPOINT ["dotnet", "Acl.Api.dll"] From f32fc4e8c07bb4ce2e2ae6ac238f3fd09c089f74 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 24 Jun 2026 09:32:38 +0200 Subject: [PATCH 02/12] ci(infra): switch runner label to ubuntu-latest (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted respellion-linux runner not required — Gitea's hosted ubuntu-latest runner has Docker + Compose v2 out of the box, so make smoke works without any manual registration step. Updates docs/runbooks/ci.md to reflect the new runner label and removes the act_runner self-hosted setup as the primary path. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yaml | 8 +++--- docs/runbooks/ci.md | 54 ++++++++-------------------------------- 2 files changed, 15 insertions(+), 47 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 2f0be4c..16e42e5 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -17,7 +17,7 @@ permissions: jobs: lint: - runs-on: respellion-linux + runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 @@ -26,7 +26,7 @@ jobs: - run: make lint build: - runs-on: respellion-linux + runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 @@ -35,7 +35,7 @@ jobs: - run: make build unit: - runs-on: respellion-linux + runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 @@ -44,7 +44,7 @@ jobs: - run: make unit compose-smoke: - runs-on: respellion-linux + runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - run: make smoke diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 4e84e5d..3758516 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -1,11 +1,9 @@ # CI runbook — Gitea Actions -> **Status: no runner yet → run CI locally with `make ci`.** The workflow -> `.gitea/workflows/ci.yaml` is in place, but the pipeline cannot go green until a -> self-hosted `respellion-linux` runner is registered against the Gitea instance. -> Until then, **`make ci` is the gate** — it runs the exact same checks locally -> (the workflow calls the same `make` targets). Issue **#30 (S-00-c)** stays open -> until CI is verified green on a runner. +> **Status: active.** The workflow `.gitea/workflows/ci.yaml` runs on Gitea's +> hosted `ubuntu-latest` runner — no self-hosted runner required. +> **`make ci` is still the local gate** — it runs the exact same checks +> (the workflow calls the same `make` targets). ## The pipeline @@ -49,56 +47,26 @@ The Makefile auto-points `DOCKER_HOST` at `/run/user/$(id -u)/podman/podman.sock when that socket exists and `DOCKER_HOST` is unset, so `make smoke` "just works" locally while leaving real Docker hosts / CI runners untouched. -## Runner: `respellion-linux` +## Runner: `ubuntu-latest` -The single self-hosted runner label this repo targets is **`respellion-linux`** -(declared here per §15). It is intended to run **co-located on the Gitea server** -(`git.labs.respellion.tech` / `46.224.220.37`) so CI is durable and independent of -any developer machine. +All jobs run on Gitea's hosted **`ubuntu-latest`** runner — no self-hosted runner +setup is required. The hosted runner ships with Docker and Docker Compose v2, so +`make smoke` (`docker compose … up --wait`) works without extra configuration. -### Host prerequisites - -The runner executes jobs in **host mode** (see registration below), so the host -must have, on `PATH`: - -- .NET 10 SDK (or let `setup-dotnet` install it into the runner tool cache) -- A container engine with Compose v2 — Docker, or Podman with the Docker-compatible - socket and the `docker-compose` provider (as configured on the dev box) -- `curl` - -### Install & register `act_runner` (on the Gitea server) +If Gitea's hosted runners are unavailable and a self-hosted fallback is needed, +register an `act_runner` with the `ubuntu-latest` label: ```bash -# 1. Install the binary (pick the version matching the Gitea release line) VER=0.2.11 curl -fsSL -o /usr/local/bin/act_runner \ "https://dl.gitea.com/act_runner/${VER}/act_runner-${VER}-linux-amd64" chmod +x /usr/local/bin/act_runner -# 2. Obtain a registration token from the Gitea UI: -# Site Administration → Actions → Runners → "Create new Runner" (instance-level) -# (or Repo → Settings → Actions → Runners for a repo-scoped runner) - -# 3. Register with the respellion-linux label in HOST execution mode. -# The ":host" suffix means jobs run directly on the host shell, so -# `docker compose` in compose-smoke uses the host engine (no docker-in-docker). act_runner register --no-interactive \ --instance https://git.labs.respellion.tech \ --token \ --name respellion-ci-1 \ - --labels "respellion-linux:host" + --labels "ubuntu-latest:docker://node:20-bookworm" -# 4. Run it (foreground to verify, then install as a systemd service) act_runner daemon ``` - -Verify in the Gitea UI (Actions → Runners) that `respellion-ci-1` shows **Idle**, -then re-run the `CI` workflow; all four jobs should pass. - -## Security note - -A self-hosted runner in **host mode** executes workflow code directly on the Gitea -server host. Anyone who can push a workflow can run code there. This is acceptable -for a **private lab** instance with trusted contributors. For anything -internet-facing, switch to container/VM isolation (`--labels "respellion-linux:docker://..."`) -or a dedicated runner host, and gate workflow runs on approval for outside PRs. From 8528664660289d9ce4aa709bb530f142fe19cbcb Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 24 Jun 2026 09:46:33 +0200 Subject: [PATCH 03/12] fix(infra): pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30) latest bumped to OpenZaak 1.29.0 (2026-06-18) and open-notificaties updated (2026-06-22), breaking oz-init in compose-smoke. Pin all four compose files to stable patch releases: open-zaak: 1.28.2 (was :latest -> 1.29.0) open-notificaties: 1.16.1 (was :latest) Tags are still overridable via OPENZAAK_TAG / OPENNOTIFICATIES_TAG env vars. Also adds two if: failure() steps to the compose-smoke CI job: one that dumps the last 100 lines of oz-init / nrc-init / acl / bff logs, and one that tears the stack down cleanly, so future failures are self-diagnosing. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yaml | 6 ++++++ infra/docker-compose.yml | 12 ++++++------ infra/opennotificaties/docker-compose.yml | 6 +++--- infra/openzaak/docker-compose.yml | 6 +++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 16e42e5..39f0115 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -48,3 +48,9 @@ jobs: steps: - uses: https://github.com/actions/checkout@v4 - run: make smoke + - name: dump container logs on failure + if: failure() + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init nrc-init acl bff 2>&1 || true + - name: tear down on failure + if: failure() + run: docker compose -f infra/docker-compose.yml down --volumes 2>&1 || true diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 8b2deb4..1f5ad2a 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -44,7 +44,7 @@ services: networks: [cg] oz-init: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} @@ -75,7 +75,7 @@ services: networks: [cg] openzaak: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -91,7 +91,7 @@ services: networks: [cg] oz-celery: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env command: /celery_worker.sh depends_on: @@ -121,7 +121,7 @@ services: networks: [cg] nrc-init: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: &nrc-env DJANGO_SETTINGS_MODULE: nrc.conf.docker SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} @@ -153,7 +153,7 @@ services: networks: [cg] nrc-web: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -169,7 +169,7 @@ services: networks: [cg] nrc-celery: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env command: /celery_worker.sh depends_on: diff --git a/infra/opennotificaties/docker-compose.yml b/infra/opennotificaties/docker-compose.yml index fb08c2a..8f05ff8 100644 --- a/infra/opennotificaties/docker-compose.yml +++ b/infra/opennotificaties/docker-compose.yml @@ -30,7 +30,7 @@ services: networks: [cg] nrc-init: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: &nrc-env DJANGO_SETTINGS_MODULE: nrc.conf.docker SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} @@ -60,7 +60,7 @@ services: networks: [cg] nrc-web: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -76,7 +76,7 @@ services: networks: [cg] nrc-celery: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest} + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env command: /celery_worker.sh depends_on: diff --git a/infra/openzaak/docker-compose.yml b/infra/openzaak/docker-compose.yml index c79aba7..6316eaa 100644 --- a/infra/openzaak/docker-compose.yml +++ b/infra/openzaak/docker-compose.yml @@ -29,7 +29,7 @@ services: networks: [cg] oz-init: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} @@ -64,7 +64,7 @@ services: networks: [cg] openzaak: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -80,7 +80,7 @@ services: networks: [cg] oz-celery: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest} + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env command: /celery_worker.sh depends_on: From 88de47d1bb84e341d2f05052eb6d1504aeb43f97 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 24 Jun 2026 11:55:15 +0200 Subject: [PATCH 04/12] fix(infra): harden oz-db healthcheck and raise compose-up timeout (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root-cause fixes for the oz-init CI failure: 1. Smoke timeout: add --wait-timeout 300 to `docker compose up --wait` so CI has 5 minutes instead of the 60-second default in older Compose v2 releases (migrations alone take 50 s locally). 2. PostGIS race: the old healthcheck used pg_isready which only checks TCP connectivity — it passes before the postgis/postgis init scripts have run SELECT PostGIS_Version(). The new check adds a psql probe so oz-init does not start until PostGIS is actually available. 3. Remove :z from volume mounts: the SELinux re-label flag is Podman/Fedora-specific and a no-op (or unexpected) under Docker on ubuntu-latest; plain :ro is correct for both runtimes. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 2 +- infra/docker-compose.yml | 17 ++++++++++------- infra/openzaak/docker-compose.yml | 13 +++++++------ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 49442f5..6e60f21 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ unit: ## smoke: compose up (wait for healthy), curl /health, then tear down smoke: - docker compose -f $(COMPOSE) up -d --build --wait + docker compose -f $(COMPOSE) up -d --build --wait --wait-timeout 300 bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc' ## down: stop and remove the local stack diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 1f5ad2a..699813e 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -33,10 +33,13 @@ services: volumes: - oz-db:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak"] + # pg_isready only checks TCP; the second clause verifies PostGIS is installed + # so oz-init migrations can safely start (avoids race on cold container start). + test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"] interval: 5s - timeout: 3s - retries: 10 + timeout: 5s + retries: 30 + start_period: 15s networks: [cg] oz-redis: @@ -66,7 +69,7 @@ services: RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh volumes: - - ./openzaak/setup_configuration:/app/setup_configuration:ro,z + - ./openzaak/setup_configuration:/app/setup_configuration:ro depends_on: oz-db: condition: service_healthy @@ -142,7 +145,7 @@ services: RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh volumes: - - ./opennotificaties/setup_configuration:/app/setup_configuration:ro,z + - ./opennotificaties/setup_configuration:/app/setup_configuration:ro depends_on: nrc-db: condition: service_healthy @@ -191,7 +194,7 @@ services: ports: - "8180:8080" volumes: - - ./keycloak/realms:/opt/keycloak/data/import:ro,z + - ./keycloak/realms:/opt/keycloak/data/import:ro networks: [cg] # ── Flowable (S-03) ────────────────────────────────────────────────────── @@ -228,7 +231,7 @@ services: image: docker.io/curlimages/curl:latest restart: "no" volumes: - - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z + - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro command: - sh - -c diff --git a/infra/openzaak/docker-compose.yml b/infra/openzaak/docker-compose.yml index 6316eaa..b61bfe9 100644 --- a/infra/openzaak/docker-compose.yml +++ b/infra/openzaak/docker-compose.yml @@ -18,10 +18,13 @@ services: volumes: - oz-db:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak"] + # pg_isready only checks TCP; the second clause verifies PostGIS is installed + # so oz-init migrations can safely start (avoids race on cold container start). + test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"] interval: 5s - timeout: 3s - retries: 10 + timeout: 5s + retries: 30 + start_period: 15s networks: [cg] oz-redis: @@ -53,9 +56,7 @@ services: RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh volumes: - # :z relabels for SELinux; the dir/file must be world-readable for the - # container user (rootless Podman uid mapping). See docs/runbooks/openzaak.md. - - ./setup_configuration:/app/setup_configuration:ro,z + - ./setup_configuration:/app/setup_configuration:ro depends_on: oz-db: condition: service_healthy From 9ff79370552aff6767ef303c589a87c10ae848ad Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 24 Jun 2026 13:06:56 +0200 Subject: [PATCH 05/12] fix(infra): bake config into images so compose-smoke passes on CI (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the compose-smoke failure (found in the runner logs): oz-init-1 | CommandError: Yaml file `/app/setup_configuration/data.yaml` does not exist. The ubuntu-latest runner runs the job inside a container, so `docker compose up` starts the stack as SIBLING containers via the host daemon. A relative bind mount (./openzaak/setup_configuration) resolves to a path inside the job container that the daemon can't see, so Docker mounts an empty dir and the init container can't find data.yaml. The same trap hit nrc-init (data.yaml), flowable-init (the BPMN) and keycloak (realm import). Fix: bake the assets into small derived images instead of bind-mounting: - infra/openzaak/Dockerfile -> register-referentie/openzaak:dev - infra/opennotificaties/Dockerfile-> register-referentie/opennotificaties:dev - infra/keycloak/Dockerfile -> register-referentie/keycloak:dev - flowable-init: build.dockerfile_inline bakes workflows/registratie.bpmn Base versions stay build args (OPENZAAK_TAG / OPENNOTIFICATIES_TAG), so the pinning is unchanged. Applied to both the consolidated compose and the per-service composes, so local Podman and CI use one mechanism — no bind mounts, no SELinux `:z`, no world-readable requirement. Verified locally: `podman build` of the OpenZaak and BPMN images produces the file at the expected in-container path. Docs: docs/runbooks/gitea-actions-gotchas.md explains the DinD bind-mount trap and the bake fix; openzaak.md and ci.md point at it. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/runbooks/ci.md | 6 ++ docs/runbooks/gitea-actions-gotchas.md | 83 +++++++++++++++++++++++ docs/runbooks/openzaak.md | 8 ++- infra/docker-compose.yml | 45 +++++++----- infra/flowable/docker-compose.yml | 12 +++- infra/keycloak/Dockerfile | 7 ++ infra/keycloak/docker-compose.yml | 7 +- infra/opennotificaties/Dockerfile | 7 ++ infra/opennotificaties/docker-compose.yml | 22 +++--- infra/openzaak/Dockerfile | 12 ++++ infra/openzaak/docker-compose.yml | 13 ++-- 11 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 docs/runbooks/gitea-actions-gotchas.md create mode 100644 infra/keycloak/Dockerfile create mode 100644 infra/opennotificaties/Dockerfile create mode 100644 infra/openzaak/Dockerfile diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 3758516..6432cc6 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -22,6 +22,12 @@ All `uses:` references are absolute, tag-pinned URLs (`https://github.com/action `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea Actions resolves them from GitHub. +> **`compose-smoke` runs on a containerized runner.** Workspace bind mounts do +> **not** reach the sibling containers Compose starts, so config/assets are baked +> into derived images instead of being mounted. If you add a service that needs a +> repo file at runtime, bake it — don't bind-mount it. See +> [gitea-actions-gotchas.md](gitea-actions-gotchas.md). + ## Running CI locally (`make ci`) Until the runner exists, run the full pipeline yourself before pushing: diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md new file mode 100644 index 0000000..ebe6834 --- /dev/null +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -0,0 +1,83 @@ +# Gitea Actions gotchas + +Known differences between Gitea Actions (our CI) and a plain local run, and the +workarounds we adopted. Referenced by `CLAUDE.md` §8.7 and §15. + +## Bind mounts don't reach Compose services on the hosted runner + +**Symptom.** `make smoke` is green locally but the `compose-smoke` CI job fails +with the OpenZaak init container exiting 1: + +``` +oz-init-1 | CommandError: Yaml file `/app/setup_configuration/data.yaml` does not exist. +``` + +Migrations run fine; only the step that reads a **mounted** file fails. The same +class of failure hits any service that bind-mounts a workspace path — +`nrc-init` (its `data.yaml`), `flowable-init` (the BPMN), `keycloak` (the realm +import dir). + +**Cause.** The `ubuntu-latest` runner executes the whole job **inside a +container** (`docker.gitea.com/runner-images:ubuntu-latest`). When the job then +runs `docker compose ... up`, Compose talks to the host's Docker daemon and +starts the stack as **sibling containers**. A relative bind mount such as + +```yaml +volumes: + - ./openzaak/setup_configuration:/app/setup_configuration:ro +``` + +is resolved by Compose to an absolute path **inside the job container** +(`/workspace/eho/register-referentie/infra/openzaak/setup_configuration`). The +daemon then looks for that path on **its own host**, doesn't find it, and +auto-creates an **empty directory** to mount. The container starts with an empty +mount point, so the file appears "missing". + +This is the classic Docker-in-Docker / sibling-container bind-mount trap. It does +not happen on a runner that executes jobs directly on the host (the previous +self-hosted `respellion-linux` setup), which is why switching to `ubuntu-latest` +exposed it. + +**Fix: bake assets into derived images instead of bind-mounting them.** Anything +a Compose service needs at runtime that lives in the repo is `COPY`-ed into a +small derived image, so it is present regardless of where the daemon runs: + +| Asset | Derived image | Dockerfile | +|---|---|---| +| OpenZaak `setup_configuration/data.yaml` | `register-referentie/openzaak:dev` | `infra/openzaak/Dockerfile` | +| Open Notificaties `setup_configuration/data.yaml` | `register-referentie/opennotificaties:dev` | `infra/opennotificaties/Dockerfile` | +| Keycloak realm exports | `register-referentie/keycloak:dev` | `infra/keycloak/Dockerfile` | +| `workflows/registratie.bpmn` | `register-referentie/flowable-init:dev` | `build.dockerfile_inline` in the compose files | + +The base image tag stays a build `arg` (`OPENZAAK_TAG`, `OPENNOTIFICATIES_TAG`) +so version pinning is unchanged. The three `*-init`/web/celery services that share +a base now share one built image tag, so the bake happens once per `up --build`. + +**Why not the alternatives.** + +- *Compose `configs:` with inline `content`* — Compose materialises these as a + temp file on the **client** side and bind-mounts it, so it hits the exact same + daemon-can't-see-the-path problem. +- *A self-hosted runner that runs jobs on the host* — works, but reintroduces a + bespoke runner and undoes the move to the hosted `ubuntu-latest` label. + +**Consequence for local dev.** There is now no bind mount of these config files, +so the SELinux `:z`/`:Z` relabel flag is no longer needed anywhere in `infra/`, +and rootless Podman no longer needs the files to be world-readable. One mechanism +(build) works on both Podman locally and Docker-in-Docker in CI. + +## `--wait` needs an explicit timeout + +`docker compose up --wait` defaults to a 60-second timeout in some Compose v2 +releases. A cold OpenZaak migrate alone takes ~50 s, so the smoke target passes +`--wait-timeout 300` (see `Makefile`). The 3-minute Definition-of-Done budget +still holds — this just stops `--wait` giving up before the stack is healthy. + +## PostGIS readiness vs. `pg_isready` + +`pg_isready` reports the server is accepting connections as soon as the TCP port +is open — **before** the `postgis/postgis` image has finished running its +`CREATE EXTENSION postgis` init scripts. An init container that starts migrating +in that window can fail on a missing PostGIS. The db healthchecks therefore add a +`SELECT PostGIS_Version()` probe so dependents wait for the extension, not just +the port. diff --git a/docs/runbooks/openzaak.md b/docs/runbooks/openzaak.md index 107a141..dc8b7fb 100644 --- a/docs/runbooks/openzaak.md +++ b/docs/runbooks/openzaak.md @@ -71,5 +71,9 @@ The Makefile auto-points `DOCKER_HOST` at the Podman socket when it exists, so t but OZ→NRC delivery wiring + re-enabling lands with **S-06**. - **Zaaktype is a concept**, not published (publishing needs roltypen/statustypen/ resultaattypen — beyond the lean seed). List with `?status=alles`. -- **Image tag.** Currently `openzaak/open-zaak:latest` via `${OPENZAAK_TAG}`; pin to - a known-good tag (ADR-0002 follow-up). +- **Image tag.** Pinned to `openzaak/open-zaak:1.28.2` via `${OPENZAAK_TAG}` (bump + deliberately, not via `:latest`). +- **Config is baked, not mounted.** `setup_configuration/data.yaml` is `COPY`-ed into + a derived image (`infra/openzaak/Dockerfile`) rather than bind-mounted, so the + init container finds it on Gitea's containerized CI runner too. See + [gitea-actions-gotchas.md](gitea-actions-gotchas.md). diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 699813e..ac94a62 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -47,7 +47,12 @@ services: networks: [cg] oz-init: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + build: + context: ./openzaak + dockerfile: Dockerfile + args: + OPENZAAK_TAG: ${OPENZAAK_TAG:-1.28.2} + image: register-referentie/openzaak:dev environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} @@ -68,8 +73,6 @@ services: OPENZAAK_SUPERUSER_EMAIL: admin@localhost RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh - volumes: - - ./openzaak/setup_configuration:/app/setup_configuration:ro depends_on: oz-db: condition: service_healthy @@ -78,7 +81,7 @@ services: networks: [cg] openzaak: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + image: register-referentie/openzaak:dev environment: *oz-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -94,7 +97,7 @@ services: networks: [cg] oz-celery: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + image: register-referentie/openzaak:dev environment: *oz-env command: /celery_worker.sh depends_on: @@ -124,7 +127,12 @@ services: networks: [cg] nrc-init: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + build: + context: ./opennotificaties + dockerfile: Dockerfile + args: + OPENNOTIFICATIES_TAG: ${OPENNOTIFICATIES_TAG:-1.16.1} + image: register-referentie/opennotificaties:dev environment: &nrc-env DJANGO_SETTINGS_MODULE: nrc.conf.docker SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} @@ -144,8 +152,6 @@ services: OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh - volumes: - - ./opennotificaties/setup_configuration:/app/setup_configuration:ro depends_on: nrc-db: condition: service_healthy @@ -156,7 +162,7 @@ services: networks: [cg] nrc-web: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + image: register-referentie/opennotificaties:dev environment: *nrc-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -172,7 +178,7 @@ services: networks: [cg] nrc-celery: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + image: register-referentie/opennotificaties:dev environment: *nrc-env command: /celery_worker.sh depends_on: @@ -182,7 +188,10 @@ services: # ── Keycloak (S-02) ────────────────────────────────────────────────────── keycloak: - image: quay.io/keycloak/keycloak:26.1 + build: + context: ./keycloak + dockerfile: Dockerfile + image: register-referentie/keycloak:dev command: ["start-dev", "--import-realm"] environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin @@ -193,8 +202,6 @@ services: KC_HTTP_ENABLED: "true" ports: - "8180:8080" - volumes: - - ./keycloak/realms:/opt/keycloak/data/import:ro networks: [cg] # ── Flowable (S-03) ────────────────────────────────────────────────────── @@ -228,10 +235,16 @@ services: networks: [cg] flowable-init: - image: docker.io/curlimages/curl:latest + # The BPMN is baked into a tiny curl image (build context = repo-root + # workflows/) instead of bind-mounted, so the deploy works on Gitea's + # containerized runner too. See docs/runbooks/gitea-actions-gotchas.md. + build: + context: ../workflows + dockerfile_inline: | + FROM docker.io/curlimages/curl:latest + COPY registratie.bpmn /work/registratie.bpmn + image: register-referentie/flowable-init:dev restart: "no" - volumes: - - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro command: - sh - -c diff --git a/infra/flowable/docker-compose.yml b/infra/flowable/docker-compose.yml index 0aa879a..c66caeb 100644 --- a/infra/flowable/docker-compose.yml +++ b/infra/flowable/docker-compose.yml @@ -38,10 +38,16 @@ services: # Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up. # Idempotent: skips if a deployment named "registratie" already exists. flowable-init: - image: docker.io/curlimages/curl:latest + # The BPMN is baked into a tiny curl image (build context = repo-root + # workflows/) instead of bind-mounted, so the deploy works on Gitea's + # containerized runner too. See docs/runbooks/gitea-actions-gotchas.md. + build: + context: ../../workflows + dockerfile_inline: | + FROM docker.io/curlimages/curl:latest + COPY registratie.bpmn /work/registratie.bpmn + image: register-referentie/flowable-init:dev restart: "no" - volumes: - - ../../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z command: - sh - -c diff --git a/infra/keycloak/Dockerfile b/infra/keycloak/Dockerfile new file mode 100644 index 0000000..baec0c5 --- /dev/null +++ b/infra/keycloak/Dockerfile @@ -0,0 +1,7 @@ +# Derived Keycloak image with the four realm exports baked into the import dir. +# `start-dev --import-realm` reads /opt/keycloak/data/import at boot. Baking the +# realms in (rather than bind-mounting ./realms) keeps the import working on +# Gitea's containerized runner, where bind mounts do not reach sibling +# containers. See docs/runbooks/gitea-actions-gotchas.md. +FROM quay.io/keycloak/keycloak:26.1 +COPY realms /opt/keycloak/data/import diff --git a/infra/keycloak/docker-compose.yml b/infra/keycloak/docker-compose.yml index b47dcc9..eaf9088 100644 --- a/infra/keycloak/docker-compose.yml +++ b/infra/keycloak/docker-compose.yml @@ -9,7 +9,10 @@ # Admin console: http://localhost:8180/ (admin / admin — dev only) services: keycloak: - image: quay.io/keycloak/keycloak:26.1 + build: + context: . + dockerfile: Dockerfile + image: register-referentie/keycloak:dev command: ["start-dev", "--import-realm"] environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin @@ -21,8 +24,6 @@ services: KC_HTTP_ENABLED: "true" ports: - "8180:8080" - volumes: - - ./realms:/opt/keycloak/data/import:ro,z networks: [cg] networks: diff --git a/infra/opennotificaties/Dockerfile b/infra/opennotificaties/Dockerfile new file mode 100644 index 0000000..f35f2ff --- /dev/null +++ b/infra/opennotificaties/Dockerfile @@ -0,0 +1,7 @@ +# Derived Open Notificaties image with the setup_configuration YAML baked in. +# Rationale is identical to infra/openzaak/Dockerfile: bind mounts do not reach +# sibling containers on Gitea's containerized runner, so nrc-init would not find +# data.yaml. See docs/runbooks/gitea-actions-gotchas.md. +ARG OPENNOTIFICATIES_TAG=1.16.1 +FROM docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG} +COPY setup_configuration /app/setup_configuration diff --git a/infra/opennotificaties/docker-compose.yml b/infra/opennotificaties/docker-compose.yml index 8f05ff8..d0b2836 100644 --- a/infra/opennotificaties/docker-compose.yml +++ b/infra/opennotificaties/docker-compose.yml @@ -19,10 +19,13 @@ services: volumes: - nrc-db:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"] + # pg_isready only checks TCP; the second clause verifies PostGIS is installed + # so nrc-init migrations can safely start (avoids race on cold container start). + test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties && psql -U opennotificaties -d opennotificaties -c 'SELECT PostGIS_Version();' -q 2>/dev/null"] interval: 5s - timeout: 3s - retries: 10 + timeout: 5s + retries: 30 + start_period: 15s networks: [cg] nrc-redis: @@ -30,7 +33,12 @@ services: networks: [cg] nrc-init: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + build: + context: . + dockerfile: Dockerfile + args: + OPENNOTIFICATIES_TAG: ${OPENNOTIFICATIES_TAG:-1.16.1} + image: register-referentie/opennotificaties:dev environment: &nrc-env DJANGO_SETTINGS_MODULE: nrc.conf.docker SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} @@ -50,8 +58,6 @@ services: OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh - volumes: - - ./setup_configuration:/app/setup_configuration:ro,z depends_on: nrc-db: condition: service_healthy @@ -60,7 +66,7 @@ services: networks: [cg] nrc-web: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + image: register-referentie/opennotificaties:dev environment: *nrc-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -76,7 +82,7 @@ services: networks: [cg] nrc-celery: - image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + image: register-referentie/opennotificaties:dev environment: *nrc-env command: /celery_worker.sh depends_on: diff --git a/infra/openzaak/Dockerfile b/infra/openzaak/Dockerfile new file mode 100644 index 0000000..f827a81 --- /dev/null +++ b/infra/openzaak/Dockerfile @@ -0,0 +1,12 @@ +# Derived OpenZaak image with the setup_configuration YAML baked in. +# +# Why bake instead of bind-mount: Gitea's containerized `ubuntu-latest` runner +# runs the job inside a container, so `docker compose` starts the stack as +# SIBLING containers via the host daemon. A bind mount of a workspace path is +# then resolved on the daemon host (where it does not exist), and Docker mounts +# an empty directory — oz-init would not find data.yaml and exits 1. Baking the +# file into the image makes it present regardless of runtime (local Podman or +# CI Docker-in-Docker). See docs/runbooks/gitea-actions-gotchas.md. +ARG OPENZAAK_TAG=1.28.2 +FROM docker.io/openzaak/open-zaak:${OPENZAAK_TAG} +COPY setup_configuration /app/setup_configuration diff --git a/infra/openzaak/docker-compose.yml b/infra/openzaak/docker-compose.yml index b61bfe9..4eee276 100644 --- a/infra/openzaak/docker-compose.yml +++ b/infra/openzaak/docker-compose.yml @@ -32,7 +32,12 @@ services: networks: [cg] oz-init: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + build: + context: . + dockerfile: Dockerfile + args: + OPENZAAK_TAG: ${OPENZAAK_TAG:-1.28.2} + image: register-referentie/openzaak:dev environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} @@ -55,8 +60,6 @@ services: OPENZAAK_SUPERUSER_EMAIL: admin@localhost RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh - volumes: - - ./setup_configuration:/app/setup_configuration:ro depends_on: oz-db: condition: service_healthy @@ -65,7 +68,7 @@ services: networks: [cg] openzaak: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + image: register-referentie/openzaak:dev environment: *oz-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -81,7 +84,7 @@ services: networks: [cg] oz-celery: - image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + image: register-referentie/openzaak:dev environment: *oz-env command: /celery_worker.sh depends_on: From 12049a0f35592df4eb36cf6b53c1f504424ff987 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 24 Jun 2026 13:18:32 +0200 Subject: [PATCH 06/12] fix(infra): nrc-init runs migrations only, not setup_configuration (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With OpenZaak now coming up, nrc-init ran for the first time and failed: nrc-init-1 | CommandError: No steps enabled, aborting. NRC's setup_configuration/data.yaml is intentionally empty ({}) — the OZ<->NRC wiring is deferred to S-06 — but /setup_configuration.sh runs `manage.py setup_configuration` regardless, and NRC 1.16.1 aborts when no steps are enabled. (This was masked until now: oz-init failed first, so openzaak never became healthy and nrc-init, which waits on it, never ran.) The documented intent is "init runs migrations only", so nrc-init now runs `manage.py migrate` directly instead of /setup_configuration.sh, and the dead RUN_SETUP_CONFIG env is dropped from the NRC services. nrc-web still migrates + creates the superuser itself via /start.sh. Also: - Makefile: bump compose `--wait-timeout` 300 -> 420. The serial oz-db -> oz-init -> openzaak(healthy) -> nrc-init -> nrc-web(healthy) chain runs ~260 s on the runner; 420 s gives comfortable headroom. - ci.yaml: widen the on-failure log dump to oz-init, openzaak, nrc-init, nrc-web, flowable-init, keycloak, acl, bff for full diagnosability. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 2 +- Makefile | 2 +- infra/docker-compose.yml | 7 +++++-- infra/opennotificaties/docker-compose.yml | 7 +++++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 39f0115..6774463 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -50,7 +50,7 @@ jobs: - run: make smoke - name: dump container logs on failure if: failure() - run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init nrc-init acl bff 2>&1 || true + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=80 oz-init openzaak nrc-init nrc-web flowable-init keycloak acl bff 2>&1 || true - name: tear down on failure if: failure() run: docker compose -f infra/docker-compose.yml down --volumes 2>&1 || true diff --git a/Makefile b/Makefile index 6e60f21..859716c 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ unit: ## smoke: compose up (wait for healthy), curl /health, then tear down smoke: - docker compose -f $(COMPOSE) up -d --build --wait --wait-timeout 300 + docker compose -f $(COMPOSE) up -d --build --wait --wait-timeout 420 bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc' ## down: stop and remove the local stack diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index ac94a62..9a3fbe5 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -150,8 +150,11 @@ services: OPENNOTIFICATIES_SUPERUSER_USERNAME: admin DJANGO_SUPERUSER_PASSWORD: admin OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost - RUN_SETUP_CONFIG: "true" - command: /setup_configuration.sh + # Migrations only for now. No setup_configuration steps are enabled yet (the + # OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration` + # aborts with "No steps enabled" on the empty data.yaml — so we run `migrate` + # directly instead of /setup_configuration.sh. See data.yaml and ADR-0002. + command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"] depends_on: nrc-db: condition: service_healthy diff --git a/infra/opennotificaties/docker-compose.yml b/infra/opennotificaties/docker-compose.yml index d0b2836..d2b8986 100644 --- a/infra/opennotificaties/docker-compose.yml +++ b/infra/opennotificaties/docker-compose.yml @@ -56,8 +56,11 @@ services: OPENNOTIFICATIES_SUPERUSER_USERNAME: admin DJANGO_SUPERUSER_PASSWORD: admin OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost - RUN_SETUP_CONFIG: "true" - command: /setup_configuration.sh + # Migrations only for now. No setup_configuration steps are enabled yet (the + # OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration` + # aborts with "No steps enabled" on the empty data.yaml — so we run `migrate` + # directly instead of /setup_configuration.sh. See data.yaml and ADR-0002. + command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"] depends_on: nrc-db: condition: service_healthy From a0aa22c80b6043de0c3033ab1569a8be39bc4ff1 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 09:03:37 +0200 Subject: [PATCH 07/12] fix(infra): smoke waits on durable services, not the whole project (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 28 got the full stack healthy but `compose-smoke` still failed. The last compose line before the error was: container infra-flowable-init-1 exited (0) `docker compose up --wait` treats a service that exits as a failure of the "stay running" condition unless something depends on it via `service_completed_successfully`. oz-init/nrc-init are fine (openzaak/nrc-web depend on them), but flowable-init deploys the BPMN and exits 0 with no dependant, so whole-project `--wait` failed the instant it finished — even though everything else was healthy and nrc-init now exits 0. Smoke now: 1. `up -d` starts the full stack (one-shots run + deploy as before), then 2. `up -d --wait ` waits only for the durable health-checked services (openzaak nrc-web acl bff). Also drops the external `curl localhost:8080/health`: the containerized CI runner can't reach published host ports at localhost, and each service's healthcheck already runs inside its container — so `--wait` succeeding IS the smoke. Documented in docs/runbooks/gitea-actions-gotchas.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 17 +++++++++++++---- docs/runbooks/ci.md | 2 +- docs/runbooks/gitea-actions-gotchas.md | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 859716c..4606bb1 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,11 @@ SLN := register-referentie.slnx COMPOSE := infra/docker-compose.yml -HEALTH_URL := http://localhost:8080/health +# Long-running services with a healthcheck — the smoke waits on these. One-shot +# init jobs (oz-init, nrc-init, flowable-init) are NOT listed: `--wait` fails when +# a one-shot with no `service_completed_successfully` dependant exits (flowable-init), +# so we wait on the durable services instead. See docs/runbooks/gitea-actions-gotchas.md. +WAIT_SVCS := openzaak nrc-web acl bff OZ_COMPOSE := infra/openzaak/docker-compose.yml OZ_BASE := http://localhost:8000 NRC_COMPOSE := infra/opennotificaties/docker-compose.yml @@ -45,10 +49,15 @@ build: unit: dotnet test $(SLN) -c Release -## smoke: compose up (wait for healthy), curl /health, then tear down +## smoke: bring the whole stack up, wait for the health-checked services, tear down +# Step 1 starts EVERYTHING (incl. one-shot init jobs that deploy and exit 0). +# Step 2 waits only for the durable, health-checked services ($(WAIT_SVCS)) — see +# WAIT_SVCS above for why the one-shots are excluded. The healthchecks run inside +# the containers, so this needs no host port access (the CI runner can't reach +# published ports anyway). smoke: - docker compose -f $(COMPOSE) up -d --build --wait --wait-timeout 420 - bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc' + docker compose -f $(COMPOSE) up -d --build + bash -c 'docker compose -f $(COMPOSE) up -d --wait --wait-timeout 420 $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc' ## down: stop and remove the local stack down: diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 6432cc6..107b985 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -16,7 +16,7 @@ and CI cannot drift: | `lint` | `make lint` → `dotnet format … --verify-no-changes` | .NET 10 SDK | | `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK | | `unit` | `make unit` → `dotnet test … -c Release` | .NET 10 SDK | -| `compose-smoke` | `make smoke` → compose up `--wait` → `curl /health` → `down` | container engine + compose v2 | +| `compose-smoke` | `make smoke` → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`, `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index ebe6834..280c682 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -66,6 +66,30 @@ so the SELinux `:z`/`:Z` relabel flag is no longer needed anywhere in `infra/`, and rootless Podman no longer needs the files to be world-readable. One mechanism (build) works on both Podman locally and Docker-in-Docker in CI. +## `--wait` fails on one-shot containers with no dependant + +`docker compose up --wait` treats a service that **exits** as a failure of the +"stay up" condition — **unless** another service depends on it with +`condition: service_completed_successfully`. Our init jobs `oz-init` and +`nrc-init` are fine (`openzaak`/`nrc-web` depend on their completion), but +`flowable-init` deploys the BPMN and exits 0 with **no dependant**, so a +whole-project `--wait` fails the moment it exits — even with everything else +healthy. The symptom is a `compose-smoke` failure whose last compose line is: + +``` +container infra-flowable-init-1 exited (0) +``` + +**Fix.** The smoke does **not** `--wait` on the whole project. It starts +everything with `up -d`, then `up -d --wait ` only for the durable, +health-checked services (`openzaak nrc-web acl bff` — see `WAIT_SVCS` in the +`Makefile`). One-shots still run (and deploy), they just don't gate `--wait`. + +This also removed the old external `curl http://localhost:8080/health` check: +the CI job runs in a container and **can't reach published host ports** at +`localhost`, and the per-service healthchecks (which run *inside* the +containers) already prove readiness, so `--wait` succeeding *is* the smoke. + ## `--wait` needs an explicit timeout `docker compose up --wait` defaults to a 60-second timeout in some Compose v2 From 6d8e1d0830d76457a88c7aca75368bb37b3fc0de Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 09:32:35 +0200 Subject: [PATCH 08/12] refactor(infra): bake config via dockerfile_inline, drop Dockerfile files (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three standalone Dockerfiles (openzaak, opennotificaties, keycloak) with `build.dockerfile_inline` recipes in the compose files, so the config bake has no separate Dockerfile artifacts to maintain. Behaviour is identical: each derived image still COPYies its config in. - oz-init / keycloak / flowable-init: 2-line inline Dockerfiles. - Open Notificaties needs no bake at all now — nrc-init runs migrations only, so all NRC services use the plain base image (removes a whole derived image). Why dockerfile_inline and not `docker cp` into named volumes: docker cp avoids images entirely but needs `docker compose create`, which podman-compose (the local dev runtime) does not implement — it would break `make openzaak-up` etc. locally. dockerfile_inline works on both podman-compose and the CI runner (verified both: oz-init + keycloak inline builds locally; flowable-init inline has been green on CI since run 27). Docs updated: gitea-actions-gotchas.md and openzaak.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/runbooks/gitea-actions-gotchas.md | 24 +++++++++++------- docs/runbooks/openzaak.md | 6 ++--- infra/docker-compose.yml | 30 ++++++++++++++--------- infra/keycloak/Dockerfile | 7 ------ infra/keycloak/docker-compose.yml | 6 ++++- infra/opennotificaties/Dockerfile | 7 ------ infra/opennotificaties/docker-compose.yml | 12 +++------ infra/openzaak/Dockerfile | 12 --------- infra/openzaak/docker-compose.yml | 8 +++--- 9 files changed, 50 insertions(+), 62 deletions(-) delete mode 100644 infra/keycloak/Dockerfile delete mode 100644 infra/opennotificaties/Dockerfile delete mode 100644 infra/openzaak/Dockerfile diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 280c682..7d8bd31 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -40,18 +40,24 @@ exposed it. **Fix: bake assets into derived images instead of bind-mounting them.** Anything a Compose service needs at runtime that lives in the repo is `COPY`-ed into a -small derived image, so it is present regardless of where the daemon runs: +small derived image, so it is present regardless of where the daemon runs. We use +**`build.dockerfile_inline`** — the 2-line recipe lives in the compose file, so +there are no standalone `Dockerfile` artifacts to maintain: -| Asset | Derived image | Dockerfile | +| Asset | Derived image | Built by | |---|---|---| -| OpenZaak `setup_configuration/data.yaml` | `register-referentie/openzaak:dev` | `infra/openzaak/Dockerfile` | -| Open Notificaties `setup_configuration/data.yaml` | `register-referentie/opennotificaties:dev` | `infra/opennotificaties/Dockerfile` | -| Keycloak realm exports | `register-referentie/keycloak:dev` | `infra/keycloak/Dockerfile` | -| `workflows/registratie.bpmn` | `register-referentie/flowable-init:dev` | `build.dockerfile_inline` in the compose files | +| OpenZaak `setup_configuration/data.yaml` | `register-referentie/openzaak:dev` | `oz-init` `dockerfile_inline` | +| Keycloak realm exports | `register-referentie/keycloak:dev` | `keycloak` `dockerfile_inline` | +| `workflows/registratie.bpmn` | `register-referentie/flowable-init:dev` | `flowable-init` `dockerfile_inline` | -The base image tag stays a build `arg` (`OPENZAAK_TAG`, `OPENNOTIFICATIES_TAG`) -so version pinning is unchanged. The three `*-init`/web/celery services that share -a base now share one built image tag, so the bake happens once per `up --build`. +The base image tag is interpolated by Compose (`${OPENZAAK_TAG}`) so pinning is +unchanged. OpenZaak's `openzaak`/`oz-celery` reuse the one image `oz-init` builds. +Open Notificaties needs **no** bake — `nrc-init` runs migrations only, so all NRC +services use the plain base image. + +`dockerfile_inline` works on both the CI runner (docker compose) and local +**podman-compose**, unlike `docker cp`-into-volumes (which needs `docker compose +create`, a subcommand podman-compose lacks). **Why not the alternatives.** diff --git a/docs/runbooks/openzaak.md b/docs/runbooks/openzaak.md index dc8b7fb..7196d2a 100644 --- a/docs/runbooks/openzaak.md +++ b/docs/runbooks/openzaak.md @@ -74,6 +74,6 @@ The Makefile auto-points `DOCKER_HOST` at the Podman socket when it exists, so t - **Image tag.** Pinned to `openzaak/open-zaak:1.28.2` via `${OPENZAAK_TAG}` (bump deliberately, not via `:latest`). - **Config is baked, not mounted.** `setup_configuration/data.yaml` is `COPY`-ed into - a derived image (`infra/openzaak/Dockerfile`) rather than bind-mounted, so the - init container finds it on Gitea's containerized CI runner too. See - [gitea-actions-gotchas.md](gitea-actions-gotchas.md). + a derived image via `oz-init`'s inline Dockerfile (`build.dockerfile_inline` in + the compose) rather than bind-mounted, so the init container finds it on Gitea's + containerized CI runner too. See [gitea-actions-gotchas.md](gitea-actions-gotchas.md). diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 9a3fbe5..b63f7be 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -47,11 +47,16 @@ services: networks: [cg] oz-init: + # Derived image: base OpenZaak + the setup_configuration YAML baked in. We use + # an inline Dockerfile (not a separate file, not a bind mount) because bind + # mounts don't reach sibling containers on the containerized CI runner. + # The ${OPENZAAK_TAG} below is interpolated by Compose. See + # docs/runbooks/gitea-actions-gotchas.md. build: context: ./openzaak - dockerfile: Dockerfile - args: - OPENZAAK_TAG: ${OPENZAAK_TAG:-1.28.2} + dockerfile_inline: | + FROM docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + COPY setup_configuration /app/setup_configuration image: register-referentie/openzaak:dev environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker @@ -127,12 +132,9 @@ services: networks: [cg] nrc-init: - build: - context: ./opennotificaties - dockerfile: Dockerfile - args: - OPENNOTIFICATIES_TAG: ${OPENNOTIFICATIES_TAG:-1.16.1} - image: register-referentie/opennotificaties:dev + # Plain base image — nrc-init runs migrations only (see command below), so it + # needs no baked config. + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: &nrc-env DJANGO_SETTINGS_MODULE: nrc.conf.docker SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} @@ -165,7 +167,7 @@ services: networks: [cg] nrc-web: - image: register-referentie/opennotificaties:dev + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -181,7 +183,7 @@ services: networks: [cg] nrc-celery: - image: register-referentie/opennotificaties:dev + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env command: /celery_worker.sh depends_on: @@ -191,9 +193,13 @@ services: # ── Keycloak (S-02) ────────────────────────────────────────────────────── keycloak: + # Derived image: base Keycloak + the realm exports baked into the import dir + # (inline Dockerfile, no bind mount — see docs/runbooks/gitea-actions-gotchas.md). build: context: ./keycloak - dockerfile: Dockerfile + dockerfile_inline: | + FROM quay.io/keycloak/keycloak:26.1 + COPY realms /opt/keycloak/data/import image: register-referentie/keycloak:dev command: ["start-dev", "--import-realm"] environment: diff --git a/infra/keycloak/Dockerfile b/infra/keycloak/Dockerfile deleted file mode 100644 index baec0c5..0000000 --- a/infra/keycloak/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -# Derived Keycloak image with the four realm exports baked into the import dir. -# `start-dev --import-realm` reads /opt/keycloak/data/import at boot. Baking the -# realms in (rather than bind-mounting ./realms) keeps the import working on -# Gitea's containerized runner, where bind mounts do not reach sibling -# containers. See docs/runbooks/gitea-actions-gotchas.md. -FROM quay.io/keycloak/keycloak:26.1 -COPY realms /opt/keycloak/data/import diff --git a/infra/keycloak/docker-compose.yml b/infra/keycloak/docker-compose.yml index eaf9088..7d74e0c 100644 --- a/infra/keycloak/docker-compose.yml +++ b/infra/keycloak/docker-compose.yml @@ -9,9 +9,13 @@ # Admin console: http://localhost:8180/ (admin / admin — dev only) services: keycloak: + # Derived image: base Keycloak + realm exports baked into the import dir via an + # inline Dockerfile. See docs/runbooks/gitea-actions-gotchas.md. build: context: . - dockerfile: Dockerfile + dockerfile_inline: | + FROM quay.io/keycloak/keycloak:26.1 + COPY realms /opt/keycloak/data/import image: register-referentie/keycloak:dev command: ["start-dev", "--import-realm"] environment: diff --git a/infra/opennotificaties/Dockerfile b/infra/opennotificaties/Dockerfile deleted file mode 100644 index f35f2ff..0000000 --- a/infra/opennotificaties/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -# Derived Open Notificaties image with the setup_configuration YAML baked in. -# Rationale is identical to infra/openzaak/Dockerfile: bind mounts do not reach -# sibling containers on Gitea's containerized runner, so nrc-init would not find -# data.yaml. See docs/runbooks/gitea-actions-gotchas.md. -ARG OPENNOTIFICATIES_TAG=1.16.1 -FROM docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG} -COPY setup_configuration /app/setup_configuration diff --git a/infra/opennotificaties/docker-compose.yml b/infra/opennotificaties/docker-compose.yml index d2b8986..d333413 100644 --- a/infra/opennotificaties/docker-compose.yml +++ b/infra/opennotificaties/docker-compose.yml @@ -33,12 +33,8 @@ services: networks: [cg] nrc-init: - build: - context: . - dockerfile: Dockerfile - args: - OPENNOTIFICATIES_TAG: ${OPENNOTIFICATIES_TAG:-1.16.1} - image: register-referentie/opennotificaties:dev + # Plain base image — nrc-init runs migrations only (see command below). + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: &nrc-env DJANGO_SETTINGS_MODULE: nrc.conf.docker SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} @@ -69,7 +65,7 @@ services: networks: [cg] nrc-web: - image: register-referentie/opennotificaties:dev + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -85,7 +81,7 @@ services: networks: [cg] nrc-celery: - image: register-referentie/opennotificaties:dev + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} environment: *nrc-env command: /celery_worker.sh depends_on: diff --git a/infra/openzaak/Dockerfile b/infra/openzaak/Dockerfile deleted file mode 100644 index f827a81..0000000 --- a/infra/openzaak/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# Derived OpenZaak image with the setup_configuration YAML baked in. -# -# Why bake instead of bind-mount: Gitea's containerized `ubuntu-latest` runner -# runs the job inside a container, so `docker compose` starts the stack as -# SIBLING containers via the host daemon. A bind mount of a workspace path is -# then resolved on the daemon host (where it does not exist), and Docker mounts -# an empty directory — oz-init would not find data.yaml and exits 1. Baking the -# file into the image makes it present regardless of runtime (local Podman or -# CI Docker-in-Docker). See docs/runbooks/gitea-actions-gotchas.md. -ARG OPENZAAK_TAG=1.28.2 -FROM docker.io/openzaak/open-zaak:${OPENZAAK_TAG} -COPY setup_configuration /app/setup_configuration diff --git a/infra/openzaak/docker-compose.yml b/infra/openzaak/docker-compose.yml index 4eee276..c3c5174 100644 --- a/infra/openzaak/docker-compose.yml +++ b/infra/openzaak/docker-compose.yml @@ -32,11 +32,13 @@ services: networks: [cg] oz-init: + # Derived image: base OpenZaak + setup_configuration baked in via an inline + # Dockerfile. See docs/runbooks/gitea-actions-gotchas.md for why not a mount. build: context: . - dockerfile: Dockerfile - args: - OPENZAAK_TAG: ${OPENZAAK_TAG:-1.28.2} + dockerfile_inline: | + FROM docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + COPY setup_configuration /app/setup_configuration image: register-referentie/openzaak:dev environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker From b349dff49642feff5962ffa29b93639f8711c451 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 10:22:14 +0200 Subject: [PATCH 09/12] refactor(infra): use upstream images verbatim, seed config via docker cp (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the inline-build images for the upstream services. The compose now references the published images directly (openzaak/open-zaak, openzaak/open-notificaties, keycloak, curl, flowable-rest) with no build for them, and the config they need is streamed into external named volumes by infra/seed-config.sh: rr-oz-config -> oz-init /app/setup_configuration (data.yaml) rr-kc-realms -> keycloak /opt/keycloak/data/import (realm exports) rr-fl-bpmn -> flowable-init /work (registratie.bpmn) How: the seeder creates each volume, `docker create`s a throwaway helper that mounts it, `docker cp`s the files in, and removes it. docker cp streams over the Docker API, so it works in Docker-in-Docker (the CI runner) where bind mounts mount empty. It uses plain `docker create`/`cp` — NOT `docker compose create`, which podman-compose (local dev) lacks. `external: true` fixed names keep the volumes identical across docker compose and podman-compose. Consequence: bare `docker compose up` no longer self-seeds, so use `make up` (seeds then starts). Every `*-up` target seeds first; `*-down` removes the external volume. acl/bff are still built (they're our apps, not upstream images). Verified end-to-end on podman-compose: `make keycloak-up` seeds rr-kc-realms, the upstream Keycloak mounts it, and --import-realm imports all four realms (digid realm returns 200). Seeder runs in ~2s. Docs updated: gitea-actions-gotchas.md, ci.md, openzaak.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 46 +++++++++++++++----- docs/runbooks/ci.md | 10 +++-- docs/runbooks/gitea-actions-gotchas.md | 59 +++++++++++++++----------- docs/runbooks/openzaak.md | 11 +++-- infra/docker-compose.yml | 57 ++++++++++++------------- infra/flowable/docker-compose.yml | 17 ++++---- infra/keycloak/docker-compose.yml | 17 ++++---- infra/openzaak/docker-compose.yml | 20 ++++----- infra/seed-config.sh | 45 ++++++++++++++++++++ 9 files changed, 182 insertions(+), 100 deletions(-) create mode 100755 infra/seed-config.sh diff --git a/Makefile b/Makefile index 4606bb1..b3908f3 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,14 @@ COMPOSE := infra/docker-compose.yml # a one-shot with no `service_completed_successfully` dependant exits (flowable-init), # so we wait on the durable services instead. See docs/runbooks/gitea-actions-gotchas.md. WAIT_SVCS := openzaak nrc-web acl bff +# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed +# into external named volumes via `docker cp` (infra/seed-config.sh) instead of +# bind-mounted, because bind mounts don't reach sibling containers on the +# containerized CI runner. SEED populates them; run it before every `up`. The +# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for +# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md. +SEED := bash infra/seed-config.sh +CFG_VOLS := rr-oz-config rr-kc-realms rr-fl-bpmn OZ_COMPOSE := infra/openzaak/docker-compose.yml OZ_BASE := http://localhost:8000 NRC_COMPOSE := infra/opennotificaties/docker-compose.yml @@ -32,7 +40,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help +.PHONY: ci lint build unit smoke up down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help ## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions) ci: lint build unit smoke @@ -49,19 +57,28 @@ build: unit: dotnet test $(SLN) -c Release -## smoke: bring the whole stack up, wait for the health-checked services, tear down -# Step 1 starts EVERYTHING (incl. one-shot init jobs that deploy and exit 0). -# Step 2 waits only for the durable, health-checked services ($(WAIT_SVCS)) — see -# WAIT_SVCS above for why the one-shots are excluded. The healthchecks run inside -# the containers, so this needs no host port access (the CI runner can't reach -# published ports anyway). +## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down +# SEED populates the external config volumes first (the upstream images are used +# verbatim — no build for them). `up -d --build` then starts EVERYTHING (building +# only our acl/bff). The second `up --wait` waits for the durable, health-checked +# services ($(WAIT_SVCS)); the one-shots are excluded because `--wait` fails when a +# one-shot with no `service_completed_successfully` dependant exits (flowable-init). +# Healthchecks run inside the containers, so no host port access is needed. smoke: + $(SEED) oz kc fl docker compose -f $(COMPOSE) up -d --build - bash -c 'docker compose -f $(COMPOSE) up -d --wait --wait-timeout 420 $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc' + bash -c 'docker compose -f $(COMPOSE) up -d --wait --wait-timeout 420 $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc' -## down: stop and remove the local stack +## up: seed config volumes and start the full stack (use instead of bare +## `docker compose up`, which can't self-seed the external config volumes) +up: + $(SEED) oz kc fl + docker compose -f $(COMPOSE) up -d --build + +## down: stop and remove the local stack (incl. the external config volumes) down: docker compose -f $(COMPOSE) down --volumes + -docker volume rm -f $(CFG_VOLS) ## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff) changelog: @@ -69,11 +86,11 @@ changelog: ## openzaak-up: start the OpenZaak stack (migrations run on first start) openzaak-up: + $(SEED) oz docker compose -f $(OZ_COMPOSE) up -d ## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced -openzaak-smoke: - docker compose -f $(OZ_COMPOSE) up -d +openzaak-smoke: openzaak-up @bash -c 'set -e; \ echo "waiting for OpenZaak to respond..."; \ for i in $$(seq 1 60); do \ @@ -97,9 +114,11 @@ openzaak-seed: openzaak-up ## openzaak-down: stop and remove the OpenZaak stack (wipes data) openzaak-down: docker compose -f $(OZ_COMPOSE) down --volumes + -docker volume rm -f rr-oz-config ## stack-up: start OpenZaak + Open Notificaties together (shared network) stack-up: + $(SEED) oz docker compose $(STACK_FILES) up -d ## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable @@ -119,9 +138,11 @@ stack-smoke: stack-up ## stack-down: stop and remove both stacks (wipes data) stack-down: docker compose $(STACK_FILES) down --volumes + -docker volume rm -f rr-oz-config ## keycloak-up: start Keycloak with the four imported realms keycloak-up: + $(SEED) kc docker compose -f $(KC_COMPOSE) up -d ## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim @@ -134,9 +155,11 @@ keycloak-smoke: keycloak-up ## keycloak-down: stop and remove Keycloak keycloak-down: docker compose -f $(KC_COMPOSE) down --volumes + -docker volume rm -f rr-kc-realms ## flowable-up: start Flowable (deploys registratie.bpmn on boot) flowable-up: + $(SEED) fl docker compose -f $(FL_COMPOSE) up -d ## flowable-smoke: start Flowable, then verify a started instance waits on the external task @@ -149,6 +172,7 @@ flowable-smoke: flowable-up ## flowable-down: stop and remove Flowable flowable-down: docker compose -f $(FL_COMPOSE) down --volumes + -docker volume rm -f rr-fl-bpmn ## help: list available targets help: diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 107b985..f6445d5 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -16,16 +16,18 @@ and CI cannot drift: | `lint` | `make lint` → `dotnet format … --verify-no-changes` | .NET 10 SDK | | `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK | | `unit` | `make unit` → `dotnet test … -c Release` | .NET 10 SDK | -| `compose-smoke` | `make smoke` → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | +| `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`, `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea Actions resolves them from GitHub. > **`compose-smoke` runs on a containerized runner.** Workspace bind mounts do -> **not** reach the sibling containers Compose starts, so config/assets are baked -> into derived images instead of being mounted. If you add a service that needs a -> repo file at runtime, bake it — don't bind-mount it. See +> **not** reach the sibling containers Compose starts, so config/assets are +> streamed into external named volumes via `docker cp` (`infra/seed-config.sh`), +> and the upstream images are used verbatim (no build). If you add a service that +> needs a repo file at runtime, seed it the same way — don't bind-mount it. Note: +> bare `docker compose up` no longer self-seeds; use `make up`. See > [gitea-actions-gotchas.md](gitea-actions-gotchas.md). ## Running CI locally (`make ci`) diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 7d8bd31..42e5bc0 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -38,39 +38,50 @@ not happen on a runner that executes jobs directly on the host (the previous self-hosted `respellion-linux` setup), which is why switching to `ubuntu-latest` exposed it. -**Fix: bake assets into derived images instead of bind-mounting them.** Anything -a Compose service needs at runtime that lives in the repo is `COPY`-ed into a -small derived image, so it is present regardless of where the daemon runs. We use -**`build.dockerfile_inline`** — the 2-line recipe lives in the compose file, so -there are no standalone `Dockerfile` artifacts to maintain: +**Fix: the upstream images are used verbatim (no build); config is streamed into +external named volumes with `docker cp`.** `infra/seed-config.sh` creates a fixed- +name volume per asset, runs a throwaway helper container that mounts it, and +`docker cp`s the files in. `docker cp` streams bytes over the Docker API, so it +works no matter where the daemon runs (including Docker-in-Docker). The services +then mount those volumes: -| Asset | Derived image | Built by | +| Asset | External volume | Mounted by → at | |---|---|---| -| OpenZaak `setup_configuration/data.yaml` | `register-referentie/openzaak:dev` | `oz-init` `dockerfile_inline` | -| Keycloak realm exports | `register-referentie/keycloak:dev` | `keycloak` `dockerfile_inline` | -| `workflows/registratie.bpmn` | `register-referentie/flowable-init:dev` | `flowable-init` `dockerfile_inline` | +| OpenZaak `setup_configuration/data.yaml` | `rr-oz-config` | `oz-init` → `/app/setup_configuration` | +| Keycloak realm exports | `rr-kc-realms` | `keycloak` → `/opt/keycloak/data/import` | +| `workflows/registratie.bpmn` | `rr-fl-bpmn` | `flowable-init` → `/work` | -The base image tag is interpolated by Compose (`${OPENZAAK_TAG}`) so pinning is -unchanged. OpenZaak's `openzaak`/`oz-celery` reuse the one image `oz-init` builds. -Open Notificaties needs **no** bake — `nrc-init` runs migrations only, so all NRC -services use the plain base image. +The volumes are declared `external: true` with fixed `name:`s so they resolve +identically under docker compose and podman-compose. The seed step (`make` runs +it before every `up`) recreates them fresh each time; `make down` / the +per-service `*-down` targets remove them. Open Notificaties needs no config at all +— `nrc-init` runs migrations only. -`dockerfile_inline` works on both the CI runner (docker compose) and local -**podman-compose**, unlike `docker cp`-into-volumes (which needs `docker compose -create`, a subcommand podman-compose lacks). +**Two hard constraints drove this design:** + +- Use plain `docker volume create` / `docker run` / `docker cp` — **not** + `docker compose create`, which **podman-compose** (the local dev runtime) does + not implement. +- `docker cp` rather than a bind mount of the source dir, because that bind mount + is exactly what fails on the containerized runner. + +**Consequence: bare `docker compose up` no longer self-seeds** — the `external` +volumes must be populated first, so use **`make up`** (or `make -up`), which +seeds then starts. CI uses `make smoke`, which does the same. **Why not the alternatives.** +- *Bake into a (possibly inline) image* — clean and portable, but it's a build; + rejected here because the goal was to use the upstream images verbatim. - *Compose `configs:` with inline `content`* — Compose materialises these as a - temp file on the **client** side and bind-mounts it, so it hits the exact same - daemon-can't-see-the-path problem. -- *A self-hosted runner that runs jobs on the host* — works, but reintroduces a - bespoke runner and undoes the move to the hosted `ubuntu-latest` label. + temp file on the **client** side and bind-mounts it → same daemon-can't-see-it + problem. +- *A self-hosted runner that runs jobs on the host* — bind mounts would then work + with zero seeding, but it reintroduces a bespoke runner and undoes the move to + the hosted `ubuntu-latest` label. -**Consequence for local dev.** There is now no bind mount of these config files, -so the SELinux `:z`/`:Z` relabel flag is no longer needed anywhere in `infra/`, -and rootless Podman no longer needs the files to be world-readable. One mechanism -(build) works on both Podman locally and Docker-in-Docker in CI. +No bind mounts of these config files remain, so the SELinux `:z`/`:Z` relabel flag +is no longer needed anywhere in `infra/` (named volumes don't need relabeling). ## `--wait` fails on one-shot containers with no dependant diff --git a/docs/runbooks/openzaak.md b/docs/runbooks/openzaak.md index 7196d2a..b054416 100644 --- a/docs/runbooks/openzaak.md +++ b/docs/runbooks/openzaak.md @@ -73,7 +73,10 @@ The Makefile auto-points `DOCKER_HOST` at the Podman socket when it exists, so t resultaattypen — beyond the lean seed). List with `?status=alles`. - **Image tag.** Pinned to `openzaak/open-zaak:1.28.2` via `${OPENZAAK_TAG}` (bump deliberately, not via `:latest`). -- **Config is baked, not mounted.** `setup_configuration/data.yaml` is `COPY`-ed into - a derived image via `oz-init`'s inline Dockerfile (`build.dockerfile_inline` in - the compose) rather than bind-mounted, so the init container finds it on Gitea's - containerized CI runner too. See [gitea-actions-gotchas.md](gitea-actions-gotchas.md). +- **Config arrives via a volume, not a bind mount.** `setup_configuration/data.yaml` + is streamed into the external `rr-oz-config` volume by `infra/seed-config.sh` + (`docker cp`) and mounted at `/app/setup_configuration`, so the init container + finds it on Gitea's containerized CI runner too (bind mounts don't reach sibling + containers there). The image is the upstream `openzaak/open-zaak` verbatim — no + build. Run via `make openzaak-up` (seeds first). See + [gitea-actions-gotchas.md](gitea-actions-gotchas.md). diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index b63f7be..b138c6a 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -47,17 +47,7 @@ services: networks: [cg] oz-init: - # Derived image: base OpenZaak + the setup_configuration YAML baked in. We use - # an inline Dockerfile (not a separate file, not a bind mount) because bind - # mounts don't reach sibling containers on the containerized CI runner. - # The ${OPENZAAK_TAG} below is interpolated by Compose. See - # docs/runbooks/gitea-actions-gotchas.md. - build: - context: ./openzaak - dockerfile_inline: | - FROM docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} - COPY setup_configuration /app/setup_configuration - image: register-referentie/openzaak:dev + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} @@ -78,6 +68,10 @@ services: OPENZAAK_SUPERUSER_EMAIL: admin@localhost RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh + # data.yaml is streamed into this external volume by infra/seed-config.sh + # before start (bind mounts don't reach sibling containers on the CI runner). + volumes: + - oz-config:/app/setup_configuration:ro depends_on: oz-db: condition: service_healthy @@ -86,7 +80,7 @@ services: networks: [cg] openzaak: - image: register-referentie/openzaak:dev + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -102,7 +96,7 @@ services: networks: [cg] oz-celery: - image: register-referentie/openzaak:dev + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env command: /celery_worker.sh depends_on: @@ -193,14 +187,7 @@ services: # ── Keycloak (S-02) ────────────────────────────────────────────────────── keycloak: - # Derived image: base Keycloak + the realm exports baked into the import dir - # (inline Dockerfile, no bind mount — see docs/runbooks/gitea-actions-gotchas.md). - build: - context: ./keycloak - dockerfile_inline: | - FROM quay.io/keycloak/keycloak:26.1 - COPY realms /opt/keycloak/data/import - image: register-referentie/keycloak:dev + image: quay.io/keycloak/keycloak:26.1 command: ["start-dev", "--import-realm"] environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin @@ -211,6 +198,9 @@ services: KC_HTTP_ENABLED: "true" ports: - "8180:8080" + # realm exports are streamed into this external volume by infra/seed-config.sh. + volumes: + - kc-realms:/opt/keycloak/data/import:ro networks: [cg] # ── Flowable (S-03) ────────────────────────────────────────────────────── @@ -244,16 +234,11 @@ services: networks: [cg] flowable-init: - # The BPMN is baked into a tiny curl image (build context = repo-root - # workflows/) instead of bind-mounted, so the deploy works on Gitea's - # containerized runner too. See docs/runbooks/gitea-actions-gotchas.md. - build: - context: ../workflows - dockerfile_inline: | - FROM docker.io/curlimages/curl:latest - COPY registratie.bpmn /work/registratie.bpmn - image: register-referentie/flowable-init:dev + image: docker.io/curlimages/curl:latest restart: "no" + # registratie.bpmn is streamed into this external volume by infra/seed-config.sh. + volumes: + - fl-bpmn:/work:ro command: - sh - -c @@ -318,6 +303,18 @@ volumes: oz-db: nrc-db: flowable-db: + # Config volumes — created and populated out-of-band by infra/seed-config.sh + # (docker cp), because bind mounts don't reach sibling containers on the CI + # runner. `external` keeps the names deterministic; the seed step manages them. + oz-config: + external: true + name: rr-oz-config + kc-realms: + external: true + name: rr-kc-realms + fl-bpmn: + external: true + name: rr-fl-bpmn networks: cg: diff --git a/infra/flowable/docker-compose.yml b/infra/flowable/docker-compose.yml index c66caeb..f1921b6 100644 --- a/infra/flowable/docker-compose.yml +++ b/infra/flowable/docker-compose.yml @@ -38,16 +38,11 @@ services: # Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up. # Idempotent: skips if a deployment named "registratie" already exists. flowable-init: - # The BPMN is baked into a tiny curl image (build context = repo-root - # workflows/) instead of bind-mounted, so the deploy works on Gitea's - # containerized runner too. See docs/runbooks/gitea-actions-gotchas.md. - build: - context: ../../workflows - dockerfile_inline: | - FROM docker.io/curlimages/curl:latest - COPY registratie.bpmn /work/registratie.bpmn - image: register-referentie/flowable-init:dev + image: docker.io/curlimages/curl:latest restart: "no" + # registratie.bpmn is streamed into this external volume by infra/seed-config.sh. + volumes: + - fl-bpmn:/work:ro command: - sh - -c @@ -66,6 +61,10 @@ services: volumes: flowable-db: + # populated out-of-band by infra/seed-config.sh (docker cp) — see that script. + fl-bpmn: + external: true + name: rr-fl-bpmn networks: cg: diff --git a/infra/keycloak/docker-compose.yml b/infra/keycloak/docker-compose.yml index 7d74e0c..678b553 100644 --- a/infra/keycloak/docker-compose.yml +++ b/infra/keycloak/docker-compose.yml @@ -9,14 +9,7 @@ # Admin console: http://localhost:8180/ (admin / admin — dev only) services: keycloak: - # Derived image: base Keycloak + realm exports baked into the import dir via an - # inline Dockerfile. See docs/runbooks/gitea-actions-gotchas.md. - build: - context: . - dockerfile_inline: | - FROM quay.io/keycloak/keycloak:26.1 - COPY realms /opt/keycloak/data/import - image: register-referentie/keycloak:dev + image: quay.io/keycloak/keycloak:26.1 command: ["start-dev", "--import-realm"] environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin @@ -28,7 +21,15 @@ services: KC_HTTP_ENABLED: "true" ports: - "8180:8080" + # realm exports are streamed into this external volume by infra/seed-config.sh. + volumes: + - kc-realms:/opt/keycloak/data/import:ro networks: [cg] +volumes: + kc-realms: + external: true + name: rr-kc-realms + networks: cg: diff --git a/infra/openzaak/docker-compose.yml b/infra/openzaak/docker-compose.yml index c3c5174..9f9e50a 100644 --- a/infra/openzaak/docker-compose.yml +++ b/infra/openzaak/docker-compose.yml @@ -32,14 +32,7 @@ services: networks: [cg] oz-init: - # Derived image: base OpenZaak + setup_configuration baked in via an inline - # Dockerfile. See docs/runbooks/gitea-actions-gotchas.md for why not a mount. - build: - context: . - dockerfile_inline: | - FROM docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} - COPY setup_configuration /app/setup_configuration - image: register-referentie/openzaak:dev + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: &oz-env DJANGO_SETTINGS_MODULE: openzaak.conf.docker SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} @@ -62,6 +55,9 @@ services: OPENZAAK_SUPERUSER_EMAIL: admin@localhost RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh + # data.yaml is streamed into this external volume by infra/seed-config.sh. + volumes: + - oz-config:/app/setup_configuration:ro depends_on: oz-db: condition: service_healthy @@ -70,7 +66,7 @@ services: networks: [cg] openzaak: - image: register-referentie/openzaak:dev + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env healthcheck: test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] @@ -86,7 +82,7 @@ services: networks: [cg] oz-celery: - image: register-referentie/openzaak:dev + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} environment: *oz-env command: /celery_worker.sh depends_on: @@ -96,6 +92,10 @@ services: volumes: oz-db: + # populated out-of-band by infra/seed-config.sh (docker cp) — see that script. + oz-config: + external: true + name: rr-oz-config networks: cg: diff --git a/infra/seed-config.sh b/infra/seed-config.sh new file mode 100755 index 0000000..64d2993 --- /dev/null +++ b/infra/seed-config.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Populate the external named *config* volumes that the upstream services mount, +# by `docker cp`-ing files into a throwaway helper container that mounts each one. +# +# Why: the compose stack uses the upstream images verbatim (no build). On Gitea's +# containerized runner, `docker compose` starts the stack as SIBLING containers +# via the host daemon, so a workspace bind mount resolves to a path the daemon +# can't see and is mounted empty. `docker cp` instead streams bytes over the +# Docker API, so the files reach the volume regardless of where the daemon runs. +# We use plain docker primitives (volume create / run / cp / rm) rather than +# `docker compose create`, because podman-compose (local dev) lacks that +# subcommand. Fixed-name `external` volumes keep the names deterministic across +# both runtimes. See docs/runbooks/gitea-actions-gotchas.md. +# +# Usage: seed-config.sh [ ...] where key ∈ { oz, kc, fl } +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELPER="${SEED_HELPER_IMAGE:-docker.io/library/busybox:stable}" + +populate() { # volume source(file or dir/.) + local vol="$1" src="$2" cid + docker volume rm -f "$vol" >/dev/null 2>&1 || true + docker volume create "$vol" >/dev/null + # A *created* (never started) helper is enough: the volume is attached at create + # time, `docker cp` writes through to it, and `docker rm` is instant (nothing to + # stop). `docker create` is a container subcommand both docker and podman have — + # unlike `docker compose create`, which podman-compose lacks. + cid="$(docker create -v "$vol:/dest" "$HELPER" true)" + docker cp "$src" "$cid:/dest/" + docker rm "$cid" >/dev/null + echo " seeded $vol" +} + +[ "$#" -gt 0 ] || { echo "usage: seed-config.sh ..." >&2; exit 2; } + +for key in "$@"; do + case "$key" in + oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;; + kc) populate rr-kc-realms "$here/keycloak/realms/." ;; + fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;; + *) echo "unknown seed key: $key" >&2; exit 2 ;; + esac +done From dda4c58e1c3342913c31f0170b4996c5a17cb1ff Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 10:57:52 +0200 Subject: [PATCH 10/12] fix(infra): portable health poll instead of compose --wait (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make smoke` errored locally because podman-compose doesn't implement `docker compose up --wait` (`unrecognized arguments: --wait`). Replace the `--wait` step with infra/wait-healthy.sh, which polls each durable health-checked service ($(WAIT_SVCS)) via `docker ps` + `docker inspect '{{.State.Health.Status}}'`. This: - works on both docker compose (CI) and podman-compose (local) — only plain docker primitives, no `--wait`; - reads the in-container healthcheck, so it needs no host port access (the CI runner can't reach published ports); - ignores the one-shot init jobs, sidestepping the "--wait fails when a consumer-less one-shot exits 0" issue (flowable-init). Verified on podman-compose: wait-healthy.sh reports bff healthy (rc=0); podman exposes .State.Health.Status (starting -> healthy) and the name filter matches both `_` and `-` container naming. Docs: gitea-actions-gotchas.md updated (the two `--wait` sections folded into one "portable health poll" section). Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 21 ++++++------ docs/runbooks/gitea-actions-gotchas.md | 44 ++++++++++---------------- infra/wait-healthy.sh | 36 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 38 deletions(-) create mode 100755 infra/wait-healthy.sh diff --git a/Makefile b/Makefile index b3908f3..b6cdec2 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,9 @@ SLN := register-referentie.slnx COMPOSE := infra/docker-compose.yml -# Long-running services with a healthcheck — the smoke waits on these. One-shot -# init jobs (oz-init, nrc-init, flowable-init) are NOT listed: `--wait` fails when -# a one-shot with no `service_completed_successfully` dependant exits (flowable-init), -# so we wait on the durable services instead. See docs/runbooks/gitea-actions-gotchas.md. +# Long-running services with a healthcheck — the smoke polls these for readiness +# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) +# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. WAIT_SVCS := openzaak nrc-web acl bff # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # into external named volumes via `docker cp` (infra/seed-config.sh) instead of @@ -58,16 +57,16 @@ unit: dotnet test $(SLN) -c Release ## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down -# SEED populates the external config volumes first (the upstream images are used -# verbatim — no build for them). `up -d --build` then starts EVERYTHING (building -# only our acl/bff). The second `up --wait` waits for the durable, health-checked -# services ($(WAIT_SVCS)); the one-shots are excluded because `--wait` fails when a -# one-shot with no `service_completed_successfully` dependant exits (flowable-init). -# Healthchecks run inside the containers, so no host port access is needed. +# SEED populates the external config volumes first (upstream images used verbatim; +# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is +# checked by infra/wait-healthy.sh polling the durable, health-checked services +# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and +# podman-compose, and needing no `--wait` flag or host port access. The one-shots +# (oz-init, flowable-init) aren't polled; they just need to have run. smoke: $(SEED) oz kc fl docker compose -f $(COMPOSE) up -d --build - bash -c 'docker compose -f $(COMPOSE) up -d --wait --wait-timeout 420 $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc' + bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc' ## up: seed config volumes and start the full stack (use instead of bare ## `docker compose up`, which can't self-seed the external config volumes) diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 42e5bc0..52a88cc 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -83,36 +83,26 @@ seeds then starts. CI uses `make smoke`, which does the same. No bind mounts of these config files remain, so the SELinux `:z`/`:Z` relabel flag is no longer needed anywhere in `infra/` (named volumes don't need relabeling). -## `--wait` fails on one-shot containers with no dependant +## Readiness: a portable health poll, not `docker compose up --wait` -`docker compose up --wait` treats a service that **exits** as a failure of the -"stay up" condition — **unless** another service depends on it with -`condition: service_completed_successfully`. Our init jobs `oz-init` and -`nrc-init` are fine (`openzaak`/`nrc-web` depend on their completion), but -`flowable-init` deploys the BPMN and exits 0 with **no dependant**, so a -whole-project `--wait` fails the moment it exits — even with everything else -healthy. The symptom is a `compose-smoke` failure whose last compose line is: +The smoke does **not** use `docker compose up --wait`, for three reasons: -``` -container infra-flowable-init-1 exited (0) -``` +- **podman-compose doesn't implement `--wait`** (`unrecognized arguments: + --wait`), so it would break local dev. +- A whole-project `--wait` **fails when a one-shot with no + `service_completed_successfully` dependant exits** — `flowable-init` deploys + the BPMN and exits 0, which `--wait` treats as the project failing (symptom: + last compose line `container infra-flowable-init-1 exited (0)`). +- The containerized CI runner **can't reach published host ports**, so an + external `curl localhost:8080/health` doesn't work either. -**Fix.** The smoke does **not** `--wait` on the whole project. It starts -everything with `up -d`, then `up -d --wait ` only for the durable, -health-checked services (`openzaak nrc-web acl bff` — see `WAIT_SVCS` in the -`Makefile`). One-shots still run (and deploy), they just don't gate `--wait`. - -This also removed the old external `curl http://localhost:8080/health` check: -the CI job runs in a container and **can't reach published host ports** at -`localhost`, and the per-service healthchecks (which run *inside* the -containers) already prove readiness, so `--wait` succeeding *is* the smoke. - -## `--wait` needs an explicit timeout - -`docker compose up --wait` defaults to a 60-second timeout in some Compose v2 -releases. A cold OpenZaak migrate alone takes ~50 s, so the smoke target passes -`--wait-timeout 300` (see `Makefile`). The 3-minute Definition-of-Done budget -still holds — this just stops `--wait` giving up before the stack is healthy. +**Fix.** `infra/wait-healthy.sh` polls each durable, health-checked service +(`openzaak nrc-web acl bff` — `WAIT_SVCS` in the `Makefile`) with `docker ps` + +`docker inspect '{{.State.Health.Status}}'`, waiting for `healthy`. That uses +only primitives both docker compose and podman-compose support, reads the +in-container healthcheck (no host port needed), and ignores the one-shots (they +only need to have run). `WAIT_TIMEOUT` (default 420 s) covers the cold +OpenZaak migrate (~90 s) plus app start. ## PostGIS readiness vs. `pg_isready` diff --git a/infra/wait-healthy.sh b/infra/wait-healthy.sh new file mode 100755 index 0000000..a054f70 --- /dev/null +++ b/infra/wait-healthy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Wait until the named compose services report a healthy healthcheck. +# +# Portable across `docker compose` (CI) and `podman-compose` (local dev): it uses +# plain `docker ps` + `docker inspect`, so it needs neither `docker compose +# up --wait` (podman-compose doesn't implement that flag) nor host port access +# (the containerized CI runner can't reach published ports). It also sidesteps the +# `--wait`-fails-when-a-one-shot-exits issue, since we only poll long-running +# services that declare a healthcheck. See docs/runbooks/gitea-actions-gotchas.md. +# +# Usage: WAIT_TIMEOUT=420 wait-healthy.sh [ ...] +set -euo pipefail + +timeout="${WAIT_TIMEOUT:-420}" +deadline=$(( $(date +%s) + timeout )) + +# compose service name -> container id. The name filter matches both docker +# compose ("infra-openzaak-1") and podman-compose ("infra_openzaak_1") naming. +cid_for() { docker ps -aq --filter "name=$1" | head -1; } + +for svc in "$@"; do + echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..." + while :; do + cid="$(cid_for "$svc")" + status="" + [ -n "$cid" ] && status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || true)" + [ "$status" = "healthy" ] && { echo " '$svc' is healthy"; break; } + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "TIMEOUT: '$svc' not healthy (status=${status:-no-container})" >&2 + docker ps -a --filter "name=$svc" >&2 || true + exit 1 + fi + sleep 3 + done +done From e87113da24d9a367a0eae9c350b243125efdf480 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 12:00:32 +0200 Subject: [PATCH 11/12] feat(infra): add bind-mount local compose for no-make/Windows dev (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infra/docker-compose.local.yml: the same full stack as the canonical infra/docker-compose.yml, but the three config inputs (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are bind-mounted from the repo instead of streamed into external volumes by seed-config.sh. Bind mounts are valid here because a local daemon (Docker Desktop on Windows/ macOS, or rootless Podman on Linux) can see the working directory — the seed dance only exists for the containerized CI runner, where it can't. So this file runs with a plain `docker compose up`: no make, no seed step, no bash. docker compose -f infra/docker-compose.local.yml up -d --build docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop Linux/macOS convenience wrappers `make local` / `make local-down` added too. Verified on podman: Keycloak boots from this file and imports the bind-mounted realms (digid realm returns 200). docs/runbooks/ci.md documents the Windows path. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 16 +- docs/runbooks/ci.md | 20 +++ infra/docker-compose.local.yml | 296 +++++++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 infra/docker-compose.local.yml diff --git a/Makefile b/Makefile index b6cdec2..403475b 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,10 @@ WAIT_SVCS := openzaak nrc-web acl bff # explicit teardown. See docs/runbooks/gitea-actions-gotchas.md. SEED := bash infra/seed-config.sh CFG_VOLS := rr-oz-config rr-kc-realms rr-fl-bpmn +# Local-only stack: same services but config is bind-mounted (no seed step), so a +# plain `docker compose -f infra/docker-compose.local.yml up` works on any local +# engine. This is the no-make / Windows-friendly path. See that file's header. +LOCAL_COMPOSE := infra/docker-compose.local.yml OZ_COMPOSE := infra/openzaak/docker-compose.yml OZ_BASE := http://localhost:8000 NRC_COMPOSE := infra/opennotificaties/docker-compose.yml @@ -39,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit smoke up down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help +.PHONY: ci lint build unit smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help ## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions) ci: lint build unit smoke @@ -79,6 +83,16 @@ down: docker compose -f $(COMPOSE) down --volumes -docker volume rm -f $(CFG_VOLS) +## local: bring up the bind-mount stack (no seed step) and wait for health +## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly) +local: + docker compose -f $(LOCAL_COMPOSE) up -d --build + WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) + +## local-down: stop and remove the bind-mount stack +local-down: + docker compose -f $(LOCAL_COMPOSE) down --volumes + ## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff) changelog: git-cliff --output CHANGELOG.md diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index f6445d5..704a6c8 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -30,6 +30,26 @@ Actions resolves them from GitHub. > bare `docker compose up` no longer self-seeds; use `make up`. See > [gitea-actions-gotchas.md](gitea-actions-gotchas.md). +## Running the stack locally without `make` (Windows / Docker Desktop) + +`make` and the bash helpers assume a Unix shell. To bring the whole stack up on a +machine without them (e.g. Windows + Docker Desktop), use the **local compose +file**, which bind-mounts the config instead of seeding volumes — so it needs no +`make`, no seed step, and no bash: + +```bash +docker compose -f infra/docker-compose.local.yml up -d --build # any engine +docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop (Compose v2) +docker compose -f infra/docker-compose.local.yml down --volumes +``` + +On Linux/macOS the same thing is wrapped as `make local` / `make local-down`. + +`infra/docker-compose.local.yml` mirrors the canonical `infra/docker-compose.yml` +but swaps the external config volumes for bind mounts — valid locally because a +local daemon can see the working directory (the seed/volume dance only exists for +the containerized CI runner). Keep the two files in sync. + ## Running CI locally (`make ci`) Until the runner exists, run the full pipeline yourself before pushing: diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml new file mode 100644 index 0000000..691ec77 --- /dev/null +++ b/infra/docker-compose.local.yml @@ -0,0 +1,296 @@ +# LOCAL development stack — runs with a plain `docker compose up`, no make / no +# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or +# macOS, or rootless Podman on Linux). +# +# docker compose -f infra/docker-compose.local.yml up -d --build # podman +# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop +# docker compose -f infra/docker-compose.local.yml down --volumes +# +# It is identical to infra/docker-compose.yml EXCEPT that the three config inputs +# (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are **bind-mounted** from +# the repo instead of being streamed into external volumes by infra/seed-config.sh. +# Bind mounts work here because a local daemon can see your working directory — +# the seed dance only exists for the containerized CI runner, where it can't. See +# docs/runbooks/gitea-actions-gotchas.md. +# +# `infra/docker-compose.yml` remains the CI-canonical stack; keep the two in sync. +# +# Port map (host): +# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST +# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only) + +services: + + # ── OpenZaak (S-01) ────────────────────────────────────────────────────── + oz-db: + image: docker.io/postgis/postgis:17-3.5 + environment: + POSTGRES_USER: openzaak + POSTGRES_PASSWORD: openzaak + POSTGRES_DB: openzaak + command: postgres -c max_connections=300 + volumes: + - oz-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 15s + networks: [cg] + + oz-redis: + image: docker.io/library/redis:7 + networks: [cg] + + oz-init: + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + environment: &oz-env + DJANGO_SETTINGS_MODULE: openzaak.conf.docker + SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production} + DB_HOST: oz-db + DB_NAME: openzaak + DB_USER: openzaak + DB_PASSWORD: openzaak + IS_HTTPS: "no" + ALLOWED_HOSTS: "*" + CACHE_DEFAULT: oz-redis:6379/0 + CACHE_AXES: oz-redis:6379/0 + CELERY_BROKER_URL: redis://oz-redis:6379/1 + CELERY_RESULT_BACKEND: redis://oz-redis:6379/1 + DISABLE_2FA: "true" + NOTIFICATIONS_DISABLED: "true" + OPENZAAK_SUPERUSER_USERNAME: admin + DJANGO_SUPERUSER_PASSWORD: admin + OPENZAAK_SUPERUSER_EMAIL: admin@localhost + RUN_SETUP_CONFIG: "true" + command: /setup_configuration.sh + # Bind mount (`:z` relabels for SELinux on Linux; a no-op on Docker Desktop). + volumes: + - ./openzaak/setup_configuration:/app/setup_configuration:ro,z + depends_on: + oz-db: + condition: service_healthy + oz-redis: + condition: service_started + networks: [cg] + + openzaak: + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + environment: *oz-env + healthcheck: + test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + ports: + - "8000:8000" + depends_on: + oz-init: + condition: service_completed_successfully + networks: [cg] + + oz-celery: + image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2} + environment: *oz-env + command: /celery_worker.sh + depends_on: + oz-init: + condition: service_completed_successfully + networks: [cg] + + # ── Open Notificaties / NRC (S-01-c) ───────────────────────────────────── + nrc-db: + image: docker.io/postgis/postgis:17-3.5 + environment: + POSTGRES_USER: opennotificaties + POSTGRES_PASSWORD: opennotificaties + POSTGRES_DB: opennotificaties + command: postgres -c max_connections=300 + volumes: + - nrc-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + nrc-redis: + image: docker.io/library/redis:7 + networks: [cg] + + nrc-init: + # Migrations only (see the canonical compose / ADR-0002); no config needed. + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + environment: &nrc-env + DJANGO_SETTINGS_MODULE: nrc.conf.docker + SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production} + DB_HOST: nrc-db + DB_NAME: opennotificaties + DB_USER: opennotificaties + DB_PASSWORD: opennotificaties + IS_HTTPS: "no" + ALLOWED_HOSTS: "*" + CACHE_DEFAULT: nrc-redis:6379/0 + CACHE_AXES: nrc-redis:6379/0 + CELERY_BROKER_URL: redis://nrc-redis:6379/1 + CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1 + DISABLE_2FA: "true" + OPENNOTIFICATIES_SUPERUSER_USERNAME: admin + DJANGO_SUPERUSER_PASSWORD: admin + OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost + command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"] + depends_on: + nrc-db: + condition: service_healthy + nrc-redis: + condition: service_started + openzaak: + condition: service_healthy + networks: [cg] + + nrc-web: + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + environment: *nrc-env + healthcheck: + test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + ports: + - "8001:8000" + depends_on: + nrc-init: + condition: service_completed_successfully + networks: [cg] + + nrc-celery: + image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1} + environment: *nrc-env + command: /celery_worker.sh + depends_on: + nrc-init: + condition: service_completed_successfully + networks: [cg] + + # ── Keycloak (S-02) ────────────────────────────────────────────────────── + keycloak: + image: quay.io/keycloak/keycloak:26.1 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_HTTP_ENABLED: "true" + ports: + - "8180:8080" + volumes: + - ./keycloak/realms:/opt/keycloak/data/import:ro,z + networks: [cg] + + # ── Flowable (S-03) ────────────────────────────────────────────────────── + flowable-db: + image: docker.io/library/postgres:16 + environment: + POSTGRES_USER: flowable + POSTGRES_PASSWORD: flowable + POSTGRES_DB: flowable + volumes: + - flowable-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"] + interval: 5s + timeout: 3s + retries: 10 + networks: [cg] + + flowable-rest: + image: docker.io/flowable/flowable-rest:latest + environment: + SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver + SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable + SPRING_DATASOURCE_USERNAME: flowable + SPRING_DATASOURCE_PASSWORD: flowable + ports: + - "8090:8080" + depends_on: + flowable-db: + condition: service_healthy + networks: [cg] + + flowable-init: + image: docker.io/curlimages/curl:latest + restart: "no" + volumes: + - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z + command: + - sh + - -c + - | + base=http://flowable-rest:8080/flowable-rest/service/repository/deployments + until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done + if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then + echo "registratie already deployed; skip" + else + curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie" + fi + depends_on: + flowable-rest: + condition: service_started + networks: [cg] + + # ── ACL ────────────────────────────────────────────────────────────────── + acl: + build: + context: ../services/acl + dockerfile: Dockerfile + image: register-referentie/acl:dev + environment: + Acl__OpenZaak__BaseUrl: http://openzaak:8000/ + Acl__OpenZaak__ClientId: big-reference-seed + Acl__OpenZaak__Secret: insecure-dev-secret-change-me + Acl__Defaults__Bronorganisatie: "517439943" + Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" + Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar + Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000} + ports: + - "8100:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + depends_on: + openzaak: + condition: service_healthy + networks: [cg] + + # ── BFF ────────────────────────────────────────────────────────────────── + bff: + build: + context: ../services/bff + dockerfile: Dockerfile + image: register-referentie/bff:dev + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + networks: [cg] + +volumes: + oz-db: + nrc-db: + flowable-db: + +networks: + cg: From f9e123dfcbc61295b9a9ab3256ccb6b139246679 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 14:00:51 +0200 Subject: [PATCH 12/12] docs(infra): tighten gitea-actions-gotchas, add local compose (refs #30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure for scannability: a shared root-cause intro, a quick-reference table (gotcha → fix → where), and consistent Symptom/Why/Fix sections with tighter prose. Documents infra/docker-compose.local.yml as the no-make/Windows path and drops the now-stale "no bind mounts remain" line (the local compose uses them, which is fine locally). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/runbooks/gitea-actions-gotchas.md | 162 +++++++++++-------------- 1 file changed, 74 insertions(+), 88 deletions(-) diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 52a88cc..52da86d 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -1,114 +1,100 @@ # Gitea Actions gotchas -Known differences between Gitea Actions (our CI) and a plain local run, and the -workarounds we adopted. Referenced by `CLAUDE.md` §8.7 and §15. +How our CI (Gitea Actions on the hosted **`ubuntu-latest`** runner) differs from a +local run, and the workarounds in this repo. Referenced by `CLAUDE.md` §8.7/§15. -## Bind mounts don't reach Compose services on the hosted runner +**One root cause sits under most of this:** the runner executes the job **inside a +container**, so when a step runs `docker compose up`, Compose starts the stack as +**sibling containers** on the host's daemon. Anything that assumes the job and +those containers share a filesystem — or a `localhost` — breaks. -**Symptom.** `make smoke` is green locally but the `compose-smoke` CI job fails -with the OpenZaak init container exiting 1: +| Gotcha | Fix | Lives in | +|---|---|---| +| Bind-mounted config arrives empty | `docker cp` config into external volumes | `infra/seed-config.sh` | +| `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` | +| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks | + +--- + +## 1. Bind mounts don't reach the containers + +**Symptom** — green locally, but `compose-smoke` fails with: ``` oz-init-1 | CommandError: Yaml file `/app/setup_configuration/data.yaml` does not exist. ``` -Migrations run fine; only the step that reads a **mounted** file fails. The same -class of failure hits any service that bind-mounts a workspace path — -`nrc-init` (its `data.yaml`), `flowable-init` (the BPMN), `keycloak` (the realm -import dir). +Migrations run fine; only the step that reads a *mounted* file fails. The same +trap hits `nrc-init`, `flowable-init`, and `keycloak`. -**Cause.** The `ubuntu-latest` runner executes the whole job **inside a -container** (`docker.gitea.com/runner-images:ubuntu-latest`). When the job then -runs `docker compose ... up`, Compose talks to the host's Docker daemon and -starts the stack as **sibling containers**. A relative bind mount such as +**Why** — a relative bind mount like `./openzaak/setup_configuration:/app/...` is +resolved by Compose to a path *inside the job container* +(`/workspace/.../setup_configuration`). The daemon then looks for that path on +*its own host*, doesn't find it, and mounts an **empty directory**. (It works on a +runner that executes jobs on the host — which is why moving to `ubuntu-latest` +exposed it.) -```yaml -volumes: - - ./openzaak/setup_configuration:/app/setup_configuration:ro -``` +**Fix** — use the upstream images verbatim (no build) and stream config into +**external named volumes** with `docker cp`, which copies over the Docker API and +so works wherever the daemon runs. `infra/seed-config.sh` creates each volume, +mounts it in a throwaway helper, and copies the files in: -is resolved by Compose to an absolute path **inside the job container** -(`/workspace/eho/register-referentie/infra/openzaak/setup_configuration`). The -daemon then looks for that path on **its own host**, doesn't find it, and -auto-creates an **empty directory** to mount. The container starts with an empty -mount point, so the file appears "missing". - -This is the classic Docker-in-Docker / sibling-container bind-mount trap. It does -not happen on a runner that executes jobs directly on the host (the previous -self-hosted `respellion-linux` setup), which is why switching to `ubuntu-latest` -exposed it. - -**Fix: the upstream images are used verbatim (no build); config is streamed into -external named volumes with `docker cp`.** `infra/seed-config.sh` creates a fixed- -name volume per asset, runs a throwaway helper container that mounts it, and -`docker cp`s the files in. `docker cp` streams bytes over the Docker API, so it -works no matter where the daemon runs (including Docker-in-Docker). The services -then mount those volumes: - -| Asset | External volume | Mounted by → at | +| Asset | Volume | Mounted at | |---|---|---| -| OpenZaak `setup_configuration/data.yaml` | `rr-oz-config` | `oz-init` → `/app/setup_configuration` | -| Keycloak realm exports | `rr-kc-realms` | `keycloak` → `/opt/keycloak/data/import` | -| `workflows/registratie.bpmn` | `rr-fl-bpmn` | `flowable-init` → `/work` | +| OpenZaak `data.yaml` | `rr-oz-config` | `oz-init:/app/setup_configuration` | +| Keycloak realms | `rr-kc-realms` | `keycloak:/opt/keycloak/data/import` | +| `registratie.bpmn` | `rr-fl-bpmn` | `flowable-init:/work` | -The volumes are declared `external: true` with fixed `name:`s so they resolve -identically under docker compose and podman-compose. The seed step (`make` runs -it before every `up`) recreates them fresh each time; `make down` / the -per-service `*-down` targets remove them. Open Notificaties needs no config at all -— `nrc-init` runs migrations only. +The volumes are `external: true` with fixed names, so they resolve identically +under docker compose and podman-compose. `make` seeds before every `up`; `make +down` removes them. (Open Notificaties needs nothing — `nrc-init` migrates only.) -**Two hard constraints drove this design:** +**Consequence — bare `docker compose up` can't self-seed external volumes:** -- Use plain `docker volume create` / `docker run` / `docker cp` — **not** - `docker compose create`, which **podman-compose** (the local dev runtime) does - not implement. -- `docker cp` rather than a bind mount of the source dir, because that bind mount - is exactly what fails on the containerized runner. +- **CI / Linux / macOS:** `make up` or `make smoke` (seed, then start). +- **No-make / Windows:** `infra/docker-compose.local.yml` — a twin stack that + **bind-mounts** the config instead. Bind mounts are fine *locally* because a + local daemon can see your working directory, so + `docker compose -f infra/docker-compose.local.yml up -d` just works. -**Consequence: bare `docker compose up` no longer self-seeds** — the `external` -volumes must be populated first, so use **`make up`** (or `make -up`), which -seeds then starts. CI uses `make smoke`, which does the same. +**Why not the obvious alternatives** -**Why not the alternatives.** +- *Bake config into an image* (incl. an inline Dockerfile) — `docker compose up` + would then work unaided, but it's a build; we wanted the upstream images as-is. +- *Compose `configs:` with inline `content`* — Compose writes a client-side temp + file and bind-mounts it, hitting the exact same problem. +- *A host-executing runner* — bind mounts would work with zero seeding, but it + reintroduces a self-hosted runner and undoes the move to `ubuntu-latest`. -- *Bake into a (possibly inline) image* — clean and portable, but it's a build; - rejected here because the goal was to use the upstream images verbatim. -- *Compose `configs:` with inline `content`* — Compose materialises these as a - temp file on the **client** side and bind-mounts it → same daemon-can't-see-it - problem. -- *A self-hosted runner that runs jobs on the host* — bind mounts would then work - with zero seeding, but it reintroduces a bespoke runner and undoes the move to - the hosted `ubuntu-latest` label. +--- -No bind mounts of these config files remain, so the SELinux `:z`/`:Z` relabel flag -is no longer needed anywhere in `infra/` (named volumes don't need relabeling). +## 2. Readiness: poll health, don't use `--wait` -## Readiness: a portable health poll, not `docker compose up --wait` +`docker compose up --wait` looks ideal but fails us three ways: -The smoke does **not** use `docker compose up --wait`, for three reasons: +- **podman-compose doesn't implement it** (`unrecognized arguments: --wait`) — so + it would break local dev. +- A project-wide `--wait` **treats a one-shot exiting `0` as a failure** unless + something `depends_on` it with `service_completed_successfully`. `flowable-init` + deploys the BPMN and exits with no dependant, so `--wait` fails the moment it + does — last line `container infra-flowable-init-1 exited (0)`. +- The containerized runner **can't reach published host ports**, so an external + `curl localhost:8080/health` can't work either. -- **podman-compose doesn't implement `--wait`** (`unrecognized arguments: - --wait`), so it would break local dev. -- A whole-project `--wait` **fails when a one-shot with no - `service_completed_successfully` dependant exits** — `flowable-init` deploys - the BPMN and exits 0, which `--wait` treats as the project failing (symptom: - last compose line `container infra-flowable-init-1 exited (0)`). -- The containerized CI runner **can't reach published host ports**, so an - external `curl localhost:8080/health` doesn't work either. +**Fix** — `infra/wait-healthy.sh` polls each durable service (`openzaak nrc-web +acl bff`, listed as `WAIT_SVCS` in the `Makefile`) with `docker ps` + `docker +inspect '{{.State.Health.Status}}'` until it reports `healthy`. It uses only +primitives both runtimes support, reads the **in-container** healthcheck (no host +port needed), and ignores the one-shots (they only need to have run). +`WAIT_TIMEOUT` defaults to 420 s — enough for the cold OpenZaak migrate (~90 s) +plus app start. -**Fix.** `infra/wait-healthy.sh` polls each durable, health-checked service -(`openzaak nrc-web acl bff` — `WAIT_SVCS` in the `Makefile`) with `docker ps` + -`docker inspect '{{.State.Health.Status}}'`, waiting for `healthy`. That uses -only primitives both docker compose and podman-compose support, reads the -in-container healthcheck (no host port needed), and ignores the one-shots (they -only need to have run). `WAIT_TIMEOUT` (default 420 s) covers the cold -OpenZaak migrate (~90 s) plus app start. +--- -## PostGIS readiness vs. `pg_isready` +## 3. `pg_isready` passes before PostGIS is ready -`pg_isready` reports the server is accepting connections as soon as the TCP port -is open — **before** the `postgis/postgis` image has finished running its -`CREATE EXTENSION postgis` init scripts. An init container that starts migrating -in that window can fail on a missing PostGIS. The db healthchecks therefore add a -`SELECT PostGIS_Version()` probe so dependents wait for the extension, not just -the port. +`pg_isready` succeeds as soon as the TCP port is open — *before* the +`postgis/postgis` image has finished running `CREATE EXTENSION postgis`. An init +container that starts migrating in that window can fail on a missing PostGIS. So +the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait +for the extension, not just the port.