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
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# The static half of the Compose contract: every lane renders, and renders exactly what it claims.
#
# Five things are checked, and each exists because assuming it cost something:
#
# 1. the Compose version, because `!override` is what stops the dev overlay's tmpfs merging with
# the base's and colliding with a bind mount, and it needs 2.24.4;
# 2. the exact service set per lane — exact, not a superset, because a lane that quietly gains a
# service is a lane whose evidence describes a different stack than the one that ran;
# 3. the rendered SPRING_PROFILES_ACTIVE, because a Compose profile selects services and says
# nothing about which environment the application thinks it is in;
# 4. mount-target uniqueness in the merged model, because that collision is exactly what made the
# dev stack unrenderable and "the syntax looks right" is not the same as "the targets are
# distinct";
# 5. the service-role partition, because a service the lane renders and files under no role is
# started by nothing and checked by nothing, and a one-shot filed as a --wait target hangs the
# lane for its full timeout on a container that was built to exit.
#
# Static only. Nothing starts here; scripts/run-compose-runtime-smoke.sh owns that.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTRACTS="${REPO_ROOT}/src/config/runtime/compose-profile-contracts.json"
FAILURES=0
fail() { echo " FAIL: $*" >&2; FAILURES=$((FAILURES + 1)); }
command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 78; }
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 78; }
[[ -r "${CONTRACTS}" ]] || { echo "missing ${CONTRACTS}" >&2; exit 78; }
# ---- 1. Compose version floor ------------------------------------------------
REQUIRED="$(jq -r '.minimumComposeVersion' "${CONTRACTS}")"
ACTUAL="$(docker compose version --short | sed 's/^v//')"
if [[ "$(printf '%s\n%s\n' "${REQUIRED}" "${ACTUAL}" | sort -V | head -1)" != "${REQUIRED}" ]]; then
echo "docker compose ${ACTUAL} is below the required ${REQUIRED};" >&2
echo " the dev overlay needs !override to replace the base tmpfs rather than merge with it." >&2
exit 1
fi
echo "compose version ${ACTUAL} >= ${REQUIRED}"
# ---- per-lane checks ---------------------------------------------------------
lane_count="$(jq '.lanes | length' "${CONTRACTS}")"
for index in $(seq 0 $((lane_count - 1))); do
lane="$(jq -c ".lanes[${index}]" "${CONTRACTS}")"
id="$(jq -r '.id' <<<"${lane}")"
profile="$(jq -r '.composeProfile // empty' <<<"${lane}")"
runtime="$(jq -r '.springRuntime' <<<"${lane}")"
expected="$(jq -r '.services | sort | join(",")' <<<"${lane}")"
args=()
while read -r key; do
file="$(jq -r --arg k "${key}" '.composeFiles[$k]' "${CONTRACTS}")"
[[ -r "${REPO_ROOT}/${file}" ]] || { fail "${id}: ${file} is missing"; continue 2; }
args+=(-f "${REPO_ROOT}/${file}")
done < <(jq -r '.files[]' <<<"${lane}")
profile_args=()
[[ -n "${profile}" ]] && profile_args=(--profile "${profile}")
# 2. exact service set
# stderr goes to a file rather than into the list. It used to be merged with `2>&1`, so Compose's
# own warning about an unset interpolation variable arrived as an extra "service" and twelve lanes
# failed with a timestamped log line in place of a container name.
render_error="${TMPDIR:-/tmp}/compose-config-$$.err"
if ! actual="$(cd "${REPO_ROOT}" && docker compose "${args[@]}" "${profile_args[@]}" config --services 2>"${render_error}" | sort | paste -sd, -)"; then
fail "${id}: the stack does not render: $(tr '\n' ' ' <"${render_error}")"
rm -f "${render_error}"
continue
fi
rm -f "${render_error}"
if [[ "${actual}" != "${expected}" ]]; then
fail "${id}: services are [${actual}], the contract says [${expected}]"
continue
fi
# 3. the rendered Spring runtime, and 4. mount-target uniqueness
model="$(cd "${REPO_ROOT}" && docker compose "${args[@]}" "${profile_args[@]}" config --format json 2>/dev/null)" || {
fail "${id}: the merged model does not render as JSON"
continue
}
rendered_runtime="$(jq -r '.services.app.environment.SPRING_PROFILES_ACTIVE // empty' <<<"${model}")"
if [[ "${rendered_runtime}" != "${runtime}" ]]; then
fail "${id}: app renders SPRING_PROFILES_ACTIVE='${rendered_runtime}', the contract says '${runtime}'"
fi
duplicates="$(jq -r '
.services | to_entries[]
| .key as $svc
| [ (.value.volumes // [] | .[].target), (.value.tmpfs // [] | .[] | split(":")[0]) ] as $targets
| ($targets | group_by(.) | map(select(length > 1) | .[0])) as $dupes
| select($dupes | length > 0)
| "\($svc): \($dupes | join(", "))"' <<<"${model}")"
if [[ -n "${duplicates}" ]]; then
fail "${id}: a mount target is declared twice — ${duplicates}"
fi
# 5. every rendered service plays exactly one role, and no non-waiting service is a --wait target.
#
# The wrapper runs longRunningServices under `up --wait`, preStartServices with `run --rm` before
# the application, and oneShotServices with `run --rm` after it is healthy. A service the lane
# renders but files under none of the three is started by nothing and checked by nothing, and one
# filed under two is run twice. Neither is visible in a green lane.
#
# The direction that actually bites is a one-shot reaching longRunningServices: `up --wait` on a
# container built to exit waits for a health state it will never report, and the lane hangs until
# the 300s timeout with no indication that the contract, not the stack, is what is wrong.
partition="$(jq -r '
( (.longRunningServices // []) + (.preStartServices // []) + (.oneShotServices // []) ) as $filed
| { missing: ((.services // []) - $filed), extra: ($filed - (.services // [])),
twice: ($filed | group_by(.) | map(select(length > 1) | .[0])) }
| select((.missing | length) + (.extra | length) + (.twice | length) > 0)
| "unfiled=[\(.missing | join(","))] not-rendered=[\(.extra | join(","))] twice=[\(.twice | join(","))]"
' <<<"${lane}")"
[[ -n "${partition}" ]] && fail "${id}: services are not partitioned by role — ${partition}"
never_waiting="$(jq -r --argjson lane "${lane}" '
( (.preStartServices // []) + (.oneShotServices // []) ) as $catalogue
| ( ($lane.longRunningServices // []) - (($lane.longRunningServices // []) - $catalogue) )
| select(length > 0) | join(",")' "${CONTRACTS}")"
[[ -n "${never_waiting}" ]] && fail "${id}: [${never_waiting}] are declared non-waiting but listed as --wait targets"
uncatalogued="$(jq -r --argjson lane "${lane}" '
( (.preStartServices // []) + (.oneShotServices // []) ) as $catalogue
| ( (($lane.preStartServices // []) + ($lane.oneShotServices // [])) - $catalogue )
| select(length > 0) | join(",")' "${CONTRACTS}")"
[[ -n "${uncatalogued}" ]] && fail "${id}: [${uncatalogued}] are run with \`run --rm\` but are in neither top-level catalogue"
[[ ${FAILURES} -eq 0 ]] && echo "${id}: ${expected} @ ${runtime}"
done
# 6. the Keycloak realm artifact deserializes into Keycloak's own representation.
#
# Keycloak rejects unknown fields rather than ignoring them, so one annotation key anywhere in the
# tree fails the whole import, the container exits 1, and every lane that needs an identity provider
# fails on Keycloak instead of on what it was testing. This cost two lane runs to find, once for
# `_comment` on the realm and once for `_flowComment` on a client, because the second was invisible
# until the first was fixed. Rationale for that artifact lives in infra/keycloak/README.md.
realm="${REPO_ROOT}/infra/keycloak/realms/ca-skeleton-realm.json"
if [[ -f "${realm}" ]]; then
if ! jq empty "${realm}" >/dev/null 2>&1; then
fail "infra/keycloak/realms/ca-skeleton-realm.json is not valid JSON"
else
annotations="$(jq -r '[paths(scalars, objects, arrays) | .[-1] | select(type == "string") | select(startswith("_"))] | unique | join(", ")' "${realm}")"
if [[ -n "${annotations}" ]]; then
fail "the Keycloak realm carries key(s) Keycloak refuses to deserialize: ${annotations}"
fi
fi
fi
if [[ ${FAILURES} -gt 0 ]]; then
echo "verify-compose-profile-contracts: ${FAILURES} lane(s) do not match the contract" >&2
exit 1
fi
echo "verify-compose-profile-contracts: all ${lane_count} lanes match src/config/runtime/compose-profile-contracts.json"