Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,510 @@
// -----------------------------------------------------------------------------
// BundleRotationJob.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0015 - Create BundleRotationJob in Scheduler
// Description: Scheduled job for monthly attestation bundle rotation
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scheduler.Worker.Attestor;
/// <summary>
/// Configuration options for bundle rotation.
/// </summary>
public sealed class BundleRotationOptions
{
/// <summary>
/// Whether bundle rotation is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Cron expression for rotation schedule.
/// Default: Monthly on the 1st at 02:00 UTC.
/// </summary>
public string CronSchedule { get; set; } = "0 2 1 * *";
/// <summary>
/// Rotation cadence.
/// </summary>
public BundleRotationCadence Cadence { get; set; } = BundleRotationCadence.Monthly;
/// <summary>
/// Look-back period in days for attestation collection.
/// </summary>
public int LookbackDays { get; set; } = 31;
/// <summary>
/// Maximum attestations per bundle.
/// </summary>
public int MaxAttestationsPerBundle { get; set; } = 10000;
/// <summary>
/// Batch size for database queries.
/// </summary>
public int QueryBatchSize { get; set; } = 500;
/// <summary>
/// Whether to sign bundles with organization key.
/// </summary>
public bool SignWithOrgKey { get; set; } = true;
/// <summary>
/// Organization key ID for signing (null = use active key).
/// </summary>
public string? OrgKeyId { get; set; }
/// <summary>
/// Default retention period in months.
/// </summary>
public int RetentionMonths { get; set; } = 24;
/// <summary>
/// Whether to apply retention policy after rotation.
/// </summary>
public bool ApplyRetentionPolicy { get; set; } = true;
/// <summary>
/// Whether to include bundles in Offline Kit.
/// </summary>
public bool IncludeInOfflineKit { get; set; } = true;
}
/// <summary>
/// Bundle rotation cadence options.
/// </summary>
public enum BundleRotationCadence
{
/// <summary>Weekly rotation.</summary>
Weekly,
/// <summary>Monthly rotation (default).</summary>
Monthly,
/// <summary>Quarterly rotation.</summary>
Quarterly
}
/// <summary>
/// Result of a bundle rotation operation.
/// </summary>
public sealed record BundleRotationResult(
string BundleId,
DateTimeOffset PeriodStart,
DateTimeOffset PeriodEnd,
int AttestationCount,
bool Signed,
bool Stored,
TimeSpan Duration,
string? ErrorMessage = null)
{
/// <summary>
/// Whether the rotation was successful.
/// </summary>
public bool Success => ErrorMessage is null;
}
/// <summary>
/// Summary of a bundle rotation run.
/// </summary>
public sealed record BundleRotationSummary(
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
string TriggeredBy,
IReadOnlyList<BundleRotationResult> Bundles,
int RetiredBundleCount,
TimeSpan TotalDuration)
{
/// <summary>
/// Number of successful bundles created.
/// </summary>
public int SuccessCount => Bundles.Count(b => b.Success);
/// <summary>
/// Number of failed bundle creations.
/// </summary>
public int FailureCount => Bundles.Count(b => !b.Success);
/// <summary>
/// Total attestations bundled.
/// </summary>
public int TotalAttestations => Bundles.Where(b => b.Success).Sum(b => b.AttestationCount);
}
/// <summary>
/// Interface for the bundle rotation scheduler.
/// </summary>
public interface IBundleRotationScheduler
{
/// <summary>
/// Triggers bundle rotation for the current period.
/// </summary>
Task<BundleRotationSummary> RotateAsync(
string triggeredBy,
CancellationToken ct = default);
/// <summary>
/// Triggers bundle rotation for a specific period.
/// </summary>
Task<BundleRotationResult> RotatePeriodAsync(
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
string? tenantId,
CancellationToken ct = default);
/// <summary>
/// Applies retention policy to delete expired bundles.
/// </summary>
Task<int> ApplyRetentionPolicyAsync(CancellationToken ct = default);
}
/// <summary>
/// Interface for attestor bundle client operations.
/// </summary>
public interface IAttestorBundleClient
{
/// <summary>
/// Creates a bundle for the specified period.
/// </summary>
Task<BundleCreationResponse> CreateBundleAsync(
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
string? tenantId,
bool signWithOrgKey,
string? orgKeyId,
CancellationToken ct = default);
/// <summary>
/// Lists bundles created before a date (for retention).
/// </summary>
Task<IReadOnlyList<BundleInfo>> ListBundlesCreatedBeforeAsync(
DateTimeOffset before,
int limit,
CancellationToken ct = default);
/// <summary>
/// Deletes a bundle by ID.
/// </summary>
Task<bool> DeleteBundleAsync(
string bundleId,
CancellationToken ct = default);
/// <summary>
/// Gets list of tenant IDs with attestations in period.
/// </summary>
Task<IReadOnlyList<string>> GetTenantsWithAttestationsAsync(
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
CancellationToken ct = default);
}
/// <summary>
/// Response from bundle creation.
/// </summary>
public sealed record BundleCreationResponse(
string BundleId,
int AttestationCount,
bool HasOrgSignature,
DateTimeOffset CreatedAt);
/// <summary>
/// Bundle information for listing.
/// </summary>
public sealed record BundleInfo(
string BundleId,
DateTimeOffset CreatedAt,
DateTimeOffset PeriodStart,
DateTimeOffset PeriodEnd,
string? TenantId);
/// <summary>
/// Scheduled job that performs monthly attestation bundle rotation.
/// Per Sprint SPRINT_20251226_002_ATTESTOR_bundle_rotation.
/// </summary>
public sealed class BundleRotationJob : IBundleRotationScheduler
{
private readonly IAttestorBundleClient _bundleClient;
private readonly BundleRotationOptions _options;
private readonly ILogger<BundleRotationJob> _logger;
public BundleRotationJob(
IAttestorBundleClient bundleClient,
IOptions<BundleRotationOptions> options,
ILogger<BundleRotationJob> logger)
{
_bundleClient = bundleClient ?? throw new ArgumentNullException(nameof(bundleClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Checks if rotation is due and executes if needed.
/// Called periodically by the scheduler.
/// </summary>
public async Task<bool> CheckAndRotateAsync(CancellationToken ct = default)
{
if (!_options.Enabled)
{
_logger.LogDebug("Bundle rotation is disabled");
return false;
}
// Determine the period to bundle based on cadence
var (periodStart, periodEnd) = GetCurrentBundlePeriod();
_logger.LogInformation(
"Checking bundle rotation for period {Start} to {End}",
periodStart,
periodEnd);
try
{
await RotateAsync("scheduled", ct);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during bundle rotation");
return false;
}
}
/// <inheritdoc/>
public async Task<BundleRotationSummary> RotateAsync(
string triggeredBy,
CancellationToken ct = default)
{
var startedAt = DateTimeOffset.UtcNow;
var results = new List<BundleRotationResult>();
var sw = Stopwatch.StartNew();
_logger.LogInformation(
"Starting bundle rotation. TriggeredBy={Trigger}, Cadence={Cadence}",
triggeredBy,
_options.Cadence);
try
{
var (periodStart, periodEnd) = GetCurrentBundlePeriod();
// Get all tenants with attestations in this period
var tenants = await _bundleClient.GetTenantsWithAttestationsAsync(
periodStart,
periodEnd,
ct);
_logger.LogInformation(
"Found {Count} tenants with attestations for period {Start} to {End}",
tenants.Count,
periodStart,
periodEnd);
// Create a bundle for each tenant
foreach (var tenantId in tenants)
{
var result = await RotatePeriodAsync(periodStart, periodEnd, tenantId, ct);
results.Add(result);
}
// If no tenants found, create a global bundle
if (tenants.Count == 0)
{
var result = await RotatePeriodAsync(periodStart, periodEnd, null, ct);
if (result.AttestationCount > 0)
{
results.Add(result);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during bundle rotation");
}
// Apply retention policy if enabled
int retiredCount = 0;
if (_options.ApplyRetentionPolicy)
{
retiredCount = await ApplyRetentionPolicyAsync(ct);
}
sw.Stop();
var completedAt = DateTimeOffset.UtcNow;
var summary = new BundleRotationSummary(
StartedAt: startedAt,
CompletedAt: completedAt,
TriggeredBy: triggeredBy,
Bundles: results,
RetiredBundleCount: retiredCount,
TotalDuration: sw.Elapsed);
_logger.LogInformation(
"Bundle rotation completed. Bundles={Created}, Attestations={Total}, Retired={Retired}, Duration={Duration}ms",
summary.SuccessCount,
summary.TotalAttestations,
retiredCount,
sw.ElapsedMilliseconds);
return summary;
}
/// <inheritdoc/>
public async Task<BundleRotationResult> RotatePeriodAsync(
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
string? tenantId,
CancellationToken ct = default)
{
var sw = Stopwatch.StartNew();
try
{
_logger.LogDebug(
"Creating bundle for period {Start} to {End}, tenant={Tenant}",
periodStart,
periodEnd,
tenantId ?? "(all)");
var response = await _bundleClient.CreateBundleAsync(
periodStart,
periodEnd,
tenantId,
_options.SignWithOrgKey,
_options.OrgKeyId,
ct);
sw.Stop();
_logger.LogInformation(
"Created bundle {BundleId} with {Count} attestations for tenant {Tenant}",
response.BundleId,
response.AttestationCount,
tenantId ?? "(all)");
return new BundleRotationResult(
BundleId: response.BundleId,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
AttestationCount: response.AttestationCount,
Signed: response.HasOrgSignature,
Stored: true,
Duration: sw.Elapsed);
}
catch (Exception ex)
{
sw.Stop();
_logger.LogWarning(
ex,
"Failed to create bundle for period {Start} to {End}, tenant={Tenant}",
periodStart,
periodEnd,
tenantId ?? "(all)");
return new BundleRotationResult(
BundleId: string.Empty,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
AttestationCount: 0,
Signed: false,
Stored: false,
Duration: sw.Elapsed,
ErrorMessage: ex.Message);
}
}
/// <inheritdoc/>
public async Task<int> ApplyRetentionPolicyAsync(CancellationToken ct = default)
{
var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-_options.RetentionMonths);
_logger.LogInformation(
"Applying retention policy. Deleting bundles created before {Cutoff}",
cutoffDate);
try
{
var expiredBundles = await _bundleClient.ListBundlesCreatedBeforeAsync(
cutoffDate,
limit: 100,
ct);
int deletedCount = 0;
foreach (var bundle in expiredBundles)
{
var deleted = await _bundleClient.DeleteBundleAsync(bundle.BundleId, ct);
if (deleted)
{
deletedCount++;
_logger.LogDebug("Deleted expired bundle {BundleId}", bundle.BundleId);
}
}
_logger.LogInformation(
"Retention policy applied. Deleted {Count} expired bundles",
deletedCount);
return deletedCount;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error applying retention policy");
return 0;
}
}
private (DateTimeOffset start, DateTimeOffset end) GetCurrentBundlePeriod()
{
var now = DateTimeOffset.UtcNow;
return _options.Cadence switch
{
BundleRotationCadence.Weekly => GetWeeklyPeriod(now),
BundleRotationCadence.Quarterly => GetQuarterlyPeriod(now),
_ => GetMonthlyPeriod(now)
};
}
private static (DateTimeOffset start, DateTimeOffset end) GetMonthlyPeriod(DateTimeOffset reference)
{
// Previous month
var previousMonth = reference.AddMonths(-1);
var start = new DateTimeOffset(
previousMonth.Year,
previousMonth.Month,
1, 0, 0, 0,
TimeSpan.Zero);
var end = start.AddMonths(1).AddTicks(-1);
return (start, end);
}
private static (DateTimeOffset start, DateTimeOffset end) GetWeeklyPeriod(DateTimeOffset reference)
{
// Previous week (Monday to Sunday)
var daysToMonday = ((int)reference.DayOfWeek - 1 + 7) % 7;
var thisMonday = reference.Date.AddDays(-daysToMonday);
var lastMonday = thisMonday.AddDays(-7);
var start = new DateTimeOffset(lastMonday, TimeSpan.Zero);
var end = start.AddDays(7).AddTicks(-1);
return (start, end);
}
private static (DateTimeOffset start, DateTimeOffset end) GetQuarterlyPeriod(DateTimeOffset reference)
{
// Previous quarter
var currentQuarter = (reference.Month - 1) / 3;
var previousQuarter = currentQuarter == 0 ? 3 : currentQuarter - 1;
var year = currentQuarter == 0 ? reference.Year - 1 : reference.Year;
var startMonth = previousQuarter * 3 + 1;
var start = new DateTimeOffset(year, startMonth, 1, 0, 0, 0, TimeSpan.Zero);
var end = start.AddMonths(3).AddTicks(-1);
return (start, end);
}
}

View File

@@ -0,0 +1,511 @@
// -----------------------------------------------------------------------------
// GateEvaluationJob.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-03 - Create GateEvaluationJob in Scheduler
// Description: Scheduled job for asynchronous gate evaluation from Zastava webhooks
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scheduler.Worker.Policy;
/// <summary>
/// Configuration options for gate evaluation jobs.
/// </summary>
public sealed class GateEvaluationOptions
{
/// <summary>
/// Whether gate evaluation jobs are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Maximum concurrent evaluations.
/// </summary>
public int MaxConcurrency { get; set; } = 10;
/// <summary>
/// Timeout for individual gate evaluation in seconds.
/// </summary>
public int EvaluationTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Maximum retry attempts for failed evaluations.
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Base delay between retries in milliseconds.
/// </summary>
public int RetryDelayMs { get; set; } = 1000;
/// <summary>
/// Whether to notify on gate failures.
/// </summary>
public bool NotifyOnFailure { get; set; } = true;
/// <summary>
/// Policy Gateway base URL.
/// </summary>
public string PolicyGatewayUrl { get; set; } = "http://policy-gateway:8080";
}
/// <summary>
/// Status of a gate evaluation.
/// </summary>
public enum GateEvaluationStatus
{
/// <summary>Evaluation pending.</summary>
Pending,
/// <summary>Evaluation in progress.</summary>
InProgress,
/// <summary>Evaluation completed successfully.</summary>
Completed,
/// <summary>Evaluation failed.</summary>
Failed,
/// <summary>Evaluation cancelled.</summary>
Cancelled
}
/// <summary>
/// Gate verdict result.
/// </summary>
public enum GateVerdict
{
/// <summary>Gate passed.</summary>
Pass = 0,
/// <summary>Gate passed with warnings.</summary>
Warn = 1,
/// <summary>Gate failed/blocked.</summary>
Fail = 2
}
/// <summary>
/// Request for gate evaluation.
/// </summary>
public sealed record GateEvaluationRequest(
string JobId,
string ImageDigest,
string? BaselineRef,
string? PolicyId,
string? TenantId,
string RequestedBy,
DateTimeOffset RequestedAt,
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Result of a gate evaluation.
/// </summary>
public sealed record GateEvaluationResult(
string JobId,
string ImageDigest,
GateVerdict Verdict,
GateEvaluationStatus Status,
string? VerdictReason,
int? DeltaCount,
int? CriticalCount,
int? HighCount,
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
TimeSpan Duration,
string? ErrorMessage = null)
{
/// <summary>
/// Whether the evaluation was successful (completed without error).
/// </summary>
public bool Success => Status == GateEvaluationStatus.Completed && ErrorMessage is null;
}
/// <summary>
/// Summary of a gate evaluation batch run.
/// </summary>
public sealed record GateEvaluationBatchSummary(
DateTimeOffset StartedAt,
DateTimeOffset CompletedAt,
int TotalJobs,
int PassedCount,
int WarnCount,
int FailedCount,
int ErrorCount,
TimeSpan TotalDuration);
/// <summary>
/// Interface for policy gateway client operations.
/// </summary>
public interface IPolicyGatewayClient
{
/// <summary>
/// Evaluates a gate for the specified image.
/// </summary>
Task<GateEvaluationResponse> EvaluateGateAsync(
string imageDigest,
string? baselineRef,
string? policyId,
string? tenantId,
CancellationToken ct = default);
}
/// <summary>
/// Response from policy gateway gate evaluation.
/// </summary>
public sealed record GateEvaluationResponse(
GateVerdict Verdict,
string VerdictReason,
int DeltaCount,
int CriticalCount,
int HighCount,
int MediumCount,
int LowCount,
IReadOnlyList<GateFinding>? Findings);
/// <summary>
/// Individual gate finding.
/// </summary>
public sealed record GateFinding(
string Severity,
string Message,
string? VulnerabilityId,
string? Component);
/// <summary>
/// Interface for gate evaluation job scheduling.
/// </summary>
public interface IGateEvaluationScheduler
{
/// <summary>
/// Enqueues a gate evaluation job.
/// </summary>
Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken ct = default);
/// <summary>
/// Gets the status of a gate evaluation job.
/// </summary>
Task<GateEvaluationResult?> GetStatusAsync(string jobId, CancellationToken ct = default);
/// <summary>
/// Processes pending gate evaluation jobs.
/// </summary>
Task<GateEvaluationBatchSummary> ProcessPendingAsync(CancellationToken ct = default);
}
/// <summary>
/// Scheduled job that processes gate evaluations from Zastava webhooks.
/// Per Sprint SPRINT_20251226_001_BE_cicd_gate_integration.
/// </summary>
public sealed class GateEvaluationJob : IGateEvaluationScheduler
{
private readonly IPolicyGatewayClient _gatewayClient;
private readonly GateEvaluationOptions _options;
private readonly ILogger<GateEvaluationJob> _logger;
// In-memory queue for pending jobs (replace with persistent store in production)
private readonly Queue<GateEvaluationRequest> _pendingJobs = new();
private readonly Dictionary<string, GateEvaluationResult> _results = new();
private readonly object _lock = new();
public GateEvaluationJob(
IPolicyGatewayClient gatewayClient,
IOptions<GateEvaluationOptions> options,
ILogger<GateEvaluationJob> logger)
{
_gatewayClient = gatewayClient ?? throw new ArgumentNullException(nameof(gatewayClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
lock (_lock)
{
_pendingJobs.Enqueue(request);
_results[request.JobId] = new GateEvaluationResult(
JobId: request.JobId,
ImageDigest: request.ImageDigest,
Verdict: GateVerdict.Pass, // Will be updated
Status: GateEvaluationStatus.Pending,
VerdictReason: null,
DeltaCount: null,
CriticalCount: null,
HighCount: null,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: default,
Duration: TimeSpan.Zero);
}
_logger.LogInformation(
"Enqueued gate evaluation job {JobId} for image {Image}",
request.JobId,
request.ImageDigest);
return Task.FromResult(request.JobId);
}
/// <inheritdoc/>
public Task<GateEvaluationResult?> GetStatusAsync(string jobId, CancellationToken ct = default)
{
lock (_lock)
{
return Task.FromResult(_results.TryGetValue(jobId, out var result) ? result : null);
}
}
/// <inheritdoc/>
public async Task<GateEvaluationBatchSummary> ProcessPendingAsync(CancellationToken ct = default)
{
if (!_options.Enabled)
{
_logger.LogDebug("Gate evaluation jobs are disabled");
return new GateEvaluationBatchSummary(
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
0, 0, 0, 0, 0,
TimeSpan.Zero);
}
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
var results = new List<GateEvaluationResult>();
// Process jobs up to concurrency limit
var jobsToProcess = new List<GateEvaluationRequest>();
lock (_lock)
{
while (_pendingJobs.Count > 0 && jobsToProcess.Count < _options.MaxConcurrency)
{
jobsToProcess.Add(_pendingJobs.Dequeue());
}
}
_logger.LogInformation(
"Processing {Count} pending gate evaluation jobs",
jobsToProcess.Count);
// Process jobs concurrently
var tasks = jobsToProcess.Select(job => ProcessJobAsync(job, ct));
var completedResults = await Task.WhenAll(tasks);
results.AddRange(completedResults);
sw.Stop();
var completedAt = DateTimeOffset.UtcNow;
var summary = new GateEvaluationBatchSummary(
StartedAt: startedAt,
CompletedAt: completedAt,
TotalJobs: results.Count,
PassedCount: results.Count(r => r.Verdict == GateVerdict.Pass && r.Success),
WarnCount: results.Count(r => r.Verdict == GateVerdict.Warn && r.Success),
FailedCount: results.Count(r => r.Verdict == GateVerdict.Fail && r.Success),
ErrorCount: results.Count(r => !r.Success),
TotalDuration: sw.Elapsed);
_logger.LogInformation(
"Completed batch processing. Jobs={Total}, Pass={Pass}, Warn={Warn}, Fail={Fail}, Errors={Error}, Duration={Duration}ms",
summary.TotalJobs,
summary.PassedCount,
summary.WarnCount,
summary.FailedCount,
summary.ErrorCount,
sw.ElapsedMilliseconds);
return summary;
}
private async Task<GateEvaluationResult> ProcessJobAsync(
GateEvaluationRequest request,
CancellationToken ct)
{
var startedAt = DateTimeOffset.UtcNow;
var sw = Stopwatch.StartNew();
// Update status to in-progress
UpdateJobStatus(request.JobId, GateEvaluationStatus.InProgress);
try
{
_logger.LogDebug(
"Processing gate evaluation {JobId} for image {Image}",
request.JobId,
request.ImageDigest);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_options.EvaluationTimeoutSeconds));
var response = await ExecuteWithRetryAsync(
() => _gatewayClient.EvaluateGateAsync(
request.ImageDigest,
request.BaselineRef,
request.PolicyId,
request.TenantId,
timeoutCts.Token),
request.JobId,
timeoutCts.Token);
sw.Stop();
var completedAt = DateTimeOffset.UtcNow;
var result = new GateEvaluationResult(
JobId: request.JobId,
ImageDigest: request.ImageDigest,
Verdict: response.Verdict,
Status: GateEvaluationStatus.Completed,
VerdictReason: response.VerdictReason,
DeltaCount: response.DeltaCount,
CriticalCount: response.CriticalCount,
HighCount: response.HighCount,
StartedAt: startedAt,
CompletedAt: completedAt,
Duration: sw.Elapsed);
lock (_lock)
{
_results[request.JobId] = result;
}
_logger.LogInformation(
"Gate evaluation {JobId} completed. Verdict={Verdict}, Duration={Duration}ms",
request.JobId,
response.Verdict,
sw.ElapsedMilliseconds);
return result;
}
catch (OperationCanceledException)
{
sw.Stop();
return CreateErrorResult(request, startedAt, sw.Elapsed,
GateEvaluationStatus.Cancelled, "Evaluation cancelled or timed out");
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(
ex,
"Gate evaluation {JobId} failed for image {Image}",
request.JobId,
request.ImageDigest);
return CreateErrorResult(request, startedAt, sw.Elapsed,
GateEvaluationStatus.Failed, ex.Message);
}
}
private async Task<GateEvaluationResponse> ExecuteWithRetryAsync(
Func<Task<GateEvaluationResponse>> operation,
string jobId,
CancellationToken ct)
{
var attempt = 0;
var delay = TimeSpan.FromMilliseconds(_options.RetryDelayMs);
while (true)
{
attempt++;
try
{
return await operation();
}
catch (Exception ex) when (attempt < _options.MaxRetries && !ct.IsCancellationRequested)
{
_logger.LogWarning(
ex,
"Gate evaluation {JobId} attempt {Attempt} failed, retrying in {Delay}ms",
jobId,
attempt,
delay.TotalMilliseconds);
await Task.Delay(delay, ct);
delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 2, 30000));
}
}
}
private void UpdateJobStatus(string jobId, GateEvaluationStatus status)
{
lock (_lock)
{
if (_results.TryGetValue(jobId, out var current))
{
_results[jobId] = current with { Status = status };
}
}
}
private GateEvaluationResult CreateErrorResult(
GateEvaluationRequest request,
DateTimeOffset startedAt,
TimeSpan duration,
GateEvaluationStatus status,
string errorMessage)
{
var result = new GateEvaluationResult(
JobId: request.JobId,
ImageDigest: request.ImageDigest,
Verdict: GateVerdict.Fail,
Status: status,
VerdictReason: null,
DeltaCount: null,
CriticalCount: null,
HighCount: null,
StartedAt: startedAt,
CompletedAt: DateTimeOffset.UtcNow,
Duration: duration,
ErrorMessage: errorMessage);
lock (_lock)
{
_results[request.JobId] = result;
}
return result;
}
}
/// <summary>
/// HTTP implementation of policy gateway client.
/// </summary>
public sealed class HttpPolicyGatewayClient : IPolicyGatewayClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<HttpPolicyGatewayClient> _logger;
public HttpPolicyGatewayClient(
HttpClient httpClient,
ILogger<HttpPolicyGatewayClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<GateEvaluationResponse> EvaluateGateAsync(
string imageDigest,
string? baselineRef,
string? policyId,
string? tenantId,
CancellationToken ct = default)
{
var request = new
{
imageDigest,
baselineRef,
policyId,
tenantId
};
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/policy/gate/evaluate",
request,
ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<GateEvaluationResponse>(ct)
?? throw new InvalidOperationException("Empty response from policy gateway");
return result;
}
}