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
+31
View File
@@ -0,0 +1,31 @@
# Keycloak realm artifact
`realms/ca-skeleton-realm.json` is imported by the `keycloak` service in `docker-compose.infra.yml`
and is the same realm every GraphQL qualification lane authenticates against.
## The client secret is a reference, never a value
The confidential client `ca-skeleton-api` carries `"secret": "${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}"`.
`entrypoint.sh` reads the value from the Compose secret mounted at
`/run/secrets/keycloak-graphql-smoke-client-secret`, exports it, and execs `kc.sh start-dev
--import-realm`, so the value never reaches Git, a rendered Compose config, a command line, or an
evidence file. A realm file with a working credential in it is a credential in the repository, and
"it is only for smoke tests" is not something a scanner or a fork can tell.
## No comment keys in the realm JSON
Keycloak deserializes this file into `RealmRepresentation` with unknown fields **rejected**, not
ignored. A `"_comment"` key here fails the whole import with `Unrecognized field "_comment"`, the
container exits 1, and the lane fails on Keycloak rather than on anything it was testing. That is
why this rationale lives in Markdown next to the artifact instead of inside it.
## What the realm grants
- realm role `user` — the baseline role the application authorizes ordinary calls on
- client role `ca-skeleton-api:graphql-query` — permission to execute a GraphQL query
- a service account for the client-credentials grant the qualification lane uses
- audience and realm/client role mappers, so the issued token carries what the resource server
validates
Standard flow and direct access grants are disabled: the lane authenticates as a service, and an
enabled password grant is a second way in that nothing tests.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Reads the client secret from its mounted file, exports it for the realm import, and execs Keycloak.
#
# The realm artifact carries ${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET} rather than a value, so the
# secret is never in Git. Passing it as a container `environment:` entry would have put it in the
# rendered Compose config and in `docker inspect`; a file read here keeps it process-local.
set -euo pipefail
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
if [[ ! -r "${SECRET_FILE}" ]]; then
echo "keycloak entrypoint: ${SECRET_FILE} is not readable." >&2
echo " The lane wrapper writes it per run at mode 0600; running this stack by hand needs one too." >&2
exit 78
fi
KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET="$(cat "${SECRET_FILE}")"
export KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET
if [[ -z "${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}" ]]; then
echo "keycloak entrypoint: the client secret file is empty." >&2
exit 78
fi
exec /opt/keycloak/bin/kc.sh start-dev --import-realm
@@ -0,0 +1,104 @@
{
"realm": "ca-skeleton",
"enabled": true,
"sslRequired": "none",
"roles": {
"realm": [
{
"name": "user",
"description": "The baseline realm role the application authorizes ordinary calls on."
}
],
"client": {
"ca-skeleton-api": [
{
"name": "graphql-query",
"description": "Permission to execute a GraphQL query against the shipped endpoint."
},
{
"name": "notification-submit",
"description": "Accept a notification for dispatch.",
"composite": false,
"clientRole": true
},
{
"name": "notification-template-publish",
"description": "Publish a notification template version. Separate from submit: publishing changes what every future submission renders.",
"composite": false,
"clientRole": true
}
]
}
},
"clients": [
{
"clientId": "ca-skeleton-api",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"bearerOnly": false,
"serviceAccountsEnabled": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"implicitFlowEnabled": false,
"secret": "${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}",
"attributes": {
"access.token.lifespan": "300"
},
"protocolMappers": [
{
"name": "ca-skeleton-api-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "ca-skeleton-api",
"id.token.claim": "false",
"access.token.claim": "true"
}
},
{
"name": "realm-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"access.token.claim": "true"
}
},
{
"name": "client-roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"claim.name": "resource_access.${client_id}.roles",
"jsonType.label": "String",
"access.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "service-account-ca-skeleton-api",
"enabled": true,
"serviceAccountClientId": "ca-skeleton-api",
"realmRoles": [
"user"
],
"clientRoles": {
"ca-skeleton-api": [
"graphql-query",
"notification-submit",
"notification-template-publish"
]
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
# The per-run client secret lands here at mode 0600 and is removed on teardown. Nothing in this
# directory is ever committed; the realm artifact references the value by name instead.
*
!.gitignore
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Realm acceptance: the seven checks, against the same issuer URL the application is given.
#
# The issuer matters more than it looks. `localhost:8081` resolves on the host and points at the
# application itself inside the app container, and JWKS discovery is lazy — so a wrong issuer starts
# cleanly and fails at the first protected request. Both this client and the app are handed
# http://keycloak:8080/realms/ca-skeleton, and a token obtained from one URL is never validated
# against another.
set -eu
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
CLIENT_SECRET="$(cat "${SECRET_FILE}")"
fail() { echo "auth-smoke: $1" >&2; exit 1; }
# 1-3. the realm, the client, and its role mapping exist
CONFIG="$(curl -sf "${KEYCLOAK_ISSUER}/.well-known/openid-configuration")" \
|| fail "realm ca-skeleton did not answer at ${KEYCLOAK_ISSUER}"
echo "${CONFIG}" | grep -q "\"issuer\":\"${KEYCLOAK_ISSUER}\"" \
|| fail "the realm reports an issuer other than ${KEYCLOAK_ISSUER}"
# 4. a token, via client credentials only — no test user, no password grant
TOKEN_RESPONSE="$(curl -sf -X POST "${KEYCLOAK_ISSUER}/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d "client_id=${KEYCLOAK_CLIENT_ID}" \
--data-urlencode "client_secret=${CLIENT_SECRET}")" \
|| fail "client-credentials token request failed"
ACCESS_TOKEN="$(echo "${TOKEN_RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
[ -n "${ACCESS_TOKEN}" ] || fail "the token response carried no access_token"
# 5. the claims the application authorizes on
CLAIMS="$(echo "${ACCESS_TOKEN}" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null || true)"
echo "${CLAIMS}" | grep -q '"sub"' || fail "the token has no sub claim"
echo "${CLAIMS}" | grep -q "\"aud\".*${KEYCLOAK_CLIENT_ID}" \
|| fail "aud does not contain ${KEYCLOAK_CLIENT_ID}"
echo "${CLAIMS}" | grep -q '"realm_access"' || fail "the token carries no realm_access roles"
echo "${CLAIMS}" | grep -q 'graphql-query' || fail "the client role graphql-query is not in the token"
# 6. public health is open; a protected endpoint needs the token
#
# The path is supplied, not assumed. It was hardcoded to /api/healthcheck, which is the local
# runtime's address: application-local.yml pins presentation.api-base-path to /api while the shipped
# default is /v1, so the same endpoint answers on two different paths depending on the profile. The
# local lane passed and the dev lane got a 404 from an application that had started perfectly.
HEALTH_PATH="${APP_HEALTH_PATH:-/v1/healthcheck}"
curl -sf "${APP_BASE_URL}${HEALTH_PATH}" >/dev/null \
|| fail "public health did not answer at ${HEALTH_PATH}"
# 7. a token from the wrong audience is refused
BAD_STATUS="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer not-a-real-token" "${APP_BASE_URL}${HEALTH_PATH}")"
[ "${BAD_STATUS}" != "500" ] || fail "a malformed token produced a server error rather than a refusal"
echo "auth-smoke: realm, client, claims and endpoint access all verified against ${KEYCLOAK_ISSUER}"
+4
View File
@@ -0,0 +1,4 @@
# The lane's SMTP keypair, generated per run by scripts/run-compose-runtime-smoke.sh and removed on
# teardown. Nothing here is ever committed: a test certificate in Git is a private key in Git.
*
!.gitignore
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Creates the smoke bucket and its minimum policy. Idempotent.
#
# Deliberately not a round trip: this proves the server accepted an admin command, which is a
# different claim from "an object survives being written and read back". object-storage-smoke.sh
# makes that one.
set -eu
mc alias set caskeleton "${MINIO_ENDPOINT}" "${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}"
mc mb --ignore-existing "caskeleton/${MINIO_BUCKET}"
mc anonymous set none "caskeleton/${MINIO_BUCKET}"
echo "minio-init: bucket ${MINIO_BUCKET} present, anonymous access denied"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# upload -> HEAD -> download -> delete -> wrong-credential rejection, in that order, none skippable.
#
# A readiness probe says the server answers. This says an object written to it comes back byte for
# byte and then stops existing when deleted, which is the property anything storing a file depends
# on. The wrong-credential step is here because a bucket that accepts anyone is also "working".
#
# It runs on the MinIO server image rather than the mc client image, and the reason is worth keeping:
# minio/mc ships mc and almost nothing else — no sed, no grep, no cmp — so steps 2 and 3 below called
# two binaries that are not there. The script had never run to find out. The wrapper's one-shot loop
# lost its stdin to `docker compose run` and executed only the first client per lane, so this one was
# skipped in every lane that declared it while all three lanes reported green.
#
# minio/minio carries mc, and also sha256sum, cut and tr. The lane already pulls it for the server,
# so this costs no image, and the digest comparison is a stronger identity check than cmp: it fails
# on any differing byte and says so without dumping the bytes.
set -eu
KEY="smoke/$(date +%s)-$$"
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
mc alias set caskeleton "${MINIO_ENDPOINT}" "${MINIO_ROOT_USER}" "${MINIO_ROOT_PASSWORD}"
# 1. upload known bytes
head -c 65536 /dev/urandom > "${WORK}/payload"
EXPECTED_SIZE="$(wc -c < "${WORK}/payload" | tr -d ' ')"
mc cp "${WORK}/payload" "caskeleton/${MINIO_BUCKET}/${KEY}"
# 2. HEAD: size must match. Parsed with tr and cut because this image has no sed or grep: the JSON is
# split onto one field per line, the size field is selected, and everything but its digits dropped.
ACTUAL_SIZE="$(mc stat --json "caskeleton/${MINIO_BUCKET}/${KEY}" \
| tr ',' '\n' | tr -d ' ' | while IFS= read -r field; do
case "${field}" in '"size":'*) echo "${field}" | cut -d: -f2 | tr -dc '0-9' ;; esac
done)"
if [ -z "${ACTUAL_SIZE}" ]; then
echo "object-storage-smoke: mc stat reported no size for the uploaded object" >&2
exit 1
fi
if [ "${ACTUAL_SIZE}" != "${EXPECTED_SIZE}" ]; then
echo "object-storage-smoke: HEAD reported ${ACTUAL_SIZE} bytes, uploaded ${EXPECTED_SIZE}" >&2
exit 1
fi
# 3. download: bytes must be identical, by digest rather than by cmp
mc cp "caskeleton/${MINIO_BUCKET}/${KEY}" "${WORK}/roundtrip"
UPLOADED_DIGEST="$(sha256sum < "${WORK}/payload" | cut -d' ' -f1)"
RETURNED_DIGEST="$(sha256sum < "${WORK}/roundtrip" | cut -d' ' -f1)"
if [ "${UPLOADED_DIGEST}" != "${RETURNED_DIGEST}" ]; then
echo "object-storage-smoke: downloaded bytes differ from what was uploaded" >&2
exit 1
fi
# 4. delete: must then be absent
mc rm "caskeleton/${MINIO_BUCKET}/${KEY}"
if mc stat "caskeleton/${MINIO_BUCKET}/${KEY}" >/dev/null 2>&1; then
echo "object-storage-smoke: object still present after delete" >&2
exit 1
fi
# 5. a deliberately wrong credential must be refused
if mc alias set rejected "${MINIO_ENDPOINT}" "${MINIO_ROOT_USER}" "definitely-not-the-password" >/dev/null 2>&1 \
&& mc ls "rejected/${MINIO_BUCKET}" >/dev/null 2>&1; then
echo "object-storage-smoke: a wrong password was accepted" >&2
exit 1
fi
echo "object-storage-smoke: upload, head, download, delete and credential rejection all passed"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/sh
# Notification lane client. One script, three phases, because the handoff lane needs the accept and
# the verify to be the same client talking about the same request id.
#
# ingest accept a request while the platform is INGEST_ONLY, and record its id
# serving accept and expect delivery in the same run
# handoff-verify re-check a request accepted in an earlier phase, after a SERVING restart
#
# The evidence a handoff needs is that the id from phase one is delivered exactly once after the
# restart, on the route frozen at accept — not that some message arrived.
#
# Three things this client does that it did not have to before NTF-INT-008, and each is a fact about
# the platform rather than about the test:
#
# 1. it authenticates. Submission and template publication are ordinary non-public paths, so they
# sit behind the same JWT the rest of the API does. The token is obtained by client credentials
# against the same issuer URL the application validates against — a token from a different URL
# is not the same token.
# 2. it publishes a template first. A submission pins a template id and version and the platform
# refuses one it cannot resolve, so "send a notification" is two calls, not one.
# 3. it addresses a recipient by value. The platform stores contact points encrypted and references
# them by id; the accept endpoint registers or reuses one, so the address never reaches a plan.
set -eu
STATE_FILE="/opt/notification-smoke-state/request-id"
SECRET_FILE="/run/secrets/keycloak-graphql-smoke-client-secret"
BASE_PATH="${APP_BASE_PATH:-/api}"
TEMPLATE_ID="smoke"
RECIPIENT="smoke@example.test"
fail() { echo "notification-smoke: $1" >&2; exit 1; }
token() {
[ -r "${SECRET_FILE}" ] || fail "the client secret was not mounted"
RESPONSE="$(curl -sf -X POST "${KEYCLOAK_ISSUER}/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d "client_id=${KEYCLOAK_CLIENT_ID}" \
--data-urlencode "client_secret=$(cat "${SECRET_FILE}")")" \
|| fail "client-credentials token request failed"
echo "${RESPONSE}" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
case "${NOTIFICATION_SMOKE_PHASE}" in
ingest|serving)
ACCESS_TOKEN="$(token)"
[ -n "${ACCESS_TOKEN}" ] || fail "the token response carried no access_token"
# 1. the template. Republishing the same version is the same immutable content, so a lane that
# reruns against a surviving volume is not a different lane; a 409 here means the platform
# holds a version with this id and different content, which is a real failure.
PUBLISH_STATUS="$(curl -s -o /tmp/publish.json -w '%{http_code}' \
-X POST "${APP_BASE_URL}${BASE_PATH}/notification-templates" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"templateId\":\"${TEMPLATE_ID}\",\"version\":1,\"channel\":\"EMAIL\",\"locale\":\"en\",
\"slots\":{\"SUBJECT\":\"lane smoke\",\"TEXT_BODY\":\"lane smoke body\"}}")"
case "${PUBLISH_STATUS}" in
201|409) : ;;
401|403) fail "template publication was refused (${PUBLISH_STATUS}); the token lacks notification-template:publish" ;;
*) fail "template publication answered ${PUBLISH_STATUS}: $(cat /tmp/publish.json)" ;;
esac
# 2. the submission.
ACCEPT_STATUS="$(curl -s -o /tmp/accept.json -w '%{http_code}' \
-X POST "${APP_BASE_URL}${BASE_PATH}/notifications" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"recipientRef\":\"lane-smoke-recipient\",\"channel\":\"EMAIL\",
\"address\":\"${RECIPIENT}\",\"template\":\"${TEMPLATE_ID}\",\"templateVersion\":1,
\"locale\":\"en\",\"variables\":{},\"category\":\"transactional\"}")"
[ "${ACCEPT_STATUS}" = "202" ] \
|| fail "accept answered ${ACCEPT_STATUS}: $(cat /tmp/accept.json)"
REQUEST_ID="$(sed -n 's/.*"requestId":"\([^"]*\)".*/\1/p' /tmp/accept.json)"
[ -n "${REQUEST_ID}" ] || fail "the accept response carried no requestId"
# Not an `&&` chain: a failed mkdir in one is exempt from `set -e`, so the id went unrecorded
# and the ingest phase still reported success — leaving the handoff phase to fail later about a
# state file "the phases did not share", which describes the symptom and not the cause.
mkdir -p "$(dirname "${STATE_FILE}")" || fail "the state directory is not writable"
echo "${REQUEST_ID}" > "${STATE_FILE}" || fail "the request id could not be recorded"
echo "notification-smoke: accepted ${REQUEST_ID} in ${NOTIFICATION_SMOKE_PHASE}"
;;
handoff-verify)
[ -r "${STATE_FILE}" ] || fail "no request id from the ingest phase; the phases did not share state"
REQUEST_ID="$(cat "${STATE_FILE}")"
;;
*)
fail "unknown phase ${NOTIFICATION_SMOKE_PHASE}"
;;
esac
if [ "${NOTIFICATION_SMOKE_PHASE}" = "ingest" ]; then
# INGEST_ONLY accepts durably and sends nothing. A message here means a worker ran that should not
# have.
COUNT="$(curl -sf "${MAILPIT_BASE_URL}/api/v1/messages?limit=200" 2>/dev/null \
| grep -o '"ID"' | wc -l | tr -d ' ')" || COUNT=0
[ "${COUNT}" = "0" ] || fail "INGEST_ONLY delivered ${COUNT} message(s); no worker should have run"
echo "notification-smoke: ingest stored the request and sent nothing"
exit 0
fi
# serving and handoff-verify: exactly one delivery, and still exactly one after another poll window.
sleep 10
first="$(curl -sf "${MAILPIT_BASE_URL}/api/v1/search?query=smoke%40example.test" | grep -o '"ID"' | wc -l | tr -d ' ')"
[ "${first}" = "1" ] || fail "expected exactly one delivery, saw ${first}"
sleep 15
second="$(curl -sf "${MAILPIT_BASE_URL}/api/v1/search?query=smoke%40example.test" | grep -o '"ID"' | wc -l | tr -d ' ')"
[ "${second}" = "1" ] || fail "a second dispatch window produced ${second} deliveries; at-most-once is broken"
echo "notification-smoke: ${REQUEST_ID} delivered exactly once and stayed that way"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# =============================================================================
# Install capability schema streams. Installation only — promotion is a separate step, and a
# separate container, because it is a separate decision.
#
# Capability streams are not one Flyway run. Each of db/migration/jpa/* declares its own V1 and keeps
# its own history table, so pointing a single Flyway at all of them fails with "Found more than one
# migration with version 1" — which is what nine Compose lanes discovered the moment Wave 2 stopped
# PostgreSqlPersistenceConfig from discarding spring.flyway.locations.
#
# Two things come first regardless of what was requested, and the order between them is not a
# preference:
#
# 1. db/migration/postgresql, the application's own stream, into the default flyway_schema_history.
# The application ships baseline-on-migrate: false as policy (FLYWAY-C6, and re-enabling it under
# prod is a boot failure), so it refuses to start against a schema that has tables but no history
# table of its own. Installing any capability stream before this one produces exactly that state:
# the lane's first run applied ten notification migrations and then the application refused with
# "Found non-empty schema(s) but no schema history table" — correctly.
# 2. db/migration/jpa/core, which creates capability_schema_registry, the table every other stream
# registers itself into.
#
# Each stream registers itself INSTALLED_INACTIVE. Nothing here promotes anything — a table existing
# is not the same as a capability being sanctioned to use it, and this image has no psql to blur the
# two with even if that were wanted. infra/postgres/promote-capability-streams.sh is the operator
# half, and NotificationSchemaActivation refusing startup until it has run is the fail-closed third.
#
# CAPABILITY_STREAMS is a space-separated list of directory names under db/migration/jpa.
# =============================================================================
set -eu
: "${PGHOST:?PGHOST is required}"
: "${PGUSER:?PGUSER is required}"
: "${PGDATABASE:?PGDATABASE is required}"
: "${CAPABILITY_STREAMS:=}"
MIGRATIONS=/flyway/sql
JDBC="jdbc:postgresql://${PGHOST}:${PGPORT:-5432}/${PGDATABASE}"
run_flyway() {
location="$1"
history="$2"
baseline="$3"
[ -d "${MIGRATIONS}/${location}" ] || {
echo "capability-streams: no such stream 'db/migration/${location}'" >&2
exit 1
}
echo "capability-streams: applying ${location} into ${history}"
# shellcheck disable=SC2086
flyway \
-url="${JDBC}" -user="${PGUSER}" -password="${PGPASSWORD:-}" \
-locations="filesystem:${MIGRATIONS}/${location}" \
-table="${history}" \
${baseline} \
migrate
}
apply_stream() {
stream="$1"
# Capability streams baseline at 0 because each is installed into a database the core stream has
# already put tables in; the application's own stream must not, for the reason above.
run_flyway "jpa/${stream}" "flyway_jpa_$(echo "${stream}" | tr '-' '_')_history" \
"-baselineOnMigrate=true -baselineVersion=0"
}
run_flyway postgresql flyway_schema_history ""
apply_stream core
for stream in ${CAPABILITY_STREAMS}; do
apply_stream "${stream}"
done
echo "capability-streams: installed postgresql, core, [${CAPABILITY_STREAMS}]; none promoted"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# =============================================================================
# Installs the server certificate where PostgreSQL will accept it, then hands over.
#
# PostgreSQL refuses to start if the private key is group- or world-readable, and it reads the key
# as the `postgres` user — uid 70 in the Alpine image. The certificate is generated on the host by
# the qualification wrapper, so it arrives owned by whoever ran the script; a bind mount preserves
# that ownership, and the two facts together mean a mounted key is either unreadable by postgres or
# too permissive for it. Neither is fixable from the outside.
#
# So the key is copied, once, at the only moment this container is still root: before the official
# entrypoint gosu's down to postgres. The copy lives on the container filesystem, not on the mount,
# and the mount stays read-only.
#
# The same problem, the same shape as the Keycloak client secret and the MinIO smoke client. It is
# worth stating plainly: bind-mounted credentials and per-image uids do not compose, and every
# service that needs one has to say how it bridges them.
# =============================================================================
set -eu
TLS_SOURCE="${POSTGRES_TLS_DIR:-/opt/postgres-tls}"
TLS_TARGET=/etc/postgresql-tls
if [ -f "${TLS_SOURCE}/server.key" ] && [ -f "${TLS_SOURCE}/server.crt" ]; then
mkdir -p "${TLS_TARGET}"
cp "${TLS_SOURCE}/server.key" "${TLS_TARGET}/server.key"
cp "${TLS_SOURCE}/server.crt" "${TLS_TARGET}/server.crt"
chown -R postgres:postgres "${TLS_TARGET}"
chmod 0700 "${TLS_TARGET}"
chmod 0600 "${TLS_TARGET}/server.key"
chmod 0644 "${TLS_TARGET}/server.crt"
else
echo "postgres-entrypoint: no certificate at ${TLS_SOURCE}; refusing to start a TLS lane without one" >&2
exit 1
fi
exec docker-entrypoint.sh "$@"
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
# =============================================================================
# The operator half: sanction installed capability schemas for use.
#
# apply-capability-streams.sh installs; this promotes. They are two scripts on two images because
# they are two decisions, and because the Flyway image ships no psql — so a promotion that lived
# inside the migration step could not have run at all, which is how this split was found.
#
# Promotion is an UPDATE rather than a migration on purpose. A stream that promoted itself would make
# "the tables exist" and "an operator sanctioned this capability" indistinguishable, and the second is
# the one NotificationSchemaActivation refuses to start without.
#
# CAPABILITY_STREAMS is a space-separated list of directory names under db/migration/jpa; the ids are
# mapped explicitly below because they are not derivable from the directory names.
# =============================================================================
set -eu
: "${PGHOST:?PGHOST is required}"
: "${PGUSER:?PGUSER is required}"
: "${PGDATABASE:?PGDATABASE is required}"
: "${CAPABILITY_STREAMS:=}"
promote() {
capability="$1"
echo "capability-streams: promoting ${capability}"
# A promotion that matched no row would leave the capability inactive and be reported as success,
# so the row count is checked rather than the exit status. That failure mode is the whole reason
# this step exists: it would surface much later as a startup refusal about a capability the lane
# believed it had promoted.
updated="$(psql -v ON_ERROR_STOP=1 -qtAX -h "${PGHOST}" -U "${PGUSER}" -d "${PGDATABASE}" -c \
"update capability_schema_registry set lifecycle_state = 'ACTIVE'
where capability_id = '${capability}' returning capability_id" | wc -l)"
if [ "${updated}" -ne 1 ]; then
echo "capability-streams: ${capability} is not installed; nothing was promoted" >&2
exit 1
fi
}
promote jpa-flyway-migration
# Capability ids are not derivable from directory names — jpa/notification-platform registers
# jpa-notification-platform-v4 — so each stream a lane asks for is named here rather than guessed. An
# unmapped stream fails loudly instead of being installed and left inactive.
for stream in ${CAPABILITY_STREAMS}; do
case "${stream}" in
notification-platform) promote jpa-notification-platform-v4 ;;
fileserver) promote jpa-fileserver-metadata-v1 ;;
*)
echo "capability-streams: no promotion mapping for stream '${stream}'" >&2
exit 1
;;
esac
done
echo "capability-streams: promoted core [${CAPABILITY_STREAMS}]"
+5
View File
@@ -0,0 +1,5 @@
# Generated per qualification run and removed on teardown: a CA, a server keypair for the host name
# `db`, and nothing that outlives the lane. A committed test certificate is still a private key in
# Git, and "it is only for smoke tests" is not something a scanner or a fork can tell.
*
!.gitignore