Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints. - Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication. - Developed ConcelierExporterClient for managing Trivy DB settings and export operations. - Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering. - Implemented styles and HTML structure for Trivy DB settings page. - Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
303 lines
10 KiB
C#
303 lines
10 KiB
C#
using System.Globalization;
|
|
using StellaOps.Bench.ScannerAnalyzers.Scenarios;
|
|
|
|
namespace StellaOps.Bench.ScannerAnalyzers;
|
|
|
|
internal static class Program
|
|
{
|
|
public static async Task<int> Main(string[] args)
|
|
{
|
|
try
|
|
{
|
|
var options = ProgramOptions.Parse(args);
|
|
var config = await BenchmarkConfig.LoadAsync(options.ConfigPath).ConfigureAwait(false);
|
|
|
|
var iterations = options.Iterations ?? config.Iterations ?? 5;
|
|
var thresholdMs = options.ThresholdMs ?? config.ThresholdMs ?? 5000;
|
|
var repoRoot = ResolveRepoRoot(options.RepoRoot, options.ConfigPath);
|
|
|
|
var results = new List<ScenarioResult>();
|
|
var failures = new List<string>();
|
|
|
|
foreach (var scenario in config.Scenarios)
|
|
{
|
|
var runner = ScenarioRunnerFactory.Create(scenario);
|
|
var scenarioRoot = ResolveScenarioRoot(repoRoot, scenario.Root!);
|
|
|
|
var execution = await runner.ExecuteAsync(scenarioRoot, iterations, CancellationToken.None).ConfigureAwait(false);
|
|
var stats = ScenarioStatistics.FromDurations(execution.Durations);
|
|
var scenarioThreshold = scenario.ThresholdMs ?? thresholdMs;
|
|
|
|
results.Add(new ScenarioResult(
|
|
scenario.Id!,
|
|
scenario.Label ?? scenario.Id!,
|
|
execution.SampleCount,
|
|
stats.MeanMs,
|
|
stats.P95Ms,
|
|
stats.MaxMs,
|
|
iterations));
|
|
|
|
if (stats.MaxMs > scenarioThreshold)
|
|
{
|
|
failures.Add($"{scenario.Id} exceeded threshold: {stats.MaxMs:F2} ms > {scenarioThreshold:F2} ms");
|
|
}
|
|
}
|
|
|
|
TablePrinter.Print(results);
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.OutPath))
|
|
{
|
|
CsvWriter.Write(options.OutPath!, results);
|
|
}
|
|
|
|
if (failures.Count > 0)
|
|
{
|
|
Console.Error.WriteLine();
|
|
Console.Error.WriteLine("Performance threshold exceeded:");
|
|
foreach (var failure in failures)
|
|
{
|
|
Console.Error.WriteLine($" - {failure}");
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine(ex.Message);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private static string ResolveRepoRoot(string? overridePath, string configPath)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(overridePath))
|
|
{
|
|
return Path.GetFullPath(overridePath);
|
|
}
|
|
|
|
var configDirectory = Path.GetDirectoryName(configPath);
|
|
if (string.IsNullOrWhiteSpace(configDirectory))
|
|
{
|
|
return Directory.GetCurrentDirectory();
|
|
}
|
|
|
|
return Path.GetFullPath(Path.Combine(configDirectory, "..", ".."));
|
|
}
|
|
|
|
private static string ResolveScenarioRoot(string repoRoot, string relativeRoot)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(relativeRoot))
|
|
{
|
|
throw new InvalidOperationException("Scenario root is required.");
|
|
}
|
|
|
|
var combined = Path.GetFullPath(Path.Combine(repoRoot, relativeRoot));
|
|
if (!PathUtilities.IsWithinRoot(repoRoot, combined))
|
|
{
|
|
throw new InvalidOperationException($"Scenario root '{relativeRoot}' escapes repository root '{repoRoot}'.");
|
|
}
|
|
|
|
if (!Directory.Exists(combined))
|
|
{
|
|
throw new DirectoryNotFoundException($"Scenario root '{combined}' does not exist.");
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
|
|
private sealed record ProgramOptions(string ConfigPath, int? Iterations, double? ThresholdMs, string? OutPath, string? RepoRoot)
|
|
{
|
|
public static ProgramOptions Parse(string[] args)
|
|
{
|
|
var configPath = DefaultConfigPath();
|
|
int? iterations = null;
|
|
double? thresholdMs = null;
|
|
string? outPath = null;
|
|
string? repoRoot = null;
|
|
|
|
for (var index = 0; index < args.Length; index++)
|
|
{
|
|
var current = args[index];
|
|
switch (current)
|
|
{
|
|
case "--config":
|
|
EnsureNext(args, index);
|
|
configPath = Path.GetFullPath(args[++index]);
|
|
break;
|
|
case "--iterations":
|
|
EnsureNext(args, index);
|
|
iterations = int.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--threshold-ms":
|
|
EnsureNext(args, index);
|
|
thresholdMs = double.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--out":
|
|
EnsureNext(args, index);
|
|
outPath = args[++index];
|
|
break;
|
|
case "--repo-root":
|
|
case "--samples":
|
|
EnsureNext(args, index);
|
|
repoRoot = args[++index];
|
|
break;
|
|
default:
|
|
throw new ArgumentException($"Unknown argument: {current}", nameof(args));
|
|
}
|
|
}
|
|
|
|
return new ProgramOptions(configPath, iterations, thresholdMs, outPath, repoRoot);
|
|
}
|
|
|
|
private static string DefaultConfigPath()
|
|
{
|
|
var binaryDir = AppContext.BaseDirectory;
|
|
var projectRoot = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", ".."));
|
|
var configDirectory = Path.GetFullPath(Path.Combine(projectRoot, ".."));
|
|
return Path.Combine(configDirectory, "config.json");
|
|
}
|
|
|
|
private static void EnsureNext(string[] args, int index)
|
|
{
|
|
if (index + 1 >= args.Length)
|
|
{
|
|
throw new ArgumentException("Missing value for argument.", nameof(args));
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed record ScenarioResult(
|
|
string Id,
|
|
string Label,
|
|
int SampleCount,
|
|
double MeanMs,
|
|
double P95Ms,
|
|
double MaxMs,
|
|
int Iterations);
|
|
|
|
private sealed record ScenarioStatistics(double MeanMs, double P95Ms, double MaxMs)
|
|
{
|
|
public static ScenarioStatistics FromDurations(IReadOnlyList<double> durations)
|
|
{
|
|
if (durations.Count == 0)
|
|
{
|
|
return new ScenarioStatistics(0, 0, 0);
|
|
}
|
|
|
|
var sorted = durations.ToArray();
|
|
Array.Sort(sorted);
|
|
|
|
var total = 0d;
|
|
foreach (var value in durations)
|
|
{
|
|
total += value;
|
|
}
|
|
|
|
var mean = total / durations.Count;
|
|
var p95 = Percentile(sorted, 95);
|
|
var max = sorted[^1];
|
|
|
|
return new ScenarioStatistics(mean, p95, max);
|
|
}
|
|
|
|
private static double Percentile(IReadOnlyList<double> sorted, double percentile)
|
|
{
|
|
if (sorted.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var rank = (percentile / 100d) * (sorted.Count - 1);
|
|
var lower = (int)Math.Floor(rank);
|
|
var upper = (int)Math.Ceiling(rank);
|
|
var weight = rank - lower;
|
|
|
|
if (upper >= sorted.Count)
|
|
{
|
|
return sorted[lower];
|
|
}
|
|
|
|
return sorted[lower] + weight * (sorted[upper] - sorted[lower]);
|
|
}
|
|
}
|
|
|
|
private static class TablePrinter
|
|
{
|
|
public static void Print(IEnumerable<ScenarioResult> results)
|
|
{
|
|
Console.WriteLine("Scenario | Count | Mean(ms) | P95(ms) | Max(ms)");
|
|
Console.WriteLine("---------------------------- | ----- | --------- | --------- | ----------");
|
|
foreach (var row in results)
|
|
{
|
|
Console.WriteLine(FormatRow(row));
|
|
}
|
|
}
|
|
|
|
private static string FormatRow(ScenarioResult row)
|
|
{
|
|
var idColumn = row.Id.Length <= 28
|
|
? row.Id.PadRight(28)
|
|
: row.Id[..28];
|
|
|
|
return string.Join(" | ", new[]
|
|
{
|
|
idColumn,
|
|
row.SampleCount.ToString(CultureInfo.InvariantCulture).PadLeft(5),
|
|
row.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9),
|
|
row.P95Ms.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9),
|
|
row.MaxMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10),
|
|
});
|
|
}
|
|
}
|
|
|
|
private static class CsvWriter
|
|
{
|
|
public static void Write(string path, IEnumerable<ScenarioResult> results)
|
|
{
|
|
var resolvedPath = Path.GetFullPath(path);
|
|
var directory = Path.GetDirectoryName(resolvedPath);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
using var stream = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
using var writer = new StreamWriter(stream);
|
|
writer.WriteLine("scenario,iterations,sample_count,mean_ms,p95_ms,max_ms");
|
|
|
|
foreach (var row in results)
|
|
{
|
|
writer.Write(row.Id);
|
|
writer.Write(',');
|
|
writer.Write(row.Iterations.ToString(CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(row.SampleCount.ToString(CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(row.MeanMs.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(row.P95Ms.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(row.MaxMs.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.WriteLine();
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class PathUtilities
|
|
{
|
|
public static bool IsWithinRoot(string root, string candidate)
|
|
{
|
|
var relative = Path.GetRelativePath(root, candidate);
|
|
if (string.IsNullOrEmpty(relative) || relative == ".")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return !relative.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relative);
|
|
}
|
|
}
|
|
}
|