consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for quota management.
|
||||
/// </summary>
|
||||
public static class QuotaEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps quota endpoints to the route builder.
|
||||
/// </summary>
|
||||
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user