using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.JobEngine.Core.Domain; using StellaOps.JobEngine.Infrastructure.Postgres; using StellaOps.JobEngine.Infrastructure.Repositories; using StellaOps.JobEngine.WebService.Contracts; using StellaOps.JobEngine.WebService.Services; using static StellaOps.Localization.T; namespace StellaOps.JobEngine.WebService.Endpoints; /// /// REST API endpoints for quota management. /// public static class QuotaEndpoints { /// /// Maps quota endpoints to the route builder. /// public static RouteGroupBuilder MapQuotaEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/v1/jobengine/quotas") .WithTags("Orchestrator Quotas") .RequireAuthorization(JobEnginePolicies.Quota) .RequireTenant(); // Quota CRUD operations group.MapGet(string.Empty, ListQuotas) .WithName("Orchestrator_ListQuotas") .WithDescription(_t("orchestrator.quota.list_description")); group.MapGet("{quotaId:guid}", GetQuota) .WithName("Orchestrator_GetQuota") .WithDescription(_t("orchestrator.quota.get_description")); group.MapPost(string.Empty, CreateQuota) .WithName("Orchestrator_CreateQuota") .WithDescription(_t("orchestrator.quota.create_description")); group.MapPut("{quotaId:guid}", UpdateQuota) .WithName("Orchestrator_UpdateQuota") .WithDescription(_t("orchestrator.quota.update_description")); group.MapDelete("{quotaId:guid}", DeleteQuota) .WithName("Orchestrator_DeleteQuota") .WithDescription(_t("orchestrator.quota.delete_description")); // Quota control operations group.MapPost("{quotaId:guid}/pause", PauseQuota) .WithName("Orchestrator_PauseQuota") .WithDescription(_t("orchestrator.quota.pause_description")); group.MapPost("{quotaId:guid}/resume", ResumeQuota) .WithName("Orchestrator_ResumeQuota") .WithDescription(_t("orchestrator.quota.resume_description")); // Quota summary group.MapGet("summary", GetQuotaSummary) .WithName("Orchestrator_GetQuotaSummary") .WithDescription(_t("orchestrator.quota.reset_description")); return group; } private static async Task ListQuotas( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, [FromQuery] string? jobType = null, [FromQuery] bool? paused = null, [FromQuery] int? limit = null, [FromQuery] string? cursor = null, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var effectiveLimit = EndpointHelpers.GetLimit(limit); var offset = EndpointHelpers.ParseCursorOffset(cursor); var quotas = await repository.ListAsync( tenantId, jobType, paused, effectiveLimit, offset, cancellationToken).ConfigureAwait(false); var responses = quotas.Select(QuotaResponse.FromDomain).ToList(); var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count); return Results.Ok(new QuotaListResponse(responses, nextCursor)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetQuota( HttpContext context, [FromRoute] Guid quotaId, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var quota = await repository.GetByIdAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); if (quota is null) { return Results.NotFound(); } return Results.Ok(QuotaResponse.FromDomain(quota)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task CreateQuota( HttpContext context, [FromBody] CreateQuotaRequest request, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var actorId = context.User?.Identity?.Name ?? "system"; // Validate request if (request.MaxActive <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.max_active_positive") }); if (request.MaxPerHour <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.max_per_hour_positive") }); if (request.BurstCapacity <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.burst_capacity_positive") }); if (request.RefillRate <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.refill_rate_positive") }); var now = DateTimeOffset.UtcNow; var quota = new Quota( QuotaId: Guid.NewGuid(), TenantId: tenantId, JobType: request.JobType, MaxActive: request.MaxActive, MaxPerHour: request.MaxPerHour, BurstCapacity: request.BurstCapacity, RefillRate: request.RefillRate, CurrentTokens: request.BurstCapacity, LastRefillAt: now, CurrentActive: 0, CurrentHourCount: 0, CurrentHourStart: new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Offset), Paused: false, PauseReason: null, QuotaTicket: null, CreatedAt: now, UpdatedAt: now, UpdatedBy: actorId); await repository.CreateAsync(quota, cancellationToken).ConfigureAwait(false); return Results.Created($"/api/v1/jobengine/quotas/{quota.QuotaId}", QuotaResponse.FromDomain(quota)); } catch (DuplicateQuotaException ex) { return Results.Conflict(new { error = ex.Message }); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task UpdateQuota( HttpContext context, [FromRoute] Guid quotaId, [FromBody] UpdateQuotaRequest request, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var actorId = context.User?.Identity?.Name ?? "system"; var quota = await repository.GetByIdAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); if (quota is null) { return Results.NotFound(); } // Validate request if (request.MaxActive.HasValue && request.MaxActive <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.max_active_positive") }); if (request.MaxPerHour.HasValue && request.MaxPerHour <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.max_per_hour_positive") }); if (request.BurstCapacity.HasValue && request.BurstCapacity <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.burst_capacity_positive") }); if (request.RefillRate.HasValue && request.RefillRate <= 0) return Results.BadRequest(new { error = _t("orchestrator.quota.error.refill_rate_positive") }); var updated = quota with { MaxActive = request.MaxActive ?? quota.MaxActive, MaxPerHour = request.MaxPerHour ?? quota.MaxPerHour, BurstCapacity = request.BurstCapacity ?? quota.BurstCapacity, RefillRate = request.RefillRate ?? quota.RefillRate, UpdatedAt = DateTimeOffset.UtcNow, UpdatedBy = actorId }; await repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false); return Results.Ok(QuotaResponse.FromDomain(updated)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task DeleteQuota( HttpContext context, [FromRoute] Guid quotaId, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var deleted = await repository.DeleteAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); if (!deleted) { return Results.NotFound(); } return Results.NoContent(); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task PauseQuota( HttpContext context, [FromRoute] Guid quotaId, [FromBody] PauseQuotaRequest request, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var actorId = context.User?.Identity?.Name ?? "system"; var quota = await repository.GetByIdAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); if (quota is null) { return Results.NotFound(); } if (string.IsNullOrWhiteSpace(request.Reason)) { return Results.BadRequest(new { error = _t("orchestrator.quota.error.pause_reason_required") }); } await repository.PauseAsync(tenantId, quotaId, request.Reason, request.Ticket, actorId, cancellationToken) .ConfigureAwait(false); var updated = await repository.GetByIdAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); return Results.Ok(QuotaResponse.FromDomain(updated!)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task ResumeQuota( HttpContext context, [FromRoute] Guid quotaId, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); var actorId = context.User?.Identity?.Name ?? "system"; var quota = await repository.GetByIdAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); if (quota is null) { return Results.NotFound(); } await repository.ResumeAsync(tenantId, quotaId, actorId, cancellationToken).ConfigureAwait(false); var updated = await repository.GetByIdAsync(tenantId, quotaId, cancellationToken).ConfigureAwait(false); return Results.Ok(QuotaResponse.FromDomain(updated!)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task GetQuotaSummary( HttpContext context, [FromServices] TenantResolver tenantResolver, [FromServices] IQuotaRepository repository, CancellationToken cancellationToken = default) { try { var tenantId = tenantResolver.Resolve(context); // Get all quotas for the tenant var quotas = await repository.ListAsync(tenantId, null, null, 1000, 0, cancellationToken) .ConfigureAwait(false); var totalQuotas = quotas.Count; var pausedQuotas = quotas.Count(q => q.Paused); // Calculate utilization for each quota var utilizationItems = quotas.Select(q => { var tokenUtilization = q.BurstCapacity > 0 ? 1.0 - (q.CurrentTokens / q.BurstCapacity) : 0.0; var concurrencyUtilization = q.MaxActive > 0 ? (double)q.CurrentActive / q.MaxActive : 0.0; var hourlyUtilization = q.MaxPerHour > 0 ? (double)q.CurrentHourCount / q.MaxPerHour : 0.0; return new QuotaUtilizationResponse( QuotaId: q.QuotaId, JobType: q.JobType, TokenUtilization: Math.Round(tokenUtilization, 4), ConcurrencyUtilization: Math.Round(concurrencyUtilization, 4), HourlyUtilization: Math.Round(hourlyUtilization, 4), Paused: q.Paused); }).ToList(); var avgTokenUtilization = utilizationItems.Count > 0 ? utilizationItems.Average(u => u.TokenUtilization) : 0.0; var avgConcurrencyUtilization = utilizationItems.Count > 0 ? utilizationItems.Average(u => u.ConcurrencyUtilization) : 0.0; return Results.Ok(new QuotaSummaryResponse( TotalQuotas: totalQuotas, PausedQuotas: pausedQuotas, AverageTokenUtilization: Math.Round(avgTokenUtilization, 4), AverageConcurrencyUtilization: Math.Round(avgConcurrencyUtilization, 4), Quotas: utilizationItems)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } }