feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
@@ -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.
|
||||
Executable
+23
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
Executable
+54
@@ -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}"
|
||||
@@ -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
|
||||
Executable
+12
@@ -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"
|
||||
Executable
+68
@@ -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
@@ -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"
|
||||
Executable
+72
@@ -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"
|
||||
Executable
+37
@@ -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 "$@"
|
||||
Executable
+55
@@ -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}]"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user