473 lines
18 KiB
YAML
473 lines
18 KiB
YAML
# =============================================================================
|
|
# 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
|