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,304 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RiskBudgetEndpoints.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-04 - Budget consumption API
|
||||
// Description: API endpoints for risk budget management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for risk budget management.
|
||||
/// </summary>
|
||||
internal static class RiskBudgetEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskBudgets(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/policy/budget")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Budgets");
|
||||
|
||||
group.MapGet("/status/{serviceId}", GetBudgetStatus)
|
||||
.WithName("GetRiskBudgetStatus")
|
||||
.WithSummary("Get current risk budget status for a service.")
|
||||
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/consume", ConsumeBudget)
|
||||
.WithName("ConsumeRiskBudget")
|
||||
.WithSummary("Record budget consumption after a release.")
|
||||
.Produces<BudgetConsumeResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/check", CheckRelease)
|
||||
.WithName("CheckRelease")
|
||||
.WithSummary("Check if a release can proceed given current budget.")
|
||||
.Produces<ReleaseCheckResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/history/{serviceId}", GetBudgetHistory)
|
||||
.WithName("GetBudgetHistory")
|
||||
.WithSummary("Get budget consumption history for a service.")
|
||||
.Produces<BudgetHistoryResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/adjust", AdjustBudget)
|
||||
.WithName("AdjustBudget")
|
||||
.WithSummary("Adjust budget allocation (earned capacity or manual override).")
|
||||
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/list", ListBudgets)
|
||||
.WithName("ListRiskBudgets")
|
||||
.WithSummary("List all risk budgets with optional filtering.")
|
||||
.Produces<BudgetListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<Ok<RiskBudgetStatusResponse>> GetBudgetStatus(
|
||||
string serviceId,
|
||||
[FromQuery] string? window,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var budget = await ledger.GetBudgetAsync(serviceId, window, ct);
|
||||
|
||||
return TypedResults.Ok(new RiskBudgetStatusResponse(
|
||||
budget.BudgetId,
|
||||
budget.ServiceId,
|
||||
budget.Tier.ToString(),
|
||||
budget.Window,
|
||||
budget.Allocated,
|
||||
budget.Consumed,
|
||||
budget.Remaining,
|
||||
budget.PercentageUsed,
|
||||
budget.Status.ToString().ToLowerInvariant(),
|
||||
budget.UpdatedAt));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BudgetConsumeResponse>, ProblemHttpResult>> ConsumeBudget(
|
||||
[FromBody] BudgetConsumeRequest request,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.RiskPoints <= 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"Risk points must be greater than 0.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var result = await ledger.ConsumeAsync(
|
||||
request.ServiceId,
|
||||
request.RiskPoints,
|
||||
request.ReleaseId,
|
||||
ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
result.Error ?? "Budget consumption failed.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new BudgetConsumeResponse(
|
||||
result.IsSuccess,
|
||||
result.Entry?.EntryId,
|
||||
result.Budget.Remaining,
|
||||
result.Budget.PercentageUsed,
|
||||
result.Budget.Status.ToString().ToLowerInvariant(),
|
||||
result.Error));
|
||||
}
|
||||
|
||||
private static async Task<Ok<ReleaseCheckResponse>> CheckRelease(
|
||||
[FromBody] ReleaseCheckRequest request,
|
||||
IBudgetConstraintEnforcer enforcer,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var input = new ReleaseCheckInput
|
||||
{
|
||||
ServiceId = request.ServiceId,
|
||||
Tier = Enum.Parse<ServiceTier>(request.Tier, ignoreCase: true),
|
||||
DiffCategory = Enum.Parse<DiffCategory>(request.DiffCategory, ignoreCase: true),
|
||||
Context = new OperationalContext
|
||||
{
|
||||
// Map request properties to actual OperationalContext properties
|
||||
InRestrictedWindow = request.ChangeFreeze || !request.DeploymentWindow,
|
||||
HasRecentIncident = request.IncidentActive,
|
||||
ErrorBudgetBelow50Percent = false, // Would come from budget ledger
|
||||
HighOnCallLoad = false // Would come from external system
|
||||
},
|
||||
Mitigations = new MitigationFactors
|
||||
{
|
||||
HasFeatureFlag = request.HasFeatureFlag,
|
||||
HasCanaryDeployment = request.CanaryPercentage > 0,
|
||||
HasBackwardCompatibleMigration = request.HasRollbackPlan,
|
||||
HasHighTestCoverage = false, // Would come from CI metadata
|
||||
HasPermissionBoundary = request.IsNonProduction
|
||||
}
|
||||
};
|
||||
|
||||
var result = await enforcer.CheckReleaseAsync(input, ct);
|
||||
|
||||
return TypedResults.Ok(new ReleaseCheckResponse(
|
||||
result.CanProceed,
|
||||
result.RequiredGate.ToString().ToLowerInvariant(),
|
||||
result.RiskPoints,
|
||||
result.BudgetBefore.Remaining,
|
||||
result.BudgetAfter.Remaining,
|
||||
result.BudgetBefore.Status.ToString().ToLowerInvariant(),
|
||||
result.BudgetAfter.Status.ToString().ToLowerInvariant(),
|
||||
result.BlockReason,
|
||||
result.Requirements,
|
||||
result.Recommendations));
|
||||
}
|
||||
|
||||
private static async Task<Ok<BudgetHistoryResponse>> GetBudgetHistory(
|
||||
string serviceId,
|
||||
[FromQuery] string? window,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entries = await ledger.GetHistoryAsync(serviceId, window, ct);
|
||||
|
||||
var items = entries.Select(e => new BudgetEntryDto(
|
||||
e.EntryId,
|
||||
e.ReleaseId,
|
||||
e.RiskPoints,
|
||||
e.ConsumedAt)).ToList();
|
||||
|
||||
return TypedResults.Ok(new BudgetHistoryResponse(
|
||||
serviceId,
|
||||
window ?? GetCurrentWindow(),
|
||||
items));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RiskBudgetStatusResponse>, ProblemHttpResult>> AdjustBudget(
|
||||
[FromBody] BudgetAdjustRequest request,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Adjustment == 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"Adjustment must be non-zero.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var budget = await ledger.AdjustAllocationAsync(
|
||||
request.ServiceId,
|
||||
request.Adjustment,
|
||||
request.Reason,
|
||||
ct);
|
||||
|
||||
return TypedResults.Ok(new RiskBudgetStatusResponse(
|
||||
budget.BudgetId,
|
||||
budget.ServiceId,
|
||||
budget.Tier.ToString(),
|
||||
budget.Window,
|
||||
budget.Allocated,
|
||||
budget.Consumed,
|
||||
budget.Remaining,
|
||||
budget.PercentageUsed,
|
||||
budget.Status.ToString().ToLowerInvariant(),
|
||||
budget.UpdatedAt));
|
||||
}
|
||||
|
||||
private static Ok<BudgetListResponse> ListBudgets(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? window,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
// This would query from PostgresBudgetStore.GetBudgetsByStatusAsync or GetBudgetsByWindowAsync
|
||||
// For now, return empty list - implementation would need to inject the store
|
||||
return TypedResults.Ok(new BudgetListResponse([], 0));
|
||||
}
|
||||
|
||||
private static string GetCurrentWindow() =>
|
||||
DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Response containing risk budget status.</summary>
|
||||
public sealed record RiskBudgetStatusResponse(
|
||||
string BudgetId,
|
||||
string ServiceId,
|
||||
string Tier,
|
||||
string Window,
|
||||
int Allocated,
|
||||
int Consumed,
|
||||
int Remaining,
|
||||
decimal PercentageUsed,
|
||||
string Status,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>Request to consume budget.</summary>
|
||||
public sealed record BudgetConsumeRequest(
|
||||
string ServiceId,
|
||||
int RiskPoints,
|
||||
string ReleaseId,
|
||||
string? Reason = null);
|
||||
|
||||
/// <summary>Response from budget consumption.</summary>
|
||||
public sealed record BudgetConsumeResponse(
|
||||
bool IsSuccess,
|
||||
string? EntryId,
|
||||
int Remaining,
|
||||
decimal PercentageUsed,
|
||||
string Status,
|
||||
string? Error);
|
||||
|
||||
/// <summary>Request to check if release can proceed.</summary>
|
||||
public sealed record ReleaseCheckRequest(
|
||||
string ServiceId,
|
||||
string Tier,
|
||||
string DiffCategory,
|
||||
bool ChangeFreeze = false,
|
||||
bool IncidentActive = false,
|
||||
bool DeploymentWindow = true,
|
||||
bool HasFeatureFlag = false,
|
||||
int CanaryPercentage = 0,
|
||||
bool HasRollbackPlan = false,
|
||||
bool IsNonProduction = false);
|
||||
|
||||
/// <summary>Response from release check.</summary>
|
||||
public sealed record ReleaseCheckResponse(
|
||||
bool CanProceed,
|
||||
string RequiredGate,
|
||||
int RiskPoints,
|
||||
int BudgetRemainingBefore,
|
||||
int BudgetRemainingAfter,
|
||||
string StatusBefore,
|
||||
string StatusAfter,
|
||||
string? BlockReason,
|
||||
IReadOnlyList<string> Requirements,
|
||||
IReadOnlyList<string> Recommendations);
|
||||
|
||||
/// <summary>Budget entry DTO.</summary>
|
||||
public sealed record BudgetEntryDto(
|
||||
string EntryId,
|
||||
string ReleaseId,
|
||||
int RiskPoints,
|
||||
DateTimeOffset ConsumedAt);
|
||||
|
||||
/// <summary>Response containing budget history.</summary>
|
||||
public sealed record BudgetHistoryResponse(
|
||||
string ServiceId,
|
||||
string Window,
|
||||
IReadOnlyList<BudgetEntryDto> Entries);
|
||||
|
||||
/// <summary>Request to adjust budget.</summary>
|
||||
public sealed record BudgetAdjustRequest(
|
||||
string ServiceId,
|
||||
int Adjustment,
|
||||
string Reason);
|
||||
|
||||
/// <summary>Response containing budget list.</summary>
|
||||
public sealed record BudgetListResponse(
|
||||
IReadOnlyList<RiskBudgetStatusResponse> Budgets,
|
||||
int TotalCount);
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user