# ============================================================================= # Shared infrastructure, owned here and nowhere else. # # Environment overlays (local, dev, prod-smoke) describe how the application runs. This file # describes what it runs against. Keeping the two apart is why `local` could stop meaning "the app # plus a database" and start meaning "the app, with whichever services the lane asked for". # # Every service carries a Compose profile, so nothing here starts unless a lane names it. A profile # selects services; it never implies a Spring profile. The lane definitions live in # src/config/runtime/compose-profile-contracts.json, and scripts/verify-compose-profile-contracts.sh # checks this file against them. # ============================================================================= services: # ---- PostgreSQL -------------------------------------------------------------- db: profiles: - local-jpa - local-messaging-outbox - local-notification-ingest - local-notification-serving - local-notification-handoff - shared-infra - prod-smoke - all-adapters image: postgres:16-alpine environment: POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}" POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}" POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}" TZ: "UTC" volumes: - type: volume source: caskeleton-db-data target: /var/lib/postgresql/data ports: - "127.0.0.1:5433:5432" networks: - caskeleton-infra healthcheck: test: [ "CMD-SHELL", "pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}", ] interval: 10s timeout: 5s retries: 5 start_period: 30s # ---- MongoDB ----------------------------------------------------------------- # A replica set of one. Single-node is still a replica set: transactions and change streams need # one, and a standalone mongod that "works for reads" is a deployment that discovers the # difference at the first transaction. mongo: profiles: - local-mongo - all-adapters image: mongo:7 command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] volumes: - type: volume source: caskeleton-mongo-data target: /data/db networks: - caskeleton-infra healthcheck: test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] interval: 10s timeout: 5s retries: 10 start_period: 20s mongo-rs-init: profiles: - local-mongo - all-adapters image: mongo:7 depends_on: mongo: condition: service_healthy # Idempotent: rs.initiate() on an already-initiated set returns an error this swallows, so the # lane can be re-run against a surviving volume without a manual reset. command: - mongosh - --host - mongo - --quiet - --eval - >- try { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) } catch (e) { if (!/already initialized/i.test(e.message)) { throw e } } networks: - caskeleton-infra restart: "no" # ---- Kafka ------------------------------------------------------------------- kafka: profiles: - local-messaging - local-messaging-outbox - all-adapters image: apache/kafka:3.8.0 environment: KAFKA_NODE_ID: "1" KAFKA_PROCESS_ROLES: "broker,controller" KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093" KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092" KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER" KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1" KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1" networks: - caskeleton-infra healthcheck: test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:9092"] interval: 10s timeout: 10s retries: 12 start_period: 30s # ---- Mailpit — the reference SMTP provider for notification serving ---------- mailpit: profiles: - local-notification-serving - local-notification-handoff - all-adapters image: axllent/mailpit:v1.21 environment: MP_SMTP_AUTH_ACCEPT_ANY: "1" # MP_SMTP_AUTH_ALLOW_INSECURE is deliberately absent, and Mailpit refuses to start with both: # "TLS cannot be required with --smtp-auth-allow-insecure". It existed to permit credentials # over a plaintext connection, which is exactly what requiring STARTTLS removes the need for — # any AUTH now happens inside the TLS session. # STARTTLS, required. Not a hardening extra: SmtpProviderProperties.TlsMode has two members and # neither is plaintext, so the platform cannot describe an unencrypted relay at all. A lane that # wanted a plaintext Mailpit would be asking for a transport the type refuses to express, and # the honest way to satisfy it is to give the relay a certificate. MP_SMTP_TLS_CERT: /run/mailpit-tls/server.crt MP_SMTP_TLS_KEY: /run/mailpit-tls/server.key MP_SMTP_REQUIRE_STARTTLS: "true" volumes: # Generated per run by the qualification wrapper for the host name `mailpit`, and removed on # teardown, exactly like the PostgreSQL lane certificate. A committed test certificate is a # private key in Git. - type: bind source: ./infra/mailpit/tls target: /run/mailpit-tls read_only: true networks: - caskeleton-infra healthcheck: test: ["CMD", "/mailpit", "readyz"] interval: 5s timeout: 3s retries: 12 start_period: 5s # ---- MinIO ------------------------------------------------------------------- minio: profiles: - shared-infra - prod-smoke image: minio/minio:RELEASE.2024-09-13T20-26-02Z command: ["server", "/data"] environment: MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}" MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}" volumes: - type: volume source: caskeleton-minio-data target: /data networks: - caskeleton-infra healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 10 start_period: 10s # Bucket and policy bootstrap. Not a substitute for the round trip: creating a bucket proves the # server accepts an admin command, not that an object survives being written and read back. minio-init: profiles: - shared-infra - prod-smoke image: minio/mc:RELEASE.2024-09-16T17-43-14Z depends_on: minio: condition: service_healthy entrypoint: ["/bin/sh", "/opt/minio/bucket-bootstrap.sh"] environment: MINIO_ENDPOINT: "http://minio:9000" MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}" MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}" MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}" volumes: - type: bind source: ./infra/minio/init target: /opt/minio read_only: true networks: - caskeleton-infra restart: "no" # ---- Keycloak ---------------------------------------------------------------- keycloak: profiles: - local-graphql - local-notification-ingest - local-notification-serving - local-notification-handoff - shared-infra - prod-smoke - all-adapters image: quay.io/keycloak/keycloak:26.0 # The wrapper reads the client secret from a mounted file and execs kc.sh. The realm artifact # carries only a ${...} reference, so no secret value is in Git, in the rendered config, or on a # command line. entrypoint: ["/bin/bash", "/opt/keycloak-entrypoint/entrypoint.sh"] environment: KC_BOOTSTRAP_ADMIN_USERNAME: "${KEYCLOAK_ADMIN:-admin}" KC_BOOTSTRAP_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}" KC_HEALTH_ENABLED: "true" volumes: - type: bind source: ./infra/keycloak/entrypoint.sh target: /opt/keycloak-entrypoint/entrypoint.sh read_only: true - type: bind source: ./infra/keycloak/realms target: /opt/keycloak/data/import read_only: true secrets: - keycloak-graphql-smoke-client-secret networks: - caskeleton-infra healthcheck: test: - "CMD-SHELL" - "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'" interval: 10s timeout: 5s retries: 20 start_period: 30s # ---- Capability schema streams ------------------------------------------------ # Two pre-start one-shots, in this order, because a capability stream is an operator sequence # rather than a property. # # Install: each stream under db/migration/jpa keeps its own Flyway history table — they all declare # a V1, so one Flyway pointed at all of them fails outright — and each registers itself # INSTALLED_INACTIVE. # # Promote: an operator sanctions the installed schema, and the application refuses to start until # that has happened. That is the fail-closed half of the same design, so it cannot be folded into # the install step without making "the tables exist" and "this is sanctioned" the same event. # # They are also two images because they must be: flyway/flyway ships no psql, so the promotion # could not have run in the migration container at all. # # Both run before `up`, not with the smoke clients after it — the application is what they are a # precondition for. The lane contract's preStartServices carries that ordering. db-migrate-capabilities: profiles: - local-notification-ingest - local-notification-serving - local-notification-handoff - all-adapters image: flyway/flyway:11.1.0 depends_on: db: condition: service_healthy entrypoint: ["/bin/sh", "/opt/capability-streams/apply-capability-streams.sh"] environment: PGHOST: "db" PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}" PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}" PGDATABASE: "${POSTGRES_DB:-ca_skeleton}" CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}" volumes: - type: bind source: ./infra/postgres/apply-capability-streams.sh target: /opt/capability-streams/apply-capability-streams.sh read_only: true # The whole migration tree, not just db/migration/jpa: the application's own postgresql stream # has to be installed first, or the capability tables arrive in a schema whose flyway_schema_history # does not exist yet and the application refuses to start — which is its baseline-on-migrate: false # policy working as designed. - type: bind source: ./src/adapter/outbound/persistence-jpa/src/main/resources/db/migration target: /flyway/sql read_only: true networks: - caskeleton-infra restart: "no" db-promote-capabilities: profiles: - local-notification-ingest - local-notification-serving - local-notification-handoff - all-adapters image: postgres:16-alpine depends_on: db: condition: service_healthy entrypoint: ["/bin/sh", "/opt/capability-streams/promote-capability-streams.sh"] environment: PGHOST: "db" PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}" PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}" PGDATABASE: "${POSTGRES_DB:-ca_skeleton}" CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}" volumes: - type: bind source: ./infra/postgres/promote-capability-streams.sh target: /opt/capability-streams/promote-capability-streams.sh read_only: true networks: - caskeleton-infra restart: "no" # ---- One-shot smoke clients -------------------------------------------------- # Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or # non-zero one fails its lane rather than being treated as "not applicable". auth-smoke: profiles: - local-graphql - shared-infra - prod-smoke - all-adapters image: curlimages/curl:8.10.1 depends_on: keycloak: condition: service_healthy # The client secret is written on the host at mode 0600 by the qualification wrapper and mounted # in. The Keycloak image happens to run as the same uid the wrapper writes as; this image runs as # uid 100, so it read "Permission denied" and the lane failed on the smoke client rather than on # anything it was checking. Compose ignores the secret's uid/gid/mode options outside swarm, so # the container reads it as root instead. The two alternatives are both worse: loosening the host # file to world-readable leaves a credential readable by every process on the machine, and passing # the value as an environment variable puts it in `docker compose config` output and in ps. user: "0:0" entrypoint: ["/bin/sh", "/opt/auth-smoke/auth-smoke.sh"] environment: # The same issuer URL the application is given. A token obtained from one URL and validated # against another proves nothing, and localhost means a different host inside each container. KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton" KEYCLOAK_CLIENT_ID: "ca-skeleton-api" APP_BASE_URL: "http://app:8080" # Supplied per runtime, because the same endpoint has two addresses: application-local.yml # pins presentation.api-base-path to /api and the shipped default is /v1. The qualification # wrapper exports the value that matches the lane's Spring runtime. APP_HEALTH_PATH: "${APP_HEALTH_PATH:-/v1/healthcheck}" volumes: - type: bind source: ./infra/keycloak/smoke target: /opt/auth-smoke read_only: true secrets: - keycloak-graphql-smoke-client-secret networks: - caskeleton-infra restart: "no" # The server image, not the mc client image: minio/mc ships no sed, grep or cmp, and the round-trip # client needs a digest tool. See infra/minio/smoke/object-storage-smoke.sh for how that went # unnoticed. The lane already pulls this image for the server itself. object-storage-smoke: profiles: - shared-infra - prod-smoke image: minio/minio:RELEASE.2024-09-13T20-26-02Z depends_on: minio-init: condition: service_completed_successfully entrypoint: ["/bin/sh", "/opt/minio-smoke/object-storage-smoke.sh"] environment: MINIO_ENDPOINT: "http://minio:9000" MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}" MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}" MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}" volumes: - type: bind source: ./infra/minio/smoke target: /opt/minio-smoke read_only: true networks: - caskeleton-infra restart: "no" notification-smoke: profiles: - local-notification-ingest - local-notification-serving - local-notification-handoff - all-adapters image: curlimages/curl:8.10.1 depends_on: keycloak: condition: service_healthy # uid 0 for the same reason auth-smoke uses it: the mounted client secret is mode 0600 on the # host and this image otherwise runs as uid 100, which reads "Permission denied". The lane then # fails on the smoke client rather than on anything it was checking. user: "0:0" entrypoint: ["/bin/sh", "/opt/notification-smoke/notification-smoke.sh"] environment: APP_BASE_URL: "http://app:8080" MAILPIT_BASE_URL: "http://mailpit:8025" # ingest | serving | handoff-verify — which phase of the lane this invocation is. # # No default, deliberately. It defaulted to `ingest`, and local-notification-serving therefore # ran the ingest assertions — "accepted, and nothing was delivered" — against an application in # SERVING mode. The lane passed while testing the opposite of what it is named for, and would # have kept passing for as long as the check happened to run before the dispatch worker. An # unset value now renders empty and the client refuses it. # `:-` and not a value: an explicit empty default keeps Compose from warning about an unset # variable on every lane that never runs this client, while still rendering empty so the # client refuses it. NOTIFICATION_SMOKE_PHASE: "${NOTIFICATION_SMOKE_PHASE:-}" # Submission and template publication are authenticated like every other non-public path, so # this client obtains a token the same way auth-smoke does — client credentials against the # same issuer URL the application validates against. APP_BASE_PATH: "${APP_BASE_PATH:-/api}" KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton" KEYCLOAK_CLIENT_ID: "ca-skeleton-api" volumes: - type: bind source: ./infra/notification/smoke target: /opt/notification-smoke read_only: true # The handoff lane runs this client twice in one project and the second run needs the request # id the first accepted, so the state lives in a named volume that outlives a `run --rm` # container and is removed with the project by the teardown's --volumes. # # Its own path, not a subdirectory of the script mount above: a volume nested inside a # read-only bind cannot be created, because the runtime has to mkdir the mountpoint in a # filesystem it was just told is read-only. - type: volume source: caskeleton-notification-smoke-state target: /opt/notification-smoke-state secrets: - keycloak-graphql-smoke-client-secret networks: - caskeleton-infra restart: "no" networks: caskeleton-infra: driver: bridge volumes: caskeleton-notification-smoke-state: driver: local caskeleton-db-data: driver: local caskeleton-mongo-data: driver: local caskeleton-minio-data: driver: local secrets: # Written per run at mode 0600 by the qualification wrapper and removed on teardown. The realm # artifact references it by name; the value never reaches Git, a rendered config, a command line, # or an evidence file. keycloak-graphql-smoke-client-secret: file: ./infra/keycloak/secrets/graphql-smoke-client-secret