- 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.
305 lines
10 KiB
C#
305 lines
10 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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
|