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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user