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:
243
src/Policy/StellaOps.Policy.Gateway/Contracts/GateContracts.cs
Normal file
243
src/Policy/StellaOps.Policy.Gateway/Contracts/GateContracts.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-01 - Create gate/evaluate endpoint contracts
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate a CI/CD gate for an image.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The image digest to evaluate (e.g., sha256:abc123...).
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The container repository name.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image tag, if any.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference for comparison.
|
||||
/// Can be a snapshot ID, image digest, or strategy name (e.g., "last-approved", "production").
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional policy ID to use for evaluation.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow override of blocking gates.
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for override (required if AllowOverride is true and gate would block).
|
||||
/// </summary>
|
||||
public string? OverrideJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the request (e.g., "cli", "api", "webhook").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD context identifier (e.g., "github-actions", "gitlab-ci").
|
||||
/// </summary>
|
||||
public string? CiContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context for the gate evaluation.
|
||||
/// </summary>
|
||||
public GateEvaluationContext? Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional context for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Git branch name.
|
||||
/// </summary>
|
||||
public string? Branch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Git commit SHA.
|
||||
/// </summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD pipeline ID or job ID.
|
||||
/// </summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment being deployed to (e.g., "production", "staging").
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor triggering the gate (e.g., user or service identity).
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique decision ID for audit and tracking.
|
||||
/// </summary>
|
||||
public required string DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The gate decision status.
|
||||
/// </summary>
|
||||
public required GateStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested CI exit code.
|
||||
/// 0 = Pass, 1 = Warn (configurable pass-through), 2 = Fail/Block
|
||||
/// </summary>
|
||||
public required int ExitCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image digest that was evaluated.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference used for comparison.
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary message for the decision.
|
||||
/// </summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory or suggestion for the developer.
|
||||
/// </summary>
|
||||
public string? Advisory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of gate results.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GateResultDto>? Gates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate that caused the block (if blocked).
|
||||
/// </summary>
|
||||
public string? BlockedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed reason for the block.
|
||||
/// </summary>
|
||||
public string? BlockReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggestion for resolving the block.
|
||||
/// </summary>
|
||||
public string? Suggestion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an override was applied.
|
||||
/// </summary>
|
||||
public bool OverrideApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Delta summary if available.
|
||||
/// </summary>
|
||||
public DeltaSummaryDto? DeltaSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record GateResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate name/ID.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate result type.
|
||||
/// </summary>
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the result.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional note.
|
||||
/// </summary>
|
||||
public string? Note { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression that was evaluated.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate evaluation status.
|
||||
/// </summary>
|
||||
public enum GateStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate passed - proceed with deployment.
|
||||
/// </summary>
|
||||
Pass = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Gate produced warnings - proceed with caution.
|
||||
/// </summary>
|
||||
Warn = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Gate blocked - do not proceed.
|
||||
/// </summary>
|
||||
Fail = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI exit codes for gate evaluation.
|
||||
/// </summary>
|
||||
public static class GateExitCodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate passed - proceed with deployment.
|
||||
/// </summary>
|
||||
public const int Pass = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gate produced warnings - configurable pass-through.
|
||||
/// </summary>
|
||||
public const int Warn = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gate blocked - do not proceed.
|
||||
/// </summary>
|
||||
public const int Fail = 2;
|
||||
}
|
||||
398
src/Policy/StellaOps.Policy.Gateway/Endpoints/GateEndpoints.cs
Normal file
398
src/Policy/StellaOps.Policy.Gateway/Endpoints/GateEndpoints.cs
Normal file
@@ -0,0 +1,398 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Audit;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Gate API endpoints for CI/CD release gating.
|
||||
/// </summary>
|
||||
public static class GateEndpoints
|
||||
{
|
||||
private const string DeltaCachePrefix = "delta:";
|
||||
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maps gate endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapGateEndpoints(this WebApplication app)
|
||||
{
|
||||
var gates = app.MapGroup("/api/v1/policy/gate")
|
||||
.WithTags("Gates");
|
||||
|
||||
// POST /api/v1/policy/gate/evaluate - Evaluate gate for image
|
||||
gates.MapPost("/evaluate", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
GateEvaluateRequest request,
|
||||
IDriftGateEvaluator gateEvaluator,
|
||||
IDeltaComputer deltaComputer,
|
||||
IBaselineSelector baselineSelector,
|
||||
IGateBypassAuditor bypassAuditor,
|
||||
IMemoryCache cache,
|
||||
ILogger<DriftGateEvaluator> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ImageDigest))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Image digest is required",
|
||||
Status = 400,
|
||||
Detail = "Provide a valid container image digest (e.g., sha256:abc123...)"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Resolve baseline snapshot
|
||||
var baselineResult = await ResolveBaselineAsync(
|
||||
request.ImageDigest,
|
||||
request.BaselineRef,
|
||||
baselineSelector,
|
||||
cancellationToken);
|
||||
|
||||
if (!baselineResult.IsFound)
|
||||
{
|
||||
// If no baseline, allow with a note (first build scenario)
|
||||
logger.LogInformation(
|
||||
"No baseline found for {ImageDigest}, allowing first build",
|
||||
request.ImageDigest);
|
||||
|
||||
return Results.Ok(new GateEvaluateResponse
|
||||
{
|
||||
DecisionId = $"gate:{DateTimeOffset.UtcNow:yyyyMMddHHmmss}:{Guid.NewGuid():N}",
|
||||
Status = GateStatus.Pass,
|
||||
ExitCode = GateExitCodes.Pass,
|
||||
ImageDigest = request.ImageDigest,
|
||||
BaselineRef = request.BaselineRef,
|
||||
DecidedAt = DateTimeOffset.UtcNow,
|
||||
Summary = "First build - no baseline for comparison",
|
||||
Advisory = "This appears to be a first build. Future builds will be compared against this baseline."
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Compute delta between baseline and current
|
||||
var delta = await deltaComputer.ComputeDeltaAsync(
|
||||
baselineResult.Snapshot!.SnapshotId,
|
||||
request.ImageDigest, // Use image digest as target snapshot ID
|
||||
new ArtifactRef(request.ImageDigest, null, null),
|
||||
cancellationToken);
|
||||
|
||||
// Cache the delta for audit
|
||||
cache.Set(
|
||||
DeltaCachePrefix + delta.DeltaId,
|
||||
delta,
|
||||
DeltaCacheDuration);
|
||||
|
||||
// Step 3: Build gate context from delta
|
||||
var gateContext = BuildGateContext(delta);
|
||||
|
||||
// Step 4: Evaluate gates
|
||||
var gateRequest = new DriftGateRequest
|
||||
{
|
||||
Context = gateContext,
|
||||
PolicyId = request.PolicyId,
|
||||
AllowOverride = request.AllowOverride,
|
||||
OverrideJustification = request.OverrideJustification
|
||||
};
|
||||
|
||||
var gateDecision = await gateEvaluator.EvaluateAsync(gateRequest, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Gate evaluated for {ImageDigest}: decision={Decision}, decisionId={DecisionId}",
|
||||
request.ImageDigest,
|
||||
gateDecision.Decision,
|
||||
gateDecision.DecisionId);
|
||||
|
||||
// Step 5: Record bypass audit if override was applied
|
||||
if (request.AllowOverride &&
|
||||
!string.IsNullOrWhiteSpace(request.OverrideJustification) &&
|
||||
gateDecision.Decision != DriftGateDecisionType.Allow)
|
||||
{
|
||||
var actor = httpContext.User.Identity?.Name ?? "unknown";
|
||||
var actorSubject = httpContext.User.Claims
|
||||
.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
var actorEmail = httpContext.User.Claims
|
||||
.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
var actorIp = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var bypassContext = new GateBypassContext
|
||||
{
|
||||
Decision = gateDecision,
|
||||
Request = gateRequest,
|
||||
ImageDigest = request.ImageDigest,
|
||||
Repository = request.Repository,
|
||||
Tag = request.Tag,
|
||||
BaselineRef = request.BaselineRef,
|
||||
Actor = actor,
|
||||
ActorSubject = actorSubject,
|
||||
ActorEmail = actorEmail,
|
||||
ActorIpAddress = actorIp,
|
||||
Justification = request.OverrideJustification,
|
||||
Source = request.Source ?? "api",
|
||||
CiContext = request.CiContext
|
||||
};
|
||||
|
||||
await bypassAuditor.RecordBypassAsync(bypassContext, cancellationToken);
|
||||
}
|
||||
|
||||
// Step 6: Build response
|
||||
var response = BuildResponse(request, gateDecision, delta);
|
||||
|
||||
// Return appropriate status code based on decision
|
||||
return gateDecision.Decision switch
|
||||
{
|
||||
DriftGateDecisionType.Block => Results.Json(response, statusCode: 403),
|
||||
DriftGateDecisionType.Warn => Results.Ok(response),
|
||||
_ => Results.Ok(response)
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Resource not found",
|
||||
Status = 404,
|
||||
Detail = ex.Message
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Gate evaluation failed for {ImageDigest}", request.ImageDigest);
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Gate evaluation failed",
|
||||
Status = 500,
|
||||
Detail = "An error occurred during gate evaluation"
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
|
||||
.WithName("EvaluateGate")
|
||||
.WithDescription("Evaluate CI/CD gate for an image digest and baseline reference");
|
||||
|
||||
// GET /api/v1/policy/gate/decision/{decisionId} - Get a previous decision
|
||||
gates.MapGet("/decision/{decisionId}", async Task<IResult>(
|
||||
string decisionId,
|
||||
IMemoryCache cache,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(decisionId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Decision ID required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
// Try to retrieve cached decision
|
||||
var cacheKey = $"gate:decision:{decisionId}";
|
||||
if (!cache.TryGetValue(cacheKey, out GateEvaluateResponse? response) || response is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Decision not found",
|
||||
Status = 404,
|
||||
Detail = $"No gate decision found with ID: {decisionId}"
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
|
||||
.WithName("GetGateDecision")
|
||||
.WithDescription("Retrieve a previous gate evaluation decision by ID");
|
||||
|
||||
// GET /api/v1/policy/gate/health - Health check for gate service
|
||||
gates.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }))
|
||||
.WithName("GateHealth")
|
||||
.WithDescription("Health check for the gate evaluation service");
|
||||
}
|
||||
|
||||
private static async Task<BaselineSelectionResult> ResolveBaselineAsync(
|
||||
string imageDigest,
|
||||
string? baselineRef,
|
||||
IBaselineSelector baselineSelector,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(baselineRef))
|
||||
{
|
||||
// Check if it's an explicit snapshot ID
|
||||
if (baselineRef.StartsWith("snapshot:") || Guid.TryParse(baselineRef, out _))
|
||||
{
|
||||
return await baselineSelector.SelectExplicitAsync(
|
||||
baselineRef.Replace("snapshot:", ""),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Parse as strategy name
|
||||
var strategy = baselineRef.ToLowerInvariant() switch
|
||||
{
|
||||
"last-approved" or "lastapproved" => BaselineSelectionStrategy.LastApproved,
|
||||
"previous-build" or "previousbuild" => BaselineSelectionStrategy.PreviousBuild,
|
||||
"production" or "production-deployed" => BaselineSelectionStrategy.ProductionDeployed,
|
||||
"branch-base" or "branchbase" => BaselineSelectionStrategy.BranchBase,
|
||||
_ => BaselineSelectionStrategy.LastApproved
|
||||
};
|
||||
|
||||
return await baselineSelector.SelectBaselineAsync(imageDigest, strategy, cancellationToken);
|
||||
}
|
||||
|
||||
// Default to LastApproved strategy
|
||||
return await baselineSelector.SelectBaselineAsync(
|
||||
imageDigest,
|
||||
BaselineSelectionStrategy.LastApproved,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static DriftGateContext BuildGateContext(SecurityStateDelta delta)
|
||||
{
|
||||
var newlyReachableVexStatuses = new List<string>();
|
||||
var newlyReachableSinkIds = new List<string>();
|
||||
var newlyUnreachableSinkIds = new List<string>();
|
||||
double? maxCvss = null;
|
||||
double? maxEpss = null;
|
||||
var hasKev = false;
|
||||
var deltaReachable = 0;
|
||||
var deltaUnreachable = 0;
|
||||
|
||||
// Extract metrics from delta drivers
|
||||
foreach (var driver in delta.Drivers)
|
||||
{
|
||||
if (driver.Type is "new-reachable-cve" or "new-reachable-path")
|
||||
{
|
||||
deltaReachable++;
|
||||
if (driver.CveId is not null)
|
||||
{
|
||||
newlyReachableSinkIds.Add(driver.CveId);
|
||||
}
|
||||
// Extract optional details from the Details dictionary
|
||||
if (driver.Details.TryGetValue("vex_status", out var vexStatus))
|
||||
{
|
||||
newlyReachableVexStatuses.Add(vexStatus);
|
||||
}
|
||||
if (driver.Details.TryGetValue("cvss", out var cvssStr) &&
|
||||
double.TryParse(cvssStr, out var cvss))
|
||||
{
|
||||
if (!maxCvss.HasValue || cvss > maxCvss.Value)
|
||||
{
|
||||
maxCvss = cvss;
|
||||
}
|
||||
}
|
||||
if (driver.Details.TryGetValue("epss", out var epssStr) &&
|
||||
double.TryParse(epssStr, out var epss))
|
||||
{
|
||||
if (!maxEpss.HasValue || epss > maxEpss.Value)
|
||||
{
|
||||
maxEpss = epss;
|
||||
}
|
||||
}
|
||||
if (driver.Details.TryGetValue("is_kev", out var kevStr) &&
|
||||
bool.TryParse(kevStr, out var isKev) && isKev)
|
||||
{
|
||||
hasKev = true;
|
||||
}
|
||||
}
|
||||
else if (driver.Type is "removed-reachable-cve" or "removed-reachable-path")
|
||||
{
|
||||
deltaUnreachable++;
|
||||
if (driver.CveId is not null)
|
||||
{
|
||||
newlyUnreachableSinkIds.Add(driver.CveId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new DriftGateContext
|
||||
{
|
||||
DeltaReachable = deltaReachable,
|
||||
DeltaUnreachable = deltaUnreachable,
|
||||
HasKevReachable = hasKev,
|
||||
NewlyReachableVexStatuses = newlyReachableVexStatuses,
|
||||
MaxCvss = maxCvss,
|
||||
MaxEpss = maxEpss,
|
||||
BaseScanId = delta.BaselineSnapshotId,
|
||||
HeadScanId = delta.TargetSnapshotId,
|
||||
NewlyReachableSinkIds = newlyReachableSinkIds,
|
||||
NewlyUnreachableSinkIds = newlyUnreachableSinkIds
|
||||
};
|
||||
}
|
||||
|
||||
private static GateEvaluateResponse BuildResponse(
|
||||
GateEvaluateRequest request,
|
||||
DriftGateDecision decision,
|
||||
SecurityStateDelta delta)
|
||||
{
|
||||
var status = decision.Decision switch
|
||||
{
|
||||
DriftGateDecisionType.Allow => GateStatus.Pass,
|
||||
DriftGateDecisionType.Warn => GateStatus.Warn,
|
||||
DriftGateDecisionType.Block => GateStatus.Fail,
|
||||
_ => GateStatus.Pass
|
||||
};
|
||||
|
||||
var exitCode = decision.Decision switch
|
||||
{
|
||||
DriftGateDecisionType.Allow => GateExitCodes.Pass,
|
||||
DriftGateDecisionType.Warn => GateExitCodes.Warn,
|
||||
DriftGateDecisionType.Block => GateExitCodes.Fail,
|
||||
_ => GateExitCodes.Pass
|
||||
};
|
||||
|
||||
return new GateEvaluateResponse
|
||||
{
|
||||
DecisionId = decision.DecisionId,
|
||||
Status = status,
|
||||
ExitCode = exitCode,
|
||||
ImageDigest = request.ImageDigest,
|
||||
BaselineRef = request.BaselineRef,
|
||||
DecidedAt = decision.DecidedAt,
|
||||
Summary = BuildSummary(decision),
|
||||
Advisory = decision.Advisory,
|
||||
Gates = decision.Gates.Select(g => new GateResultDto
|
||||
{
|
||||
Name = g.Name,
|
||||
Result = g.Result.ToString(),
|
||||
Reason = g.Reason,
|
||||
Note = g.Note,
|
||||
Condition = g.Condition
|
||||
}).ToList(),
|
||||
BlockedBy = decision.BlockedBy,
|
||||
BlockReason = decision.BlockReason,
|
||||
Suggestion = decision.Suggestion,
|
||||
OverrideApplied = request.AllowOverride && decision.Decision == DriftGateDecisionType.Warn && !string.IsNullOrWhiteSpace(request.OverrideJustification),
|
||||
DeltaSummary = DeltaSummaryDto.FromModel(delta.Summary)
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildSummary(DriftGateDecision decision)
|
||||
{
|
||||
return decision.Decision switch
|
||||
{
|
||||
DriftGateDecisionType.Allow => "Gate passed - release may proceed",
|
||||
DriftGateDecisionType.Warn => $"Gate passed with warnings - review recommended{(decision.Advisory is not null ? $": {decision.Advisory}" : "")}",
|
||||
DriftGateDecisionType.Block => $"Gate blocked - {decision.BlockReason ?? "release cannot proceed"}",
|
||||
_ => "Gate evaluation complete"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RegistryWebhookEndpoints.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-02 - Webhook handler for registry image-push events
|
||||
// Description: Receives webhooks from container registries and triggers gate evaluation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for receiving registry webhook events and triggering gate evaluations.
|
||||
/// </summary>
|
||||
internal static class RegistryWebhookEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRegistryWebhooks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/webhooks/registry")
|
||||
.WithTags("Registry Webhooks");
|
||||
|
||||
group.MapPost("/docker", HandleDockerRegistryWebhook)
|
||||
.WithName("DockerRegistryWebhook")
|
||||
.WithSummary("Handle Docker Registry v2 webhook events")
|
||||
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/harbor", HandleHarborWebhook)
|
||||
.WithName("HarborWebhook")
|
||||
.WithSummary("Handle Harbor registry webhook events")
|
||||
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/generic", HandleGenericWebhook)
|
||||
.WithName("GenericRegistryWebhook")
|
||||
.WithSummary("Handle generic registry webhook events with image digest")
|
||||
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles Docker Registry v2 notification webhooks.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
|
||||
[FromBody] DockerRegistryNotification notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (notification.Events is null || notification.Events.Count == 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"No events in notification",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var jobs = new List<string>();
|
||||
|
||||
foreach (var evt in notification.Events.Where(e => e.Action == "push"))
|
||||
{
|
||||
if (string.IsNullOrEmpty(evt.Target?.Digest))
|
||||
{
|
||||
logger.LogWarning("Skipping push event without digest: {Repository}", evt.Target?.Repository);
|
||||
continue;
|
||||
}
|
||||
|
||||
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
|
||||
{
|
||||
ImageDigest = evt.Target.Digest,
|
||||
Repository = evt.Target.Repository ?? "unknown",
|
||||
Tag = evt.Target.Tag,
|
||||
RegistryUrl = evt.Request?.Host,
|
||||
Source = "docker-registry",
|
||||
Timestamp = evt.Timestamp ?? DateTimeOffset.UtcNow
|
||||
}, ct);
|
||||
|
||||
jobs.Add(jobId);
|
||||
|
||||
logger.LogInformation(
|
||||
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
|
||||
evt.Target.Repository,
|
||||
evt.Target.Digest,
|
||||
jobId);
|
||||
}
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
|
||||
new WebhookAcceptedResponse(jobs.Count, jobs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles Harbor registry webhook events.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
|
||||
[FromBody] HarborWebhookEvent notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Only process push events
|
||||
if (notification.Type != "PUSH_ARTIFACT" && notification.Type != "pushImage")
|
||||
{
|
||||
logger.LogDebug("Ignoring Harbor event type: {Type}", notification.Type);
|
||||
return TypedResults.Accepted(
|
||||
"/api/v1/policy/gate/jobs",
|
||||
new WebhookAcceptedResponse(0, []));
|
||||
}
|
||||
|
||||
if (notification.EventData?.Resources is null || notification.EventData.Resources.Count == 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"No resources in Harbor notification",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var jobs = new List<string>();
|
||||
|
||||
foreach (var resource in notification.EventData.Resources)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resource.Digest))
|
||||
{
|
||||
logger.LogWarning("Skipping resource without digest: {ResourceUrl}", resource.ResourceUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
|
||||
{
|
||||
ImageDigest = resource.Digest,
|
||||
Repository = notification.EventData.Repository?.Name ?? "unknown",
|
||||
Tag = resource.Tag,
|
||||
RegistryUrl = notification.EventData.Repository?.RepoFullName,
|
||||
Source = "harbor",
|
||||
Timestamp = notification.OccurAt ?? DateTimeOffset.UtcNow
|
||||
}, ct);
|
||||
|
||||
jobs.Add(jobId);
|
||||
|
||||
logger.LogInformation(
|
||||
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
|
||||
notification.EventData.Repository?.Name,
|
||||
resource.Digest,
|
||||
jobId);
|
||||
}
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
|
||||
new WebhookAcceptedResponse(jobs.Count, jobs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles generic webhook events with image digest.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
|
||||
[FromBody] GenericRegistryWebhook notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(notification.ImageDigest))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"imageDigest is required",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
|
||||
{
|
||||
ImageDigest = notification.ImageDigest,
|
||||
Repository = notification.Repository ?? "unknown",
|
||||
Tag = notification.Tag,
|
||||
RegistryUrl = notification.RegistryUrl,
|
||||
BaselineRef = notification.BaselineRef,
|
||||
Source = notification.Source ?? "generic",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}, ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
|
||||
notification.Repository,
|
||||
notification.ImageDigest,
|
||||
jobId);
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/policy/gate/jobs/{jobId}",
|
||||
new WebhookAcceptedResponse(1, [jobId]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker type for endpoint logging.
|
||||
/// </summary>
|
||||
internal sealed class RegistryWebhookEndpointMarker;
|
||||
|
||||
// ============================================================================
|
||||
// Docker Registry Notification Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry v2 notification envelope.
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryNotification
|
||||
{
|
||||
[JsonPropertyName("events")]
|
||||
public List<DockerRegistryEvent>? Events { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry v2 event.
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryEvent
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; init; }
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public DockerRegistryTarget? Target { get; init; }
|
||||
|
||||
[JsonPropertyName("request")]
|
||||
public DockerRegistryRequest? Request { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry event target (the image).
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryTarget
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long? Size { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry request metadata.
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryRequest
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; init; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Harbor Webhook Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Harbor webhook event.
|
||||
/// </summary>
|
||||
public sealed record HarborWebhookEvent
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("occur_at")]
|
||||
public DateTimeOffset? OccurAt { get; init; }
|
||||
|
||||
[JsonPropertyName("operator")]
|
||||
public string? Operator { get; init; }
|
||||
|
||||
[JsonPropertyName("event_data")]
|
||||
public HarborEventData? EventData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harbor event data.
|
||||
/// </summary>
|
||||
public sealed record HarborEventData
|
||||
{
|
||||
[JsonPropertyName("resources")]
|
||||
public List<HarborResource>? Resources { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public HarborRepository? Repository { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harbor resource (artifact).
|
||||
/// </summary>
|
||||
public sealed record HarborResource
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("resource_url")]
|
||||
public string? ResourceUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harbor repository info.
|
||||
/// </summary>
|
||||
public sealed record HarborRepository
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("repo_full_name")]
|
||||
public string? RepoFullName { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Generic Webhook Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Generic registry webhook payload.
|
||||
/// </summary>
|
||||
public sealed record GenericRegistryWebhook
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("registryUrl")]
|
||||
public string? RegistryUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineRef")]
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Response indicating webhook was accepted.
|
||||
/// </summary>
|
||||
public sealed record WebhookAcceptedResponse(
|
||||
int JobsQueued,
|
||||
IReadOnlyList<string> JobIds);
|
||||
|
||||
// ============================================================================
|
||||
// Gate Evaluation Queue Interface
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Interface for queuing gate evaluation jobs.
|
||||
/// </summary>
|
||||
public interface IGateEvaluationQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues a gate evaluation request.
|
||||
/// </summary>
|
||||
/// <param name="request">The evaluation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The job ID for tracking.</returns>
|
||||
Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate a gate for an image.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationRequest
|
||||
{
|
||||
public required string ImageDigest { get; init; }
|
||||
public required string Repository { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? RegistryUrl { get; init; }
|
||||
public string? BaselineRef { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Polly;
|
||||
@@ -127,6 +128,21 @@ builder.Services.AddScoped<IBaselineSelector, BaselineSelector>();
|
||||
builder.Services.AddScoped<ISnapshotStore, InMemorySnapshotStore>();
|
||||
builder.Services.AddScoped<StellaOps.Policy.Deltas.ISnapshotService, DeltaSnapshotServiceAdapter>();
|
||||
|
||||
// Gate services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
builder.Services.Configure<DriftGateOptions>(
|
||||
builder.Configuration.GetSection(DriftGateOptions.SectionName));
|
||||
builder.Services.AddScoped<IDriftGateEvaluator, DriftGateEvaluator>();
|
||||
builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
|
||||
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
|
||||
builder.Services.AddHostedService<GateEvaluationWorker>();
|
||||
|
||||
// Gate bypass audit services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration, Task: CICD-GATE-06)
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
|
||||
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOptions>();
|
||||
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
|
||||
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
@@ -497,6 +513,12 @@ app.MapExceptionEndpoints();
|
||||
// Delta management endpoints
|
||||
app.MapDeltasEndpoints();
|
||||
|
||||
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapGateEndpoints();
|
||||
|
||||
// Registry webhook endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapRegistryWebhooks();
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryGateEvaluationQueue.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-02 - Gate evaluation queue implementation
|
||||
// Description: In-memory queue for gate evaluation jobs with background processing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the gate evaluation queue.
|
||||
/// Uses System.Threading.Channels for async producer-consumer pattern.
|
||||
/// </summary>
|
||||
public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
|
||||
{
|
||||
private readonly Channel<GateEvaluationJob> _channel;
|
||||
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
|
||||
|
||||
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_logger = logger;
|
||||
|
||||
// Bounded channel to prevent unbounded memory growth
|
||||
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var jobId = GenerateJobId();
|
||||
var job = new GateEvaluationJob
|
||||
{
|
||||
JobId = jobId,
|
||||
Request = request,
|
||||
QueuedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Enqueued gate evaluation job {JobId} for {Repository}@{Digest}",
|
||||
jobId,
|
||||
request.Repository,
|
||||
request.ImageDigest);
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the channel reader for consuming jobs.
|
||||
/// </summary>
|
||||
public ChannelReader<GateEvaluationJob> Reader => _channel.Reader;
|
||||
|
||||
private static string GenerateJobId()
|
||||
{
|
||||
// Format: gate-{timestamp}-{random}
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var random = Guid.NewGuid().ToString("N")[..8];
|
||||
return $"gate-{timestamp}-{random}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A gate evaluation job in the queue.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationJob
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required GateEvaluationRequest Request { get; init; }
|
||||
public required DateTimeOffset QueuedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that processes gate evaluation jobs from the queue.
|
||||
/// Orchestrates: image analysis → drift delta computation → gate evaluation.
|
||||
/// </summary>
|
||||
public sealed class GateEvaluationWorker : BackgroundService
|
||||
{
|
||||
private readonly InMemoryGateEvaluationQueue _queue;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<GateEvaluationWorker> _logger;
|
||||
|
||||
public GateEvaluationWorker(
|
||||
InMemoryGateEvaluationQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<GateEvaluationWorker> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(queue);
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_queue = queue;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Gate evaluation worker starting");
|
||||
|
||||
await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessJobAsync(job, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error processing gate evaluation job {JobId} for {Repository}@{Digest}",
|
||||
job.JobId,
|
||||
job.Request.Repository,
|
||||
job.Request.ImageDigest);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Gate evaluation worker stopping");
|
||||
}
|
||||
|
||||
private async Task ProcessJobAsync(GateEvaluationJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing gate evaluation job {JobId} for {Repository}@{Digest}",
|
||||
job.JobId,
|
||||
job.Request.Repository,
|
||||
job.Request.ImageDigest);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var evaluator = scope.ServiceProvider.GetRequiredService<IDriftGateEvaluator>();
|
||||
|
||||
// Build a minimal context for the gate evaluation.
|
||||
// In production, this would involve:
|
||||
// 1. Fetching or triggering a scan of the image
|
||||
// 2. Computing the reachability delta against the baseline
|
||||
// 3. Building the DriftGateContext with actual metrics
|
||||
//
|
||||
// For now, we create a placeholder context that represents "no drift detected"
|
||||
// which allows the gate to pass. The full implementation requires Scanner integration.
|
||||
var driftContext = new DriftGateContext
|
||||
{
|
||||
DeltaReachable = 0,
|
||||
DeltaUnreachable = 0,
|
||||
HasKevReachable = false,
|
||||
BaseScanId = job.Request.BaselineRef,
|
||||
HeadScanId = job.Request.ImageDigest
|
||||
};
|
||||
|
||||
var evalRequest = new DriftGateRequest
|
||||
{
|
||||
Context = driftContext,
|
||||
PolicyId = null, // Use default policy
|
||||
AllowOverride = false
|
||||
};
|
||||
|
||||
var result = await evaluator.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gate evaluation {JobId} completed: Decision={Decision}, GateCount={GateCount}",
|
||||
job.JobId,
|
||||
result.Decision,
|
||||
result.Gates.Length);
|
||||
|
||||
// TODO: Store result and notify via webhook/event
|
||||
// This will be implemented in CICD-GATE-03
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user