using System.Diagnostics; using System.Security.Claims; using Grpc.Core; using Satellite.V1; using SatelliteProvider.TestSupport; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.PixelFormats; namespace SatelliteProvider.IntegrationTests; // AZ-492: bootstrap helpers invoked by scripts/run-performance-tests.sh. // Each helper is a short-circuit subcommand that prints/writes its output // and exits before the integration-test runner does any HTTP or DB work. // All token-minting goes through the canonical JwtTokenFactory (AZ-491) // so the shell script does NOT inline a third copy of the JWT logic. internal static class PerfBootstrap { public const string PerfSubject = "perf-tests"; public const string GpsPermission = "GPS"; public const string PermissionsClaimType = "permissions"; public static readonly TimeSpan PerfTokenLifetime = TimeSpan.FromHours(4); public static int MintToken() { string secret; string issuer; string audience; try { secret = JwtTestHelpers.ResolveSecretOrThrow(); issuer = JwtTestHelpers.ResolveIssuerOrThrow(); audience = JwtTestHelpers.ResolveAudienceOrThrow(); } catch (InvalidOperationException ex) { Console.Error.WriteLine($"--mint-only: {ex.Message}"); return 1; } var token = JwtTokenFactory.Create( secret, PerfSubject, PerfTokenLifetime, new[] { new Claim(PermissionsClaimType, GpsPermission) }, issuer: issuer, audience: audience); Console.Out.Write(token); return 0; } public static int GenerateUavFixture(string[] args) { if (args.Length < 2 || string.IsNullOrWhiteSpace(args[1])) { Console.Error.WriteLine("--gen-uav-fixture: missing output path. Usage: --gen-uav-fixture "); return 2; } var path = args[1]; var directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } var bytes = CreateValidJpeg(); File.WriteAllBytes(path, bytes); Console.Out.WriteLine(path); return 0; } // Mirrors the random-noise JPEG produced by UavUploadTests.CreateValidJpeg so // that the perf harness exercises the same quality-gate path as the integration // tests. Pixel pattern is high-variance enough to pass the UAV quality gate. private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42) { using var image = new Image(width, height); var random = new Random(seed); image.ProcessPixelRows(accessor => { for (var y = 0; y < accessor.Height; y++) { var row = accessor.GetRowSpan(y); for (var x = 0; x < row.Length; x++) { row[x] = new Rgba32( (byte)random.Next(256), (byte)random.Next(256), (byte)random.Next(256)); } } }); using var stream = new MemoryStream(); image.Save(stream, new JpegEncoder { Quality = 95 }); return stream.ToArray(); } private static bool TryResolvePerfJwt(out string token) { var preMinted = Environment.GetEnvironmentVariable("PERF_JWT_TOKEN"); if (!string.IsNullOrWhiteSpace(preMinted)) { token = preMinted; return true; } try { var secret = JwtTestHelpers.ResolveSecretOrThrow(); var issuer = JwtTestHelpers.ResolveIssuerOrThrow(); var audience = JwtTestHelpers.ResolveAudienceOrThrow(); token = JwtTokenFactory.Create( secret, PerfSubject, PerfTokenLifetime, new[] { new Claim(PermissionsClaimType, GpsPermission) }, issuer: issuer, audience: audience); return true; } catch (InvalidOperationException ex) { Console.Error.WriteLine($"--run-pt10: {ex.Message}"); token = string.Empty; return false; } } public static async Task RunPt10Async() { var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "https://localhost:18980"; var repeatCount = int.TryParse(Environment.GetEnvironmentVariable("PERF_REPEAT_COUNT"), out var n) ? n : 20; var slowMs = int.TryParse(Environment.GetEnvironmentVariable("PERF_PT10_SLOW_MS"), out var slow) ? slow : 50; if (repeatCount < 1) { Console.Error.WriteLine("--run-pt10: PERF_REPEAT_COUNT must be >= 1"); return 1; } string token; if (!TryResolvePerfJwt(out token)) { return 1; } RouteTileDelivery.RouteTileDeliveryClient client; try { client = GrpcTestHelpers.CreateClient(apiUrl, token); } catch (Exception ex) { Console.Error.WriteLine($"--run-pt10: failed to open gRPC channel to {apiUrl}: {ex.Message}"); return 1; } var headers = GrpcTestHelpers.AuthMetadata(token); var firstBatchTimes = new List(); var totalStreamTimes = new List(); var failed = 0; for (var i = 0; i < repeatCount; i++) { var result = await ProbeStreamAsync(client, headers, Guid.NewGuid(), delayMsBetweenEvents: null); if (result.Success) { firstBatchTimes.Add(result.FirstBatchMs); totalStreamTimes.Add(result.TotalStreamMs); Console.Error.WriteLine($" iteration {i + 1}/{repeatCount}: first_batch_ms={result.FirstBatchMs} total_stream_ms={result.TotalStreamMs}"); } else { failed++; Console.Error.WriteLine($" iteration {i + 1}/{repeatCount}: FAILED — {result.FailureReason}"); } } if (firstBatchTimes.Count == 0) { Console.Error.WriteLine("--run-pt10: zero successful iterations"); Console.Out.WriteLine("PT10_ITERATIONS_OK=0"); Console.Out.WriteLine($"PT10_ITERATIONS_FAILED={failed}"); Console.Out.WriteLine("PT10_SLOW_CONSUMER=FAIL"); return 1; } var slowResult = await ProbeStreamAsync(client, headers, Guid.NewGuid(), delayMsBetweenEvents: slowMs); var slowPass = slowResult.Success && slowResult.TileCount >= 1; Console.Out.WriteLine($"PT10_FIRST_BATCH_P50={Percentile(firstBatchTimes, 50)}"); Console.Out.WriteLine($"PT10_FIRST_BATCH_P95={Percentile(firstBatchTimes, 95)}"); Console.Out.WriteLine($"PT10_TOTAL_STREAM_P50={Percentile(totalStreamTimes, 50)}"); Console.Out.WriteLine($"PT10_TOTAL_STREAM_P95={Percentile(totalStreamTimes, 95)}"); Console.Out.WriteLine($"PT10_ITERATIONS_OK={firstBatchTimes.Count}"); Console.Out.WriteLine($"PT10_ITERATIONS_FAILED={failed}"); Console.Out.WriteLine($"PT10_SLOW_CONSUMER={(slowPass ? "PASS" : "FAIL")}"); if (!slowPass) { Console.Error.WriteLine($"--run-pt10: slow-consumer check failed — {slowResult.FailureReason ?? "no tiles received"}"); return 1; } return failed > 0 ? 1 : 0; } internal static long Percentile(IReadOnlyList values, int pct) { if (values.Count == 0) { return 0; } var sorted = values.OrderBy(v => v).ToList(); var idx = (int)Math.Ceiling(sorted.Count * pct / 100.0) - 1; idx = Math.Clamp(idx, 0, sorted.Count - 1); return sorted[idx]; } private static async Task ProbeStreamAsync( RouteTileDelivery.RouteTileDeliveryClient client, Metadata headers, Guid routeId, int? delayMsBetweenEvents) { var request = GrpcTestHelpers.BuildValidRequest(routeId: routeId); var sw = Stopwatch.StartNew(); long firstBatchMs = -1; var tileCount = 0; DeliveryComplete? complete = null; DeliveryError? error = null; try { using var call = client.DeliverRouteTiles(request, headers); await foreach (var evt in call.ResponseStream.ReadAllAsync()) { switch (evt.PayloadCase) { case RouteTileEvent.PayloadOneofCase.Batch: if (firstBatchMs < 0) { firstBatchMs = sw.ElapsedMilliseconds; } tileCount += evt.Batch.Tiles.Count; break; case RouteTileEvent.PayloadOneofCase.Complete: complete = evt.Complete; break; case RouteTileEvent.PayloadOneofCase.Error: error = evt.Error; break; } if (delayMsBetweenEvents.HasValue) { await Task.Delay(delayMsBetweenEvents.Value); } } } catch (RpcException ex) { return StreamProbeResult.Fail($"RpcException {ex.StatusCode}: {ex.Status.Detail}"); } var totalStreamMs = sw.ElapsedMilliseconds; if (error is not null) { return StreamProbeResult.Fail($"DeliveryError {error.Code}: {error.Message}"); } if (complete is null) { return StreamProbeResult.Fail("stream ended without DeliveryComplete"); } if (firstBatchMs < 0) { return StreamProbeResult.Fail("no Batch event before DeliveryComplete"); } return StreamProbeResult.Ok(firstBatchMs, totalStreamMs, tileCount); } private readonly record struct StreamProbeResult( bool Success, long FirstBatchMs, long TotalStreamMs, int TileCount, string? FailureReason) { public static StreamProbeResult Ok(long firstBatchMs, long totalStreamMs, int tileCount) => new(true, firstBatchMs, totalStreamMs, tileCount, null); public static StreamProbeResult Fail(string reason) => new(false, 0, 0, 0, reason); } }