using System.Diagnostics; using System.Globalization; using System.Net.Http.Headers; using Azaion.Missions.E2E.Helpers; using Npgsql; using Xunit; namespace Azaion.Missions.E2E.Fixtures; /// /// Shared 5-minute steady-state load fixture for NFT-RES-LIM-01 / -02 / -03. /// Runs the load generator once, samples RSS / connection count / FD count /// every 5s, and exposes the time series + sentinel "did the SUT exit" flag. /// /// /// The fixture is class-scoped (xUnit ) /// and shared across all three NFT-RES-LIM-01/02/03 tests so the 5-minute /// window runs once per CI invocation. /// Disabled when COMPOSE_RESTART_ENABLED != 1 or docker CLI /// is missing. Disabled state is observable via ; tests /// must call at the top of the /// method body — initialising the fixture without docker would throw inside /// and surface as a hard failure instead of /// the explicit skip the spec requires. /// public sealed class SteadyStateLoadFixture : IAsyncLifetime { public const int SampleIntervalSeconds = 5; public const int LoadDurationSeconds = 300; public const int TargetRps = 50; public const string ContainerName = "missions-sut"; public bool Enabled => Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1"; public bool LoadGeneratorMetTargetRps { get; private set; } public bool SutExitedDuringWindow { get; private set; } public string? SkipReason { get; private set; } public List RssBytesSamples { get; } = new(); public List NpgsqlConnectionSamples { get; } = new(); public List FileDescriptorSamples { get; } = new(); public List SampleTimestamps { get; } = new(); public async Task InitializeAsync() { if (!Enabled) { SkipReason = "COMPOSE_RESTART_ENABLED!=1 — docker CLI primitives unavailable"; return; } if (!CommandAvailable("docker")) { SkipReason = "docker CLI not on PATH in this consumer image"; return; } using var http = new HttpClient { BaseAddress = new Uri(TestEnvironment.MissionsBaseUrl) }; var token = await new TokenMinter(TestEnvironment.JwksMockBaseUrl + "/sign").MintDefaultAsync(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Jwt); var cancel = new CancellationTokenSource(); var endpoints = new[] { "/vehicles", "/missions", "/missions?page=1&pageSize=20" }; long requestsSent = 0; var loadTask = Task.Run(async () => { var ix = 0; while (!cancel.IsCancellationRequested) { try { var url = endpoints[ix++ % endpoints.Length]; using var resp = await http.GetAsync(url, cancel.Token); Interlocked.Increment(ref requestsSent); } catch (HttpRequestException) { /* surfaces via SutExitedDuringWindow below */ } catch (OperationCanceledException) { return; } } }, cancel.Token); var samplingDeadline = DateTime.UtcNow.AddSeconds(LoadDurationSeconds); while (DateTime.UtcNow < samplingDeadline) { await Task.Delay(TimeSpan.FromSeconds(SampleIntervalSeconds), cancel.Token); if (!ContainerIsRunning(ContainerName)) { SutExitedDuringWindow = true; break; } SampleTimestamps.Add(DateTime.UtcNow); RssBytesSamples.Add(ReadRssBytes(ContainerName)); NpgsqlConnectionSamples.Add(ReadNpgsqlConnectionCount()); FileDescriptorSamples.Add(ReadFileDescriptorCount(ContainerName)); } cancel.Cancel(); try { await loadTask; } catch (OperationCanceledException) { } // Sustained 50 RPS over 300s = 15000 requests; allow 10% slack for // CI variance / connection-refused retries. LoadGeneratorMetTargetRps = requestsSent >= (long)(TargetRps * LoadDurationSeconds * 0.9); } public Task DisposeAsync() => Task.CompletedTask; private static long ReadRssBytes(string containerName) { // `docker stats --no-stream --format '{{.MemUsage}}'` prints e.g. // "187.4MiB / 7.7GiB". We need the LHS in bytes. var raw = Run("docker", $"stats --no-stream --format '{{{{.MemUsage}}}}' {containerName}"); var lhs = raw.Split('/')[0].Trim().Trim('\''); return ParseHumanBytes(lhs); } private static int ReadNpgsqlConnectionCount() { using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = """ SELECT count(*)::INTEGER FROM pg_stat_activity WHERE application_name LIKE 'Npgsql%' OR (usename = 'postgres' AND backend_type = 'client backend'); """; return Convert.ToInt32(cmd.ExecuteScalar()); } private static int ReadFileDescriptorCount(string containerName) { // `pgrep` is not guaranteed in the runtime image; we walk /proc // directly. `/proc/1/comm` is the entrypoint process name; for the // ASP.NET Core SDK image this is `dotnet`. var stdout = Run("docker", $"exec {containerName} sh -c 'ls /proc/1/fd | wc -l'"); return int.Parse(stdout.Trim(), CultureInfo.InvariantCulture); } private static long ParseHumanBytes(string text) { // "187.4MiB" / "1.2GiB" / "234KiB" / "987B" var unitIx = text.IndexOfAny(new[] { 'K', 'M', 'G', 'T', 'B' }); if (unitIx < 0) return long.Parse(text, CultureInfo.InvariantCulture); var num = double.Parse(text.Substring(0, unitIx), CultureInfo.InvariantCulture); var unit = text.Substring(unitIx); return unit switch { "B" => (long)num, "KiB" or "KB" or "K" => (long)(num * 1024), "MiB" or "MB" or "M" => (long)(num * 1024 * 1024), "GiB" or "GB" or "G" => (long)(num * 1024 * 1024 * 1024), "TiB" or "TB" or "T" => (long)(num * 1024L * 1024 * 1024 * 1024), _ => throw new FormatException($"unknown human-bytes unit in '{text}'") }; } private static bool ContainerIsRunning(string containerName) { try { var stdout = Run("docker", $"inspect --format '{{{{.State.Running}}}}' {containerName}"); return stdout.Trim().Trim('\'').Equals("true", StringComparison.Ordinal); } catch (InvalidOperationException) { return false; } } private static bool CommandAvailable(string command) { try { var psi = new ProcessStartInfo(command, "--version") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using var p = Process.Start(psi); if (p is null) return false; p.WaitForExit(); return p.ExitCode == 0; } catch (System.ComponentModel.Win32Exception) { return false; } } private static string Run(string file, string args) { var psi = new ProcessStartInfo(file, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using var p = Process.Start(psi) ?? throw new InvalidOperationException($"failed to launch `{file} {args}`"); var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode != 0) throw new InvalidOperationException( $"`{file} {args}` exited {p.ExitCode}: {stderr}"); return stdout; } }