Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/Endpoints/RiskBudgetEndpoints.cs
StellaOps Bot 907783f625 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.
2025-12-26 15:17:58 +02:00

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