// ----------------------------------------------------------------------------- // 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; /// /// API endpoints for risk budget management. /// 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(StatusCodes.Status200OK); group.MapPost("/consume", ConsumeBudget) .WithName("ConsumeRiskBudget") .WithSummary("Record budget consumption after a release.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); group.MapPost("/check", CheckRelease) .WithName("CheckRelease") .WithSummary("Check if a release can proceed given current budget.") .Produces(StatusCodes.Status200OK); group.MapGet("/history/{serviceId}", GetBudgetHistory) .WithName("GetBudgetHistory") .WithSummary("Get budget consumption history for a service.") .Produces(StatusCodes.Status200OK); group.MapPost("/adjust", AdjustBudget) .WithName("AdjustBudget") .WithSummary("Adjust budget allocation (earned capacity or manual override).") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest); group.MapGet("/list", ListBudgets) .WithName("ListRiskBudgets") .WithSummary("List all risk budgets with optional filtering.") .Produces(StatusCodes.Status200OK); return endpoints; } private static async Task> 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, 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> CheckRelease( [FromBody] ReleaseCheckRequest request, IBudgetConstraintEnforcer enforcer, CancellationToken ct) { var input = new ReleaseCheckInput { ServiceId = request.ServiceId, Tier = Enum.Parse(request.Tier, ignoreCase: true), DiffCategory = Enum.Parse(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> 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, 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 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 /// Response containing risk budget status. public sealed record RiskBudgetStatusResponse( string BudgetId, string ServiceId, string Tier, string Window, int Allocated, int Consumed, int Remaining, decimal PercentageUsed, string Status, DateTimeOffset UpdatedAt); /// Request to consume budget. public sealed record BudgetConsumeRequest( string ServiceId, int RiskPoints, string ReleaseId, string? Reason = null); /// Response from budget consumption. public sealed record BudgetConsumeResponse( bool IsSuccess, string? EntryId, int Remaining, decimal PercentageUsed, string Status, string? Error); /// Request to check if release can proceed. 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); /// Response from release check. public sealed record ReleaseCheckResponse( bool CanProceed, string RequiredGate, int RiskPoints, int BudgetRemainingBefore, int BudgetRemainingAfter, string StatusBefore, string StatusAfter, string? BlockReason, IReadOnlyList Requirements, IReadOnlyList Recommendations); /// Budget entry DTO. public sealed record BudgetEntryDto( string EntryId, string ReleaseId, int RiskPoints, DateTimeOffset ConsumedAt); /// Response containing budget history. public sealed record BudgetHistoryResponse( string ServiceId, string Window, IReadOnlyList Entries); /// Request to adjust budget. public sealed record BudgetAdjustRequest( string ServiceId, int Adjustment, string Reason); /// Response containing budget list. public sealed record BudgetListResponse( IReadOnlyList Budgets, int TotalCount); #endregion