feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
Executable
+156
@@ -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"
|
||||
Reference in New Issue
Block a user