157 lines
8.3 KiB
Bash
Executable File
157 lines
8.3 KiB
Bash
Executable File
#!/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"
|