#!/usr/bin/env bash # Satellite Provider Performance Tests # # Runs PT-01..PT-08 against a live API. All probes carry a Bearer token minted # from JWT_SECRET (AZ-487 required RequireAuthorization on every endpoint; # without the header every probe returns 401). # # Token-mint surface is the canonical SatelliteProvider.TestSupport.JwtTokenFactory # (AZ-491). The shell does NOT inline a third copy of the JWT logic — it shells # out to the IntegrationTests --mint-only subcommand which calls JwtTokenFactory. # # Token lifetime: 4 hours (covers the longest possible PT-05 + PT-07 + PT-08 # combined run with margin). Override via PERF_JWT_TOKEN if you want to use # your own pre-minted token instead. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" API_URL="${API_URL:-https://localhost:18980}" PERF_REPEAT_COUNT="${PERF_REPEAT_COUNT:-20}" PERF_UAV_BATCH_SIZE="${PERF_UAV_BATCH_SIZE:-10}" # AZ-505 dev TLS: the dev compose stack now binds Kestrel on https://+:8080 with # a self-signed cert (./certs/api.crt) so ALPN can negotiate HTTP/2. Every curl # below splats "${CURL_OPTS[@]}" so the cert is trusted (--cacert against the # dev cert when present, otherwise the host-CA store). Override by exporting # PERF_CURL_OPTS (whitespace-separated, e.g. PERF_CURL_OPTS="-k --silent") to # bypass dev-cert logic entirely (useful against a staging cert). CURL_OPTS=() if [[ -n "${PERF_CURL_OPTS:-}" ]]; then read -r -a CURL_OPTS <<<"$PERF_CURL_OPTS" elif [[ "$API_URL" == https://* && -f "$PROJECT_ROOT/certs/api.crt" ]]; then CURL_OPTS=(--cacert "$PROJECT_ROOT/certs/api.crt") fi cleanup() { echo "Cleaning up..." if [[ -n "${PERF_TMP_DIR:-}" && -d "${PERF_TMP_DIR}" ]]; then rm -rf "${PERF_TMP_DIR}" fi } trap cleanup EXIT echo "=== Satellite Provider Performance Tests ===" echo "API URL: $API_URL" echo "" PASS=0 FAIL=0 # Load JWT_SECRET / JWT_ISSUER / JWT_AUDIENCE from .env if not already exported # (mirrors run-tests.sh). AZ-494: iss + aud are now required. if { [[ -z "${JWT_SECRET:-}" ]] || [[ -z "${JWT_ISSUER:-}" ]] || [[ -z "${JWT_AUDIENCE:-}" ]]; } && [[ -f "$PROJECT_ROOT/.env" ]]; then set -o allexport # shellcheck disable=SC1091 source "$PROJECT_ROOT/.env" set +o allexport fi PERF_PROJECT="$PROJECT_ROOT/SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj" PERF_DLL="$PROJECT_ROOT/SatelliteProvider.IntegrationTests/bin/Release/net10.0/SatelliteProvider.IntegrationTests.dll" # Pre-build IntegrationTests once so the --mint-only / --gen-uav-fixture # subcommands produce clean stdout (no interleaved Restore/Build chatter). if [[ -z "${PERF_JWT_TOKEN:-}" || ! -f "$PERF_DLL" ]]; then echo "Building SatelliteProvider.IntegrationTests (Release) for perf bootstrap..." if ! dotnet build "$PERF_PROJECT" --configuration Release --verbosity quiet; then echo "ERROR: failed to build SatelliteProvider.IntegrationTests" exit 3 fi fi if [[ -z "${PERF_JWT_TOKEN:-}" ]]; then if [[ -z "${JWT_SECRET:-}" ]]; then echo "ERROR: neither PERF_JWT_TOKEN nor JWT_SECRET is set." echo " export JWT_SECRET (>=32 bytes) or PERF_JWT_TOKEN before running." exit 3 fi jwt_secret_bytes=${#JWT_SECRET} if (( jwt_secret_bytes < 32 )); then echo "ERROR: JWT_SECRET is ${jwt_secret_bytes} bytes; HMAC-SHA256 requires at least 32 bytes." exit 3 fi if [[ -z "${JWT_ISSUER:-}" ]]; then echo "ERROR: JWT_ISSUER is not set (AZ-494). Export it or add to .env so the minted token's iss matches the API." exit 3 fi if [[ -z "${JWT_AUDIENCE:-}" ]]; then echo "ERROR: JWT_AUDIENCE is not set (AZ-494). Export it or add to .env so the minted token's aud matches the API." exit 3 fi export JWT_SECRET JWT_ISSUER JWT_AUDIENCE echo "Minting perf JWT via SatelliteProvider.IntegrationTests --mint-only..." if ! PERF_JWT_TOKEN=$(dotnet "$PERF_DLL" --mint-only); then echo "ERROR: --mint-only invocation failed (see stderr above)" exit 3 fi PERF_JWT_TOKEN="${PERF_JWT_TOKEN//$'\n'/}" if [[ -z "$PERF_JWT_TOKEN" ]]; then echo "ERROR: --mint-only returned an empty token. Check JWT_SECRET." exit 3 fi fi AUTH_HEADER="Authorization: Bearer $PERF_JWT_TOKEN" echo "JWT token: ready (${#PERF_JWT_TOKEN} bytes, 4h lifetime)" echo "" # Working directory for generated fixtures (UAV JPEG). Removed on exit. PERF_TMP_DIR="$(mktemp -d -t perf-XXXXXX)" # --- Helper functions --- check_threshold() { local test_name="$1" local actual_ms="$2" local threshold_ms="$3" if (( actual_ms <= threshold_ms )); then echo " ✓ $test_name: ${actual_ms}ms (threshold: ${threshold_ms}ms)" PASS=$((PASS + 1)) else echo " ✗ $test_name: ${actual_ms}ms EXCEEDS threshold ${threshold_ms}ms" FAIL=$((FAIL + 1)) fi } # percentile ... (sorts ascending, picks ceil(N*pct/100)) percentile() { local pct="$1" shift printf '%s\n' "$@" | sort -n | awk -v p="$pct" ' { v[NR] = $1 } END { if (NR == 0) { print 0; exit } idx = int((NR * p / 100) + 0.999999) if (idx < 1) idx = 1 if (idx > NR) idx = NR print v[idx] }' } wait_region_completed() { local region_id="$1" local timeout_s="${2:-180}" local elapsed=0 while (( elapsed < timeout_s )); do local status status=$(curl "${CURL_OPTS[@]}" -s -H "$AUTH_HEADER" "$API_URL/api/satellite/region/$region_id" | grep -o '"status":"[^"]*"' | head -1 || true) case "$status" in *completed*) return 0 ;; *failed*) echo " region $region_id failed during wait" >&2; return 2 ;; esac sleep 2 elapsed=$((elapsed + 2)) done return 1 } # --- PT-01..PT-06 (existing scenarios; now with Bearer token) --- # PT-01: Tile download latency for a fresh tile (cold path). echo "PT-01: Tile Download Latency (cold) (threshold: 30000ms)" PT01_LAT="47.461347" PT01_LON="37.646663" START=$(date +%s%N) HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?Latitude=$PT01_LAT&Longitude=$PT01_LON&ZoomLevel=18") END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 )) if [[ "$HTTP_CODE" == "200" ]]; then check_threshold "Tile download (cold)" "$ELAPSED_MS" 30000 else echo " ✗ PT-01: HTTP $HTTP_CODE (expected 200)" FAIL=$((FAIL + 1)) fi echo "" echo "PT-02: Cached Tile Retrieval Latency (threshold: 500ms)" START=$(date +%s%N) HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" "$API_URL/api/satellite/tiles/latlon?Latitude=47.461747&Longitude=37.647063&ZoomLevel=18") END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 )) if [[ "$HTTP_CODE" == "200" ]]; then check_threshold "Cached tile retrieval" "$ELAPSED_MS" 500 else echo " ✗ PT-02: HTTP $HTTP_CODE (expected 200) - tile may not be cached yet" FAIL=$((FAIL + 1)) fi # PT-03: Region 200m at zoom 18, no stitching, threshold 60s end-to-end. echo "" echo "PT-03: Region Processing 200m / zoom 18 (threshold: 60000ms)" PT03_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') PT03_BODY="{\"id\":\"$PT03_ID\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" START=$(date +%s%N) HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$PT03_BODY" "$API_URL/api/satellite/request") if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "202" ]]; then if wait_region_completed "$PT03_ID" 60; then END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 )) check_threshold "Region 200m/z18" "$ELAPSED_MS" 60000 else echo " ✗ PT-03: region did not complete within 60s" FAIL=$((FAIL + 1)) fi else echo " ✗ PT-03: enqueue HTTP $HTTP_CODE (expected 200/202)" FAIL=$((FAIL + 1)) fi # PT-04: Region 500m at zoom 18 with stitching, threshold 120s end-to-end. echo "" echo "PT-04: Region Processing 500m / zoom 18 + stitch (threshold: 120000ms)" PT04_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') PT04_BODY="{\"id\":\"$PT04_ID\",\"latitude\":47.461747,\"longitude\":37.647063,\"sizeMeters\":500,\"zoomLevel\":18,\"stitchTiles\":true}" START=$(date +%s%N) HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$PT04_BODY" "$API_URL/api/satellite/request") if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "202" ]]; then if wait_region_completed "$PT04_ID" 120; then END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 )) check_threshold "Region 500m/z18 stitched" "$ELAPSED_MS" 120000 else echo " ✗ PT-04: region did not complete within 120s" FAIL=$((FAIL + 1)) fi else echo " ✗ PT-04: enqueue HTTP $HTTP_CODE (expected 200/202)" FAIL=$((FAIL + 1)) fi # PT-05: 5 concurrent regions all complete within 5min (300s) end-to-end. echo "" echo "PT-05: Concurrent Region Processing (5 in 300000ms)" PT05_IDS=() PT05_START=$(date +%s%N) for i in 1 2 3 4 5; do rid=$(uuidgen | tr '[:upper:]' '[:lower:]') PT05_IDS+=("$rid") LAT=$(awk "BEGIN { printf \"%.6f\", 47.461747 + 0.001 * $i }") LON=$(awk "BEGIN { printf \"%.6f\", 37.647063 + 0.001 * $i }") BODY="{\"id\":\"$rid\",\"latitude\":$LAT,\"longitude\":$LON,\"sizeMeters\":200,\"zoomLevel\":18,\"stitchTiles\":false}" HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$BODY" "$API_URL/api/satellite/request") if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "202" ]]; then echo " ✗ PT-05: enqueue $i HTTP $HTTP_CODE (expected 200/202)" FAIL=$((FAIL + 1)) fi done PT05_OK=1 for rid in "${PT05_IDS[@]}"; do if ! wait_region_completed "$rid" 300; then PT05_OK=0 echo " ✗ PT-05: region $rid did not complete within 300s" break fi done if (( PT05_OK == 1 )); then PT05_END=$(date +%s%N) ELAPSED_MS=$(( (PT05_END - PT05_START) / 1000000 )) check_threshold "5 concurrent regions" "$ELAPSED_MS" 300000 else FAIL=$((FAIL + 1)) fi echo "" echo "PT-06: Route Point Interpolation Speed (threshold: 5000ms)" ROUTE_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') BODY="{\"id\":\"$ROUTE_ID\",\"name\":\"Perf Test\",\"regionSizeMeters\":300,\"zoomLevel\":18,\"points\":[{\"lat\":48.276067,\"lon\":37.384458},{\"lat\":48.270740,\"lon\":37.374029}]}" START=$(date +%s%N) HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$BODY" "$API_URL/api/satellite/route") END=$(date +%s%N) ELAPSED_MS=$(( (END - START) / 1000000 )) if [[ "$HTTP_CODE" == "200" ]]; then check_threshold "Route creation (2 points)" "$ELAPSED_MS" 5000 else echo " ✗ PT-06: HTTP $HTTP_CODE (expected 200)" FAIL=$((FAIL + 1)) fi # --- PT-07: GetTilesByRegionAsync latency post-AZ-484 (cold + warm distribution) --- echo "" echo "PT-07: Region request latency distribution (N=$PERF_REPEAT_COUNT, cold + warm)" PT07_BASE_LAT="47.471747" PT07_BASE_LON="37.657063" declare -a PT07_COLD_MS=() declare -a PT07_WARM_MS=() PT07_FAILED=0 # Cold run: each request hits a distinct coordinate band so the tile cache is missed. echo " cold run (${PERF_REPEAT_COUNT} distinct coordinates)..." for ((i=0; i 0 && ${#PT07_WARM_MS[@]} > 0 )); then PT07_COLD_P50=$(percentile 50 "${PT07_COLD_MS[@]}") PT07_COLD_P95=$(percentile 95 "${PT07_COLD_MS[@]}") PT07_WARM_P50=$(percentile 50 "${PT07_WARM_MS[@]}") PT07_WARM_P95=$(percentile 95 "${PT07_WARM_MS[@]}") echo " cold: p50=${PT07_COLD_P50}ms p95=${PT07_COLD_P95}ms (N=${#PT07_COLD_MS[@]})" echo " warm: p50=${PT07_WARM_P50}ms p95=${PT07_WARM_P95}ms (N=${#PT07_WARM_MS[@]})" if (( PT07_WARM_P95 < PT07_COLD_P95 )); then echo " ✓ PT-07: warm p95 (${PT07_WARM_P95}ms) < cold p95 (${PT07_COLD_P95}ms)" PASS=$((PASS + 1)) else # AZ-492 spec AC-2: warm < cold expected but no specific threshold required. # Surface the inversion as a soft FAIL rather than asserting. echo " ✗ PT-07: warm p95 (${PT07_WARM_P95}ms) is NOT below cold p95 (${PT07_COLD_P95}ms)" FAIL=$((FAIL + 1)) fi else echo " ✗ PT-07: insufficient measurements (cold=${#PT07_COLD_MS[@]} warm=${#PT07_WARM_MS[@]} failed=${PT07_FAILED})" FAIL=$((FAIL + 1)) fi # --- PT-08: UAV tile batch upload latency --- echo "" echo "PT-08: UAV batch upload latency (batch size=${PERF_UAV_BATCH_SIZE}, N=${PERF_REPEAT_COUNT})" PT08_FIXTURE="$PERF_TMP_DIR/uav_fixture.jpg" echo " generating UAV fixture JPEG..." if ! dotnet "$PERF_DLL" --gen-uav-fixture "$PT08_FIXTURE" >/dev/null; then echo " ✗ PT-08: --gen-uav-fixture failed; cannot run PT-08" FAIL=$((FAIL + 1)) elif [[ ! -s "$PT08_FIXTURE" ]]; then echo " ✗ PT-08: fixture JPEG is empty at $PT08_FIXTURE" FAIL=$((FAIL + 1)) else declare -a PT08_BATCH_MS=() PT08_ACCEPTED=0 PT08_REJECTED=0 PT08_FAILED=0 PT08_BASE_LAT="60.0" PT08_BASE_LON="30.0" PT08_COORD_STRIDE="0.0005" for ((run=0; run 0 )); then PT08_P50=$(percentile 50 "${PT08_BATCH_MS[@]}") PT08_P95=$(percentile 95 "${PT08_BATCH_MS[@]}") # Per-item gate cost is a proxy: total batch latency / item count. # True per-call UavTileQualityGate.Validate timing requires server-side # instrumentation (out of scope for AZ-492). This client-side proxy is # still useful for catching gross regressions. PT08_PER_ITEM_P95=$(( PT08_P95 / PERF_UAV_BATCH_SIZE )) echo " batch p50=${PT08_P50}ms p95=${PT08_P95}ms (N=${#PT08_BATCH_MS[@]})" echo " per-item proxy p95=${PT08_PER_ITEM_P95}ms (= batch p95 / ${PERF_UAV_BATCH_SIZE})" echo " items: accepted=${PT08_ACCEPTED} rejected=${PT08_REJECTED} failed=${PT08_FAILED}" # AZ-488 acceptable target: end-to-end batch p95 < 2000ms on dev hardware. check_threshold "PT-08 batch p95" "$PT08_P95" 2000 else echo " ✗ PT-08: no successful batches (failed=${PT08_FAILED})" FAIL=$((FAIL + 1)) fi fi # --- Summary --- echo "" echo "=== Performance Test Summary ===" echo " Passed: $PASS" echo " Failed: $FAIL" echo "" if [[ $FAIL -gt 0 ]]; then echo "FAILED: $FAIL performance threshold(s) exceeded" exit 1 fi echo "ALL PERFORMANCE TESTS PASSED" exit 0