Files
Rendezvous/scripts/run-real-network-canary.sh
T

235 lines
9.0 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}"
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; 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 [[ -z "${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" ]]; then
printf 'The host role requires RENDEZVOUS_PUBLISHER_CREDENTIAL.\n' >&2
exit 2
fi
else
if [[ ! "$LISTING_ID" =~ ^[0-9a-fA-F-]{36}$ ]]; 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" =~ ^[0-9a-f-]{36}$ ]]; 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="$COORDINATION_FILE.tmp.$$"
printf '%s\n' "$observed_listing" >"$coordination_temp"
chmod 600 "$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")"
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" \
--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:"deleted-after-success",identifiers:"not-in-summary",networkEndpoints:"not-in-summary"}}' \
>"$OUTPUT"
run_succeeded=true
printf 'Real-network canary passed; sanitized evidence: %s\n' "$OUTPUT"