#!/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"