feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
+472
View File
@@ -0,0 +1,472 @@
# =============================================================================
# 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