Files
CosmicClash/scripts/verify_allocated_compose.sh
T
Josh Creek fc2f5c8669 test(compose): report the actual status when the idempotency conflict check fails
The ERR trap added in 432e5a11 located the CI failure at the
`[[ "$conflict_status" == 409 ]]` assertion, but the request discarded
its body and the assertion printed nothing, so three failing runs never
revealed what the API actually returned.

Print the status and body on mismatch.
2026-09-05 19:27:35 +01:00

271 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Independent allocated-flow fixture for multiplayer-next.md §8.48. This
# intentionally does not call compose.phase6-smoke.yml or reuse its ports.
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
compose_file="$root_dir/compose.allocated-smoke.yml"
project="${COMPOSE_PROJECT_NAME:-cosmic-clash-allocated-smoke}"
api_url="http://127.0.0.1:18080"
secret="compose-workload-secret"
smoke_dir="${COMPOSE_SMOKE_DIR:-/tmp/cosmic-clash-allocated-smoke}"
compose=(docker compose -p "$project" -f "$compose_file")
# Most of this script is `curl -fsS` and bare [[ ]] assertions under `set -e`,
# which abort with no message at all. That is fine locally, where the fixture
# is still up to poke at, but in CI it produces a failed run whose log contains
# nothing but "make: *** Error 1" -- undiagnosable without re-running by hand.
# Report where it stopped, and dump the service logs, so a CI failure explains
# itself on the first occurrence.
failed_line=""
on_error() {
failed_line="$1"
echo "allocated Compose fixture failed at ${BASH_SOURCE[0]}:${failed_line}" >&2
echo "--- failing command: ${BASH_COMMAND}" >&2
}
trap 'on_error "$LINENO"' ERR
cleanup() {
local rc=$?
if [[ "$rc" != 0 ]]; then
echo "--- allocated Compose service logs follow (exit ${rc}) ---" >&2
"${compose[@]}" ps >&2 2>/dev/null || true
"${compose[@]}" logs --no-color --tail=80 >&2 2>/dev/null || true
fi
if [[ "$rc" != 0 && "${COMPOSE_KEEP_ON_FAILURE:-}" == 1 ]]; then
echo "allocated Compose fixture retained for inspection: ${project}" >&2
exit "$rc"
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "$rc"
}
trap cleanup EXIT
command -v docker >/dev/null 2>&1 || { echo "Docker is required for 8.48" >&2; exit 2; }
docker info >/dev/null 2>&1 || { echo "A running Docker daemon is required for 8.48" >&2; exit 2; }
mkdir -p "$smoke_dir"
python3 - "$smoke_dir" <<'PY'
import base64, hashlib, hmac, json, pathlib, sys, time
directory = pathlib.Path(sys.argv[1])
key = b"compose-join-signing-key"
key_id = "compose-key-1"
expires = "2099-12-31T00:00:00Z"
# Field order and the trailing key ID must match
# server/domain.JoinAuthorisationBytes and Game/scripts/match_net.gd.
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires, key_id]
canonical = b"\0".join(field.encode() for field in fields)
signature = base64.urlsafe_b64encode(hmac.new(key, canonical, hashlib.sha256).digest()).rstrip(b"=").decode()
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires, "KeyID": key_id}, "Signature": signature}
# The key file maps key ID -> base64 key so a rotation can publish several.
(directory / "join-signing-keys.json").write_text(json.dumps({key_id: base64.b64encode(key).decode()}) + "\n")
(directory / "join-roster.json").write_text(json.dumps([base64.urlsafe_b64encode(json.dumps(envelope, separators=(",", ":")).encode()).rstrip(b"=").decode()]) + "\n")
PY
command -v openssl >/dev/null 2>&1 || { echo "OpenSSL is required for the HTTPS fake Kubernetes API" >&2; exit 2; }
printf 'compose-kubernetes-token' > "$smoke_dir/kubernetes-token"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=agones-provider' -addext 'subjectAltName=DNS:agones-provider' \
-keyout "$smoke_dir/fake-agones.key" -out "$smoke_dir/fake-agones.crt" >/dev/null 2>&1
token="$(python3 - "$secret" <<'PY'
import base64, datetime, hashlib, hmac, json, sys
secret = sys.argv[1].encode()
payload = {"a": "compose-allocation-0001", "e": (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)).isoformat().replace("+00:00", "Z")}
encoded = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).rstrip(b"=")
signature = hmac.new(secret, encoded, hashlib.sha256).digest()
sig = base64.urlsafe_b64encode(signature).rstrip(b"=")
print(encoded.decode() + "." + sig.decode())
PY
)"
export COSMIC_CLASH_COMPOSE_WORKLOAD_TOKEN="$token"
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
"${compose[@]}" up -d --build
for attempt in $(seq 1 180); do
if "${compose[@]}" logs game-server 2>/dev/null | grep -q ' server_started '; then
break
fi
if ! "${compose[@]}" ps --status running --services | grep -qx game-server; then
"${compose[@]}" logs game-server >&2
echo "allocated Compose game server exited before becoming ready" >&2
exit 1
fi
if [[ "$attempt" == 180 ]]; then
"${compose[@]}" logs >&2
echo "allocated Compose game server did not become ready" >&2
exit 1
fi
sleep 1
done
for attempt in $(seq 1 60); do
if curl -fsS "$api_url/healthz" >/dev/null 2>&1; then
break
fi
if [[ "$attempt" == 60 ]]; then
"${compose[@]}" logs >&2
echo "allocated Compose control plane did not become ready" >&2
exit 1
fi
sleep 1
done
# The deployed maintenance service owns the ranked reconnect deadline. Seed an
# already-expired durable lease and require the real Compose process to record
# its cooldown before exercising the rest of the allocation flow.
"${compose[@]}" exec -T database psql -v ON_ERROR_STOP=1 -U cosmic_clash_test -d cosmic_clash_test <<'SQL'
INSERT INTO identities (player_id, steam_id) VALUES ('compose-abandon-player', 'compose-abandon-steam');
INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at)
VALUES ('compose-abandon-ticket', 'compose-abandon-player', 'ranked', 'LIVE', 'build-1', 1, now() - interval '2 minutes', now() + interval '1 hour');
INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, arena_path)
VALUES ('compose-abandon-match', 'ranked', 'LIVE', 'EU', 1, 'compose-abandon-server', 'res://scenes/arena_01.tscn');
INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team, connection_generation, connected_at, disconnected_at)
VALUES ('compose-abandon-match', 'compose-abandon-player', 'compose-abandon-ticket', 0, 0, 1, now() - interval '2 minutes', now() - interval '61 seconds');
SQL
for attempt in $(seq 1 30); do
abandoned="$(${compose[@]} exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM match_participants WHERE match_id = 'compose-abandon-match' AND abandoned_at IS NOT NULL" | tr -d '\r')"
[[ "$abandoned" == 1 ]] && break
[[ "$attempt" == 30 ]] && { "${compose[@]}" logs maintenance >&2; echo "maintenance did not record an expired ranked reconnect lease" >&2; exit 1; }
sleep 1
done
"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM penalties WHERE match_id = 'compose-abandon-match' AND kind = 'MATCH_ABANDONED'" | grep -qx 1
session_json="$(curl -fsS -X POST "$api_url/v1/session/steam" \
-H 'Content-Type: application/json' -d '{"web_api_ticket":"compose-queue-ticket"}')"
access_token="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' <<<"$session_json")"
queue_body='{"ticket_id":"compose-queue-ticket","playlist":"casual","client_build":"build-1","protocol_version":1}'
queue_json="$(curl -fsS -X POST "$api_url/v1/queue" \
-H "Authorization: Bearer $access_token" \
-H 'Idempotency-Key: compose-queue-key-123456' \
-H 'Content-Type: application/json' -d "$queue_body")"
queue_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])' <<<"$queue_json")"
[[ "$queue_revision" == 0 ]]
# Reusing a queue idempotency key with different command material must not
# silently turn into a second ticket or a successful replay.
conflict_body="$(mktemp)"
conflict_status="$(curl -sS -o "$conflict_body" -w '%{http_code}' -X POST "$api_url/v1/queue" \
-H "Authorization: Bearer $access_token" \
-H 'Idempotency-Key: compose-queue-key-123456' \
-H 'Content-Type: application/json' \
-d '{"ticket_id":"compose-other-ticket","playlist":"casual","client_build":"build-1","protocol_version":1}')"
if [[ "$conflict_status" != 409 ]]; then
# Report what actually came back. A bare [[ ]] here just aborts, which is
# how this assertion failed in CI three times without ever saying what the
# status was.
echo "idempotency conflict returned ${conflict_status}, want 409; body:" >&2
cat "$conflict_body" >&2 || true
echo >&2
rm -f "$conflict_body"
exit 1
fi
rm -f "$conflict_body"
heartbeat_json="$(curl -fsS -X POST "$api_url/v1/queue/compose-queue-ticket/heartbeat" \
-H "Authorization: Bearer $access_token" \
-H 'Idempotency-Key: compose-heartbeat-key-123456' \
-H 'If-Match-Revision: 0')"
heartbeat_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])' <<<"$heartbeat_json")"
[[ "$heartbeat_revision" == 1 ]]
curl -fsS -o /dev/null -X POST "$api_url/v1/queue/compose-queue-ticket/cancel" \
-H "Authorization: Bearer $access_token" \
-H 'Idempotency-Key: compose-cancel-key-123456' \
-H "If-Match-Revision: $heartbeat_revision"
# Drive six independent authenticated players through the real queue boundary;
# the matcher service consumes the durable rows below and creates the proposal.
match_tokens=()
for player in 1 2 3 4 5 6; do
player_session="$(curl -fsS -X POST "$api_url/v1/session/steam" \
-H 'Content-Type: application/json' -d "{\"web_api_ticket\":\"compose-match-player-${player}\"}")"
match_tokens+=("$(python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' <<<"$player_session")")
curl -fsS -o /dev/null -X POST "$api_url/v1/queue" \
-H "Authorization: Bearer ${match_tokens[$((player - 1))]}" \
-H "Idempotency-Key: compose-match-queue-key-${player}-123456" \
-H 'Content-Type: application/json' \
-d "{\"ticket_id\":\"compose-match-ticket-${player}\",\"playlist\":\"casual\",\"client_build\":\"build-1\",\"protocol_version\":1}"
done
"${compose[@]}" exec -T database psql -v ON_ERROR_STOP=1 -U cosmic_clash_test -d cosmic_clash_test -c \
"UPDATE queue_tickets SET predicted_rtt = '{\"EU\":30}'::jsonb WHERE ticket_id LIKE 'compose-match-ticket-%'" >/dev/null
proposal_id=""
for attempt in $(seq 1 30); do
proposal_id="$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT proposal_id FROM proposals WHERE state = 'OPEN' ORDER BY created_at DESC LIMIT 1" | tr -d '\r')"
if [[ -n "$proposal_id" ]]; then break; fi
[[ "$attempt" == 30 ]] && { echo "matcher did not create a proposal" >&2; exit 1; }
sleep 1
done
proposal_json="$(curl -fsS -H "Authorization: Bearer ${match_tokens[0]}" "$api_url/v1/proposals/$proposal_id")"
python3 - "$proposal_json" <<'PY'
import json, sys
proposal = json.loads(sys.argv[1])
assert proposal["state"] == "OPEN"
assert len(proposal["participants"]) == 6
print("proposal formation check passed")
PY
proposal_revision=0
for player in 1 2 3 4 5 6; do
proposal_json="$(curl -fsS -X POST "$api_url/v1/proposals/$proposal_id/accept" \
-H "Authorization: Bearer ${match_tokens[$((player - 1))]}" \
-H "Idempotency-Key: compose-proposal-accept-${player}-123456" \
-H "If-Match-Revision: $proposal_revision")"
proposal_revision="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])' <<<"$proposal_json")"
done
[[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT state FROM proposals WHERE proposal_id = '$proposal_id'" | tr -d '\r')" == ACCEPTED ]]
[[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM matches WHERE state = 'ALLOCATING'" | tr -d '\r')" == 1 ]]
for attempt in $(seq 1 30); do
allocation_count="$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM allocations WHERE match_id LIKE 'match-%'" | tr -d '\r')"
if [[ "$allocation_count" == 1 ]]; then break; fi
[[ "$attempt" == 30 ]] && { echo "allocator did not bind a provider allocation" >&2; exit 1; }
sleep 1
done
[[ "$("${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT server_id FROM matches WHERE state = 'ALLOCATING'" | tr -d '\r')" == allocator-ready-1 ]]
# Model the durable state produced by the allocator, then use the real HTTP
# workload authentication and mutation boundaries for every action below.
"${compose[@]}" exec -T database psql -v ON_ERROR_STOP=1 -U cosmic_clash_test -d cosmic_clash_test <<'SQL'
INSERT INTO game_servers (server_id, region, build, protocol_version, transport, state)
VALUES ('compose-server-0001', 'EU', 'build-1', 1, 'enet', 'ALLOCATED');
INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, revision)
VALUES ('compose-match-0001', 'casual', 'RESULT_PENDING', 'EU', 1, 'compose-server-0001', 2);
INSERT INTO allocations (allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at)
VALUES ('compose-allocation-0001', 'compose-match-0001', 'compose-server-0001', 'EU', 'build-1', 1, 'enet', decode(repeat('00', 32), 'hex'), 'ALLOCATED', now());
SQL
result_body='{"match_id":"compose-match-0001","result_nonce":"compose-result-nonce-1234","score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}'
curl -fsS -o /dev/null -w '%{http_code}' \
-X POST "$api_url/v1/servers/compose-server-0001/result" \
-H "Authorization: Bearer $token" \
-H 'Idempotency-Key: compose-result-key-123456' \
-H 'Content-Type: application/json' -d "$result_body" | grep -qx 202
# An identical retry must be acknowledged without a second receipt.
curl -fsS -o /dev/null -w '%{http_code}' \
-X POST "$api_url/v1/servers/compose-server-0001/result" \
-H "Authorization: Bearer $token" \
-H 'Idempotency-Key: compose-result-key-123456' \
-H 'Content-Type: application/json' -d "$result_body" | grep -qx 202
"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT state FROM matches WHERE match_id = 'compose-match-0001'" | grep -qx COMPLETED
"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM result_receipts WHERE match_id = 'compose-match-0001'" | grep -qx 1
curl -fsS -o /dev/null -w '%{http_code}' \
-X POST "$api_url/v1/servers/compose-server-0001/shutdown" \
-H "Authorization: Bearer $token" \
-H 'Idempotency-Key: compose-shutdown-key-123456' \
-H 'Content-Type: application/json' -d '{"reason":"server_draining"}' | grep -qx 204
"${compose[@]}" exec -T database psql -At -U cosmic_clash_test -d cosmic_clash_test -c "SELECT count(*) FROM audit_events WHERE action = 'SERVER_SHUTDOWN' AND aggregate_id = 'compose-match-0001'" | grep -qx 1
"${compose[@]}" stop -t 12 game-server >/dev/null
if "${compose[@]}" ps --status running --services | grep -qx game-server; then
echo "allocated game-server did not stop after supervisor drain" >&2
exit 1
fi
"${compose[@]}" stop -t 10 control-plane >/dev/null
echo "8.48 PASS: allocated Compose HTTP result/retry/shutdown and supervisor drain completed"