244 lines
9.4 KiB
Bash
Executable File
244 lines
9.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
|
ROLE="${RENDEZVOUS_CANARY_ROLE:-}"
|
|
TOPOLOGY="${RENDEZVOUS_CANARY_TOPOLOGY:-}"
|
|
ADDRESS_FAMILY="${RENDEZVOUS_CANARY_ADDRESS_FAMILY:-ipv4}"
|
|
SERVICE_URL="${RENDEZVOUS_CANARY_HTTP_URL:-}"
|
|
MEDIATOR="${RENDEZVOUS_CANARY_UDP_ENDPOINT:-}"
|
|
GAME_ID="${RENDEZVOUS_CANARY_GAME_ID:-space-game}"
|
|
ENVIRONMENT_ID="${RENDEZVOUS_CANARY_ENVIRONMENT_ID:-production-canary}"
|
|
REGION="${RENDEZVOUS_CANARY_REGION:-production-canary}"
|
|
PROTOCOL_VERSION="${RENDEZVOUS_CANARY_PROTOCOL_VERSION:-1}"
|
|
TIMEOUT_SECONDS="${RENDEZVOUS_CANARY_TIMEOUT_SECONDS:-60}"
|
|
RUN_SECONDS="${RENDEZVOUS_CANARY_RUN_SECONDS:-900}"
|
|
OUTPUT="${RENDEZVOUS_CANARY_OUTPUT:-$ROOT/artifacts/canary/${ROLE:-unknown}-${TOPOLOGY:-unknown}.json}"
|
|
COORDINATION_FILE="${RENDEZVOUS_CANARY_COORDINATION_FILE:-}"
|
|
LISTING_ID="${RENDEZVOUS_CANARY_LISTING_ID:-}"
|
|
REQUIRE_CLEAN="${RENDEZVOUS_CANARY_REQUIRE_CLEAN:-true}"
|
|
KEEP_RAW="${RENDEZVOUS_CANARY_KEEP_RAW:-false}"
|
|
UUID_PATTERN='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
|
|
|
usage() {
|
|
printf '%s\n' \
|
|
'Set RENDEZVOUS_CANARY_ROLE to host, client-success, or client-expected-failure.' \
|
|
'Also set RENDEZVOUS_CANARY_TOPOLOGY, RENDEZVOUS_CANARY_HTTP_URL, and' \
|
|
'RENDEZVOUS_CANARY_UDP_ENDPOINT. See docs/operations/production-readiness.md.' >&2
|
|
exit 2
|
|
}
|
|
|
|
for command in date dotnet git jq mktemp tail; do
|
|
command -v "$command" >/dev/null || {
|
|
printf 'Missing required command: %s\n' "$command" >&2
|
|
exit 2
|
|
}
|
|
done
|
|
|
|
case "$ROLE" in
|
|
host|client-success|client-expected-failure) ;;
|
|
*) usage ;;
|
|
esac
|
|
case "$TOPOLOGY" in
|
|
same-lan|home-nat|firewall-blocked-udp|restrictive-cgnat|ipv6-direct) ;;
|
|
*) usage ;;
|
|
esac
|
|
case "$ADDRESS_FAMILY" in
|
|
ipv4|ipv6) ;;
|
|
*) printf 'RENDEZVOUS_CANARY_ADDRESS_FAMILY must be ipv4 or ipv6.\n' >&2; exit 2 ;;
|
|
esac
|
|
if [[ "$TOPOLOGY" == ipv6-direct && "$ADDRESS_FAMILY" != ipv6 ]]; then
|
|
printf 'The ipv6-direct topology requires RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv6.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ "$TOPOLOGY" =~ ^(firewall-blocked-udp|restrictive-cgnat)$ \
|
|
&& "$ROLE" == client-success ]]; then
|
|
printf 'Failure topologies must use the client-expected-failure role.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ -z "$SERVICE_URL" || -z "$MEDIATOR" ]]; then
|
|
usage
|
|
fi
|
|
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] \
|
|
|| (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
|
printf 'RENDEZVOUS_CANARY_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$RUN_SECONDS" =~ ^[0-9]+$ ]] \
|
|
|| (( RUN_SECONDS < 60 || RUN_SECONDS > 3600 )); then
|
|
printf 'RENDEZVOUS_CANARY_RUN_SECONDS must be an integer from 60 through 3600.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
|
printf 'RENDEZVOUS_CANARY_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ "$REQUIRE_CLEAN" != true && "$REQUIRE_CLEAN" != false ]]; then
|
|
printf 'RENDEZVOUS_CANARY_REQUIRE_CLEAN must be true or false.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ "$KEEP_RAW" != true && "$KEEP_RAW" != false ]]; then
|
|
printf 'RENDEZVOUS_CANARY_KEEP_RAW must be true or false.\n' >&2
|
|
exit 2
|
|
fi
|
|
|
|
cd "$ROOT"
|
|
commit="$(git rev-parse HEAD)"
|
|
tree_state=clean
|
|
if [[ -n "$(git status --porcelain)" ]]; then
|
|
tree_state=dirty
|
|
fi
|
|
if [[ "$REQUIRE_CLEAN" == true && "$tree_state" != clean ]]; then
|
|
printf 'Formal canary evidence requires a clean source tree.\n' >&2
|
|
exit 2
|
|
fi
|
|
|
|
if [[ "$ROLE" == host ]]; then
|
|
if [[ -z "$COORDINATION_FILE" ]]; then
|
|
printf 'The host role requires RENDEZVOUS_CANARY_COORDINATION_FILE.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ -e "$COORDINATION_FILE" ]]; then
|
|
printf 'The host coordination file already exists; remove it explicitly before a new canary.\n' >&2
|
|
exit 2
|
|
fi
|
|
if [[ -z "${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" ]]; then
|
|
printf 'The host role requires RENDEZVOUS_PUBLISHER_CREDENTIAL.\n' >&2
|
|
exit 2
|
|
fi
|
|
else
|
|
if [[ ! "$LISTING_ID" =~ $UUID_PATTERN ]]; then
|
|
printf 'A client role requires a UUID in RENDEZVOUS_CANARY_LISTING_ID.\n' >&2
|
|
exit 2
|
|
fi
|
|
fi
|
|
|
|
umask 077
|
|
raw_dir="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-canary.XXXXXXXX")"
|
|
raw_log="$raw_dir/events.jsonl"
|
|
run_succeeded=false
|
|
host_pid=''
|
|
cleanup() {
|
|
local status="$?"
|
|
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
|
kill -TERM "$host_pid" 2>/dev/null || true
|
|
wait "$host_pid" 2>/dev/null || true
|
|
fi
|
|
if [[ "$run_succeeded" == true && "$KEEP_RAW" == false ]]; then
|
|
rm -rf "$raw_dir"
|
|
else
|
|
printf 'Private raw canary events retained at %s\n' "$raw_dir" >&2
|
|
fi
|
|
return "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
|
|
common_arguments=(
|
|
--service "$SERVICE_URL"
|
|
--mediator "$MEDIATOR"
|
|
--game "$GAME_ID"
|
|
--environment "$ENVIRONMENT_ID"
|
|
--region "$REGION"
|
|
--protocol "$PROTOCOL_VERSION"
|
|
--script
|
|
--json
|
|
--timeout-seconds "$TIMEOUT_SECONDS"
|
|
)
|
|
|
|
exit_code=0
|
|
if [[ "$ROLE" == host ]]; then
|
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
|
host "${common_arguments[@]}" --exit-after-echo --run-seconds "$RUN_SECONDS" \
|
|
>"$raw_log" 2>&1 &
|
|
host_pid="$!"
|
|
ready=false
|
|
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
|
if jq -e 'select(.event == "host.ready" and .status == "ready")' "$raw_log" \
|
|
>/dev/null 2>&1; then
|
|
ready=true
|
|
break
|
|
fi
|
|
if ! kill -0 "$host_pid" 2>/dev/null; then
|
|
break
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
if [[ "$ready" != true ]]; then
|
|
printf 'The canary host did not become ready within the bounded startup window.\n' >&2
|
|
kill -TERM "$host_pid" 2>/dev/null || true
|
|
wait "$host_pid" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
observed_listing="$(jq -r 'select(.event == "host.registered") | .listingId' "$raw_log" | tail -n 1)"
|
|
if [[ ! "$observed_listing" =~ $UUID_PATTERN ]]; then
|
|
printf 'The canary host did not produce a valid coordination identifier.\n' >&2
|
|
kill -TERM "$host_pid" 2>/dev/null || true
|
|
wait "$host_pid" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
coordination_parent="$(dirname "$COORDINATION_FILE")"
|
|
mkdir -p "$coordination_parent"
|
|
coordination_temp="$(mktemp "$COORDINATION_FILE.tmp.XXXXXXXX")"
|
|
printf '%s\n' "$observed_listing" >"$coordination_temp"
|
|
mv "$coordination_temp" "$COORDINATION_FILE"
|
|
printf 'Host ready; securely transfer the private coordination file to the client operator.\n'
|
|
set +e
|
|
wait "$host_pid"
|
|
exit_code="$?"
|
|
set -e
|
|
elif [[ "$ROLE" == client-success ]]; then
|
|
set +e
|
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
|
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
|
exit_code="$?"
|
|
set -e
|
|
else
|
|
set +e
|
|
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
|
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
|
exit_code="$?"
|
|
set -e
|
|
fi
|
|
|
|
checks='{}'
|
|
if [[ "$ROLE" == host ]]; then
|
|
[[ "$exit_code" -eq 0 ]]
|
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "host.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
|
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$raw_log" >/dev/null
|
|
checks='{"authenticatedDirectTraffic":true,"deregistered":true}'
|
|
elif [[ "$ROLE" == client-success ]]; then
|
|
[[ "$exit_code" -eq 0 ]]
|
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.connected" and .status == "connected" and .addressFamily == $family)' "$raw_log" >/dev/null
|
|
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
|
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$raw_log" >/dev/null
|
|
checks='{"authenticatedDirectTraffic":true,"typedOutcomeReported":true}'
|
|
else
|
|
[[ "$exit_code" -eq 12 ]]
|
|
jq -e 'select((.event == "join.traversal" or .event == "join.authorization") and .status == "failed" and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
|
jq -e 'select(.event == "join.fallback" and (.status == "available" or .status == "unavailable") and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
|
checks='{"boundedTypedFailure":true,"fallbackPolicyReported":true}'
|
|
fi
|
|
|
|
mkdir -p "$(dirname "$OUTPUT")"
|
|
raw_retention=deleted-after-success
|
|
if [[ "$KEEP_RAW" == true ]]; then
|
|
raw_retention=retained-private-on-request
|
|
fi
|
|
jq -n \
|
|
--arg commit "$commit" \
|
|
--arg treeState "$tree_state" \
|
|
--arg timestampUtc "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
--arg role "$ROLE" \
|
|
--arg topology "$TOPOLOGY" \
|
|
--arg addressFamily "$ADDRESS_FAMILY" \
|
|
--arg rawEvents "$raw_retention" \
|
|
--argjson checks "$checks" \
|
|
'{schemaVersion:1,kind:"rendezvous-real-network-canary",commit:$commit,treeState:$treeState,timestampUtc:$timestampUtc,role:$role,topology:$topology,addressFamily:$addressFamily,result:"pass",checks:$checks,dataRetention:{rawEvents:$rawEvents,identifiers:"not-in-summary",networkEndpoints:"not-in-summary"}}' \
|
|
>"$OUTPUT"
|
|
|
|
run_succeeded=true
|
|
printf 'Real-network canary passed; sanitized evidence: %s\n' "$OUTPUT"
|