consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Approval endpoints for the release orchestrator.
|
||||
/// Routes: /api/release-orchestrator/approvals
|
||||
/// </summary>
|
||||
public static class ApprovalEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapApprovalEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapApprovalGroup(app, "/api/release-orchestrator/approvals", includeRouteNames: true);
|
||||
MapApprovalGroup(app, "/api/v1/release-orchestrator/approvals", includeRouteNames: false);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapApprovalGroup(
|
||||
IEndpointRouteBuilder app,
|
||||
string prefix,
|
||||
bool includeRouteNames)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("Approvals")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
|
||||
var list = group.MapGet(string.Empty, ListApprovals)
|
||||
.WithDescription(_t("orchestrator.approval.list_description"));
|
||||
if (includeRouteNames)
|
||||
{
|
||||
list.WithName("Approval_List");
|
||||
}
|
||||
|
||||
var detail = group.MapGet("/{id}", GetApproval)
|
||||
.WithDescription(_t("orchestrator.approval.get_description"));
|
||||
if (includeRouteNames)
|
||||
{
|
||||
detail.WithName("Approval_Get");
|
||||
}
|
||||
|
||||
var approve = group.MapPost("/{id}/approve", Approve)
|
||||
.WithDescription(_t("orchestrator.approval.approve_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
approve.WithName("Approval_Approve");
|
||||
}
|
||||
|
||||
var reject = group.MapPost("/{id}/reject", Reject)
|
||||
.WithDescription(_t("orchestrator.approval.reject_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
reject.WithName("Approval_Reject");
|
||||
}
|
||||
|
||||
var batchApprove = group.MapPost("/batch-approve", BatchApprove)
|
||||
.WithDescription(_t("orchestrator.approval.create_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
batchApprove.WithName("Approval_BatchApprove");
|
||||
}
|
||||
|
||||
var batchReject = group.MapPost("/batch-reject", BatchReject)
|
||||
.WithDescription(_t("orchestrator.approval.cancel_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
batchReject.WithName("Approval_BatchReject");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult ListApprovals(
|
||||
[FromQuery] string? statuses,
|
||||
[FromQuery] string? urgencies,
|
||||
[FromQuery] string? environment)
|
||||
{
|
||||
var approvals = SeedData.Approvals
|
||||
.Select(WithDerivedSignals)
|
||||
.Select(ToSummary)
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
var statusList = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
approvals = approvals.Where(a => statusList.Contains(a.Status, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(urgencies))
|
||||
{
|
||||
var urgencyList = urgencies.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
approvals = approvals.Where(a => urgencyList.Contains(a.Urgency, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
approvals = approvals.Where(a =>
|
||||
string.Equals(a.TargetEnvironment, environment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Results.Ok(approvals.ToList());
|
||||
}
|
||||
|
||||
private static IResult GetApproval(string id)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
return approval is not null
|
||||
? Results.Ok(WithDerivedSignals(approval))
|
||||
: Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult Approve(string id, [FromBody] ApprovalActionDto request)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
if (approval is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(WithDerivedSignals(approval with
|
||||
{
|
||||
CurrentApprovals = approval.CurrentApprovals + 1,
|
||||
Status = approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status,
|
||||
}));
|
||||
}
|
||||
|
||||
private static IResult Reject(string id, [FromBody] ApprovalActionDto request)
|
||||
{
|
||||
var approval = SeedData.Approvals.FirstOrDefault(a => a.Id == id);
|
||||
if (approval is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(WithDerivedSignals(approval with { Status = "rejected" }));
|
||||
}
|
||||
|
||||
private static IResult BatchApprove([FromBody] BatchActionDto request)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult BatchReject([FromBody] BatchActionDto request)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
public static ApprovalDto WithDerivedSignals(ApprovalDto approval)
|
||||
{
|
||||
var manifestDigest = approval.ManifestDigest
|
||||
?? approval.ReleaseComponents.FirstOrDefault()?.Digest
|
||||
?? $"sha256:{approval.ReleaseId.Replace("-", string.Empty, StringComparison.Ordinal)}";
|
||||
|
||||
var risk = approval.RiskSnapshot
|
||||
?? ReleaseControlSignalCatalog.GetRiskSnapshot(approval.ReleaseId, approval.TargetEnvironment);
|
||||
|
||||
var coverage = approval.ReachabilityCoverage
|
||||
?? ReleaseControlSignalCatalog.GetCoverage(approval.ReleaseId);
|
||||
|
||||
var opsConfidence = approval.OpsConfidence
|
||||
?? ReleaseControlSignalCatalog.GetOpsConfidence(approval.TargetEnvironment);
|
||||
|
||||
var evidencePacket = approval.EvidencePacket
|
||||
?? ReleaseControlSignalCatalog.BuildEvidencePacket(approval.Id, approval.ReleaseId);
|
||||
|
||||
return approval with
|
||||
{
|
||||
ManifestDigest = manifestDigest,
|
||||
RiskSnapshot = risk,
|
||||
ReachabilityCoverage = coverage,
|
||||
OpsConfidence = opsConfidence,
|
||||
EvidencePacket = evidencePacket,
|
||||
DecisionDigest = approval.DecisionDigest ?? evidencePacket.DecisionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
public static ApprovalSummaryDto ToSummary(ApprovalDto approval)
|
||||
{
|
||||
var enriched = WithDerivedSignals(approval);
|
||||
return new ApprovalSummaryDto
|
||||
{
|
||||
Id = enriched.Id,
|
||||
ReleaseId = enriched.ReleaseId,
|
||||
ReleaseName = enriched.ReleaseName,
|
||||
ReleaseVersion = enriched.ReleaseVersion,
|
||||
SourceEnvironment = enriched.SourceEnvironment,
|
||||
TargetEnvironment = enriched.TargetEnvironment,
|
||||
RequestedBy = enriched.RequestedBy,
|
||||
RequestedAt = enriched.RequestedAt,
|
||||
Urgency = enriched.Urgency,
|
||||
Justification = enriched.Justification,
|
||||
Status = enriched.Status,
|
||||
CurrentApprovals = enriched.CurrentApprovals,
|
||||
RequiredApprovals = enriched.RequiredApprovals,
|
||||
GatesPassed = enriched.GatesPassed,
|
||||
ScheduledTime = enriched.ScheduledTime,
|
||||
ExpiresAt = enriched.ExpiresAt,
|
||||
ManifestDigest = enriched.ManifestDigest,
|
||||
RiskSnapshot = enriched.RiskSnapshot,
|
||||
ReachabilityCoverage = enriched.ReachabilityCoverage,
|
||||
OpsConfidence = enriched.OpsConfidence,
|
||||
DecisionDigest = enriched.DecisionDigest,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record ApprovalSummaryDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ReleaseName { get; init; }
|
||||
public required string ReleaseVersion { get; init; }
|
||||
public required string SourceEnvironment { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string RequestedBy { get; init; }
|
||||
public required string RequestedAt { get; init; }
|
||||
public required string Urgency { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int CurrentApprovals { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public bool GatesPassed { get; init; }
|
||||
public string? ScheduledTime { get; init; }
|
||||
public string? ExpiresAt { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public PromotionRiskSnapshot? RiskSnapshot { get; init; }
|
||||
public HybridReachabilityCoverage? ReachabilityCoverage { get; init; }
|
||||
public OpsDataConfidence? OpsConfidence { get; init; }
|
||||
public string? DecisionDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string ReleaseName { get; init; }
|
||||
public required string ReleaseVersion { get; init; }
|
||||
public required string SourceEnvironment { get; init; }
|
||||
public required string TargetEnvironment { get; init; }
|
||||
public required string RequestedBy { get; init; }
|
||||
public required string RequestedAt { get; init; }
|
||||
public required string Urgency { get; init; }
|
||||
public required string Justification { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int CurrentApprovals { get; init; }
|
||||
public int RequiredApprovals { get; init; }
|
||||
public bool GatesPassed { get; init; }
|
||||
public string? ScheduledTime { get; init; }
|
||||
public string? ExpiresAt { get; init; }
|
||||
public List<GateResultDto> GateResults { get; init; } = new();
|
||||
public List<ApprovalActionRecordDto> Actions { get; init; } = new();
|
||||
public List<ApproverDto> Approvers { get; init; } = new();
|
||||
public List<ReleaseComponentSummaryDto> ReleaseComponents { get; init; } = new();
|
||||
public string? ManifestDigest { get; init; }
|
||||
public PromotionRiskSnapshot? RiskSnapshot { get; init; }
|
||||
public HybridReachabilityCoverage? ReachabilityCoverage { get; init; }
|
||||
public OpsDataConfidence? OpsConfidence { get; init; }
|
||||
public ApprovalEvidencePacket? EvidencePacket { get; init; }
|
||||
public string? DecisionDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GateResultDto
|
||||
{
|
||||
public required string GateId { get; init; }
|
||||
public required string GateName { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public Dictionary<string, object> Details { get; init; } = new();
|
||||
public string? EvaluatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalActionRecordDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ApprovalId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
public required string Comment { get; init; }
|
||||
public required string Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApproverDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public bool HasApproved { get; init; }
|
||||
public string? ApprovedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseComponentSummaryDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ApprovalActionDto
|
||||
{
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BatchActionDto
|
||||
{
|
||||
public string[]? Ids { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class SeedData
|
||||
{
|
||||
public static readonly List<ApprovalDto> Approvals = new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "apr-001", ReleaseId = "rel-001", ReleaseName = "API Gateway", ReleaseVersion = "2.1.0",
|
||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
||||
RequestedBy = "alice.johnson", RequestedAt = "2026-01-12T08:00:00Z",
|
||||
Urgency = "normal", Justification = "Scheduled release with new rate limiting feature and bug fixes.",
|
||||
Status = "pending", CurrentApprovals = 1, RequiredApprovals = 2, GatesPassed = true,
|
||||
ExpiresAt = "2026-01-14T08:00:00Z",
|
||||
GateResults = new()
|
||||
{
|
||||
new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "passed", Message = "No vulnerabilities found", EvaluatedAt = "2026-01-12T08:05:00Z" },
|
||||
new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = "2026-01-12T08:06:00Z" },
|
||||
new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "passed", Message = "Code coverage: 85%", EvaluatedAt = "2026-01-12T08:07:00Z" },
|
||||
},
|
||||
Actions = new()
|
||||
{
|
||||
new() { Id = "act-1", ApprovalId = "apr-001", Action = "approved", Actor = "bob.smith", Comment = "Looks good, tests are passing.", Timestamp = "2026-01-12T09:30:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com", HasApproved = true, ApprovedAt = "2026-01-12T09:30:00Z" },
|
||||
new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "api-gateway", Version = "2.1.0", Digest = "sha256:abc123def456..." },
|
||||
new() { Name = "rate-limiter", Version = "1.0.5", Digest = "sha256:789xyz012..." },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "apr-002", ReleaseId = "rel-002", ReleaseName = "User Service", ReleaseVersion = "3.0.0-rc1",
|
||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
||||
RequestedBy = "david.wilson", RequestedAt = "2026-01-12T10:00:00Z",
|
||||
Urgency = "high", Justification = "Critical fix for user authentication timeout issue.",
|
||||
Status = "pending", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = false,
|
||||
ExpiresAt = "2026-01-13T10:00:00Z",
|
||||
GateResults = new()
|
||||
{
|
||||
new() { GateId = "g1", GateName = "Security Scan", Type = "security", Status = "warning", Message = "2 low severity vulnerabilities", EvaluatedAt = "2026-01-12T10:05:00Z" },
|
||||
new() { GateId = "g2", GateName = "Policy Compliance", Type = "policy", Status = "passed", Message = "All policies satisfied", EvaluatedAt = "2026-01-12T10:06:00Z" },
|
||||
new() { GateId = "g3", GateName = "Quality Gates", Type = "quality", Status = "failed", Message = "Code coverage: 72%", EvaluatedAt = "2026-01-12T10:07:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com" },
|
||||
new() { Id = "u3", Name = "Emily Chen", Email = "emily.chen@example.com" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "user-service", Version = "3.0.0-rc1", Digest = "sha256:user123..." },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "apr-003", ReleaseId = "rel-003", ReleaseName = "Payment Gateway", ReleaseVersion = "1.5.2",
|
||||
SourceEnvironment = "dev", TargetEnvironment = "staging",
|
||||
RequestedBy = "frank.miller", RequestedAt = "2026-01-11T14:00:00Z",
|
||||
Urgency = "critical", Justification = "Emergency fix for payment processing failure.",
|
||||
Status = "approved", CurrentApprovals = 2, RequiredApprovals = 2, GatesPassed = true,
|
||||
ScheduledTime = "2026-01-12T06:00:00Z", ExpiresAt = "2026-01-12T14:00:00Z",
|
||||
Actions = new()
|
||||
{
|
||||
new() { Id = "act-2", ApprovalId = "apr-003", Action = "approved", Actor = "carol.davis", Comment = "Urgent fix approved.", Timestamp = "2026-01-11T14:30:00Z" },
|
||||
new() { Id = "act-3", ApprovalId = "apr-003", Action = "approved", Actor = "grace.lee", Comment = "Confirmed, proceed.", Timestamp = "2026-01-11T15:00:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u2", Name = "Carol Davis", Email = "carol.davis@example.com", HasApproved = true, ApprovedAt = "2026-01-11T14:30:00Z" },
|
||||
new() { Id = "u4", Name = "Grace Lee", Email = "grace.lee@example.com", HasApproved = true, ApprovedAt = "2026-01-11T15:00:00Z" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "payment-gateway", Version = "1.5.2", Digest = "sha256:pay456..." },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "apr-004", ReleaseId = "rel-004", ReleaseName = "Notification Service", ReleaseVersion = "2.0.0",
|
||||
SourceEnvironment = "staging", TargetEnvironment = "production",
|
||||
RequestedBy = "alice.johnson", RequestedAt = "2026-01-10T09:00:00Z",
|
||||
Urgency = "low", Justification = "Feature release with new email templates.",
|
||||
Status = "rejected", CurrentApprovals = 0, RequiredApprovals = 2, GatesPassed = true,
|
||||
ExpiresAt = "2026-01-12T09:00:00Z",
|
||||
Actions = new()
|
||||
{
|
||||
new() { Id = "act-4", ApprovalId = "apr-004", Action = "rejected", Actor = "bob.smith", Comment = "Missing integration tests.", Timestamp = "2026-01-10T11:00:00Z" },
|
||||
},
|
||||
Approvers = new()
|
||||
{
|
||||
new() { Id = "u1", Name = "Bob Smith", Email = "bob.smith@example.com" },
|
||||
},
|
||||
ReleaseComponents = new()
|
||||
{
|
||||
new() { Name = "notification-service", Version = "2.0.0", Digest = "sha256:notify789..." },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
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 audit log operations.
|
||||
/// </summary>
|
||||
public static class AuditEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps audit endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapAuditEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/audit")
|
||||
.WithTags("Orchestrator Audit")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// List and get operations
|
||||
group.MapGet(string.Empty, ListAuditEntries)
|
||||
.WithName("Orchestrator_ListAuditEntries")
|
||||
.WithDescription(_t("orchestrator.audit.list_description"));
|
||||
|
||||
group.MapGet("{entryId:guid}", GetAuditEntry)
|
||||
.WithName("Orchestrator_GetAuditEntry")
|
||||
.WithDescription(_t("orchestrator.audit.get_description"));
|
||||
|
||||
group.MapGet("resource/{resourceType}/{resourceId:guid}", GetResourceHistory)
|
||||
.WithName("Orchestrator_GetResourceHistory")
|
||||
.WithDescription(_t("orchestrator.audit.get_resource_history_description"));
|
||||
|
||||
group.MapGet("latest", GetLatestEntry)
|
||||
.WithName("Orchestrator_GetLatestAuditEntry")
|
||||
.WithDescription(_t("orchestrator.audit.get_latest_description"));
|
||||
|
||||
group.MapGet("sequence/{startSeq:long}/{endSeq:long}", GetBySequenceRange)
|
||||
.WithName("Orchestrator_GetAuditBySequence")
|
||||
.WithDescription(_t("orchestrator.audit.get_by_sequence_description"));
|
||||
|
||||
// Summary and verification
|
||||
group.MapGet("summary", GetAuditSummary)
|
||||
.WithName("Orchestrator_GetAuditSummary")
|
||||
.WithDescription(_t("orchestrator.audit.summary_description"));
|
||||
|
||||
group.MapGet("verify", VerifyAuditChain)
|
||||
.WithName("Orchestrator_VerifyAuditChain")
|
||||
.WithDescription(_t("orchestrator.audit.verify_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAuditEntries(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
[FromQuery] string? eventType = null,
|
||||
[FromQuery] string? resourceType = null,
|
||||
[FromQuery] Guid? resourceId = null,
|
||||
[FromQuery] string? actorId = null,
|
||||
[FromQuery] DateTimeOffset? startTime = null,
|
||||
[FromQuery] DateTimeOffset? endTime = 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);
|
||||
|
||||
AuditEventType? parsedEventType = null;
|
||||
if (!string.IsNullOrEmpty(eventType) && Enum.TryParse<AuditEventType>(eventType, true, out var et))
|
||||
{
|
||||
parsedEventType = et;
|
||||
}
|
||||
|
||||
var entries = await repository.ListAsync(
|
||||
tenantId,
|
||||
parsedEventType,
|
||||
resourceType,
|
||||
resourceId,
|
||||
actorId,
|
||||
startTime,
|
||||
endTime,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(AuditEntryResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new AuditEntryListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAuditEntry(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid entryId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByIdAsync(tenantId, entryId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(AuditEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetResourceHistory(
|
||||
HttpContext context,
|
||||
[FromRoute] string resourceType,
|
||||
[FromRoute] Guid resourceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
[FromQuery] int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
|
||||
var entries = await repository.GetByResourceAsync(
|
||||
tenantId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
effectiveLimit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(AuditEntryResponse.FromDomain).ToList();
|
||||
return Results.Ok(new AuditEntryListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLatestEntry(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(AuditEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBySequenceRange(
|
||||
HttpContext context,
|
||||
[FromRoute] long startSeq,
|
||||
[FromRoute] long endSeq,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
if (startSeq < 1 || endSeq < startSeq)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.audit.error.invalid_sequence_range") });
|
||||
}
|
||||
|
||||
var entries = await repository.GetBySequenceRangeAsync(
|
||||
tenantId,
|
||||
startSeq,
|
||||
endSeq,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(AuditEntryResponse.FromDomain).ToList();
|
||||
return Results.Ok(new AuditEntryListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAuditSummary(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
[FromQuery] DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var summary = await repository.GetSummaryAsync(tenantId, since, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(AuditSummaryResponse.FromDomain(summary));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyAuditChain(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IAuditRepository repository,
|
||||
[FromQuery] long? startSeq = null,
|
||||
[FromQuery] long? endSeq = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await repository.VerifyChainAsync(tenantId, startSeq, endSeq, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Infrastructure.JobEngineMetrics.AuditChainVerified(tenantId, result.IsValid);
|
||||
|
||||
return Results.Ok(ChainVerificationResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Core.Services;
|
||||
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 circuit breaker management.
|
||||
/// </summary>
|
||||
public static class CircuitBreakerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps circuit breaker endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapCircuitBreakerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/circuit-breakers")
|
||||
.WithTags("Orchestrator Circuit Breakers")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// List circuit breakers
|
||||
group.MapGet(string.Empty, ListCircuitBreakers)
|
||||
.WithName("Orchestrator_ListCircuitBreakers")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.list_description"));
|
||||
|
||||
// Get specific circuit breaker
|
||||
group.MapGet("{serviceId}", GetCircuitBreaker)
|
||||
.WithName("Orchestrator_GetCircuitBreaker")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.get_description"));
|
||||
|
||||
// Check if request is allowed
|
||||
group.MapGet("{serviceId}/check", CheckCircuitBreaker)
|
||||
.WithName("Orchestrator_CheckCircuitBreaker")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.check_description"));
|
||||
|
||||
// Record success
|
||||
group.MapPost("{serviceId}/success", RecordSuccess)
|
||||
.WithName("Orchestrator_RecordCircuitBreakerSuccess")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.record_success_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Record failure
|
||||
group.MapPost("{serviceId}/failure", RecordFailure)
|
||||
.WithName("Orchestrator_RecordCircuitBreakerFailure")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.record_failure_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Force open
|
||||
group.MapPost("{serviceId}/force-open", ForceOpen)
|
||||
.WithName("Orchestrator_ForceOpenCircuitBreaker")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.force_open_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Force close
|
||||
group.MapPost("{serviceId}/force-close", ForceClose)
|
||||
.WithName("Orchestrator_ForceCloseCircuitBreaker")
|
||||
.WithDescription(_t("orchestrator.circuit_breaker.force_close_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListCircuitBreakers(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
[FromQuery] string? state = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
CircuitState? filterState = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(state) && Enum.TryParse<CircuitState>(state, ignoreCase: true, out var parsed))
|
||||
{
|
||||
filterState = parsed;
|
||||
}
|
||||
|
||||
var circuitBreakers = await service.ListAsync(tenantId, filterState, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responses = circuitBreakers.Select(CircuitBreakerResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new CircuitBreakerListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCircuitBreaker(
|
||||
HttpContext context,
|
||||
[FromRoute] string serviceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var state = await service.GetStateAsync(tenantId, serviceId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (state is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(CircuitBreakerResponse.FromDomain(state));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CheckCircuitBreaker(
|
||||
HttpContext context,
|
||||
[FromRoute] string serviceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await service.CheckAsync(tenantId, serviceId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new CircuitBreakerCheckResponse(
|
||||
IsAllowed: result.IsAllowed,
|
||||
State: result.State.ToString(),
|
||||
FailureRate: result.FailureRate,
|
||||
TimeUntilRetry: result.TimeUntilRetry,
|
||||
BlockReason: result.BlockReason));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecordSuccess(
|
||||
HttpContext context,
|
||||
[FromRoute] string serviceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
await service.RecordSuccessAsync(tenantId, serviceId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { recorded = true });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecordFailure(
|
||||
HttpContext context,
|
||||
[FromRoute] string serviceId,
|
||||
[FromBody] RecordFailureRequest? request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var failureReason = request?.FailureReason ?? "Unspecified failure";
|
||||
await service.RecordFailureAsync(tenantId, serviceId, failureReason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { recorded = true });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ForceOpen(
|
||||
HttpContext context,
|
||||
[FromRoute] string serviceId,
|
||||
[FromBody] ForceOpenCircuitBreakerRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.circuit_breaker.error.force_open_reason_required") });
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
await service.ForceOpenAsync(tenantId, serviceId, request.Reason, actorId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { opened = true });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ForceClose(
|
||||
HttpContext context,
|
||||
[FromRoute] string serviceId,
|
||||
[FromBody] ForceCloseCircuitBreakerRequest? request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ICircuitBreakerService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
await service.ForceCloseAsync(tenantId, serviceId, actorId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { closed = true });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Scheduling;
|
||||
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 job DAG (dependency graph).
|
||||
/// </summary>
|
||||
public static class DagEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps DAG endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapDagEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/dag")
|
||||
.WithTags("Orchestrator DAG")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("run/{runId:guid}", GetRunDag)
|
||||
.WithName("Orchestrator_GetRunDag")
|
||||
.WithDescription(_t("orchestrator.dag.get_run_description"));
|
||||
|
||||
group.MapGet("run/{runId:guid}/edges", GetRunEdges)
|
||||
.WithName("Orchestrator_GetRunEdges")
|
||||
.WithDescription(_t("orchestrator.dag.get_run_edges_description"));
|
||||
|
||||
group.MapGet("run/{runId:guid}/ready-jobs", GetReadyJobs)
|
||||
.WithName("Orchestrator_GetReadyJobs")
|
||||
.WithDescription(_t("orchestrator.dag.get_ready_jobs_description"));
|
||||
|
||||
group.MapGet("run/{runId:guid}/blocked/{jobId:guid}", GetBlockedJobs)
|
||||
.WithName("Orchestrator_GetBlockedJobs")
|
||||
.WithDescription(_t("orchestrator.dag.get_blocked_jobs_description"));
|
||||
|
||||
group.MapGet("job/{jobId:guid}/parents", GetJobParents)
|
||||
.WithName("Orchestrator_GetJobParents")
|
||||
.WithDescription(_t("orchestrator.dag.get_job_parents_description"));
|
||||
|
||||
group.MapGet("job/{jobId:guid}/children", GetJobChildren)
|
||||
.WithName("Orchestrator_GetJobChildren")
|
||||
.WithDescription(_t("orchestrator.dag.get_job_children_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunDag(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] IDagEdgeRepository dagEdgeRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Verify run exists
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Get all edges
|
||||
var edges = await dagEdgeRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
var edgeResponses = edges.Select(DagEdgeResponse.FromDomain).ToList();
|
||||
|
||||
// Get all jobs for topological sort and critical path
|
||||
var jobs = await jobRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Compute topological order
|
||||
IReadOnlyList<Guid> topologicalOrder;
|
||||
try
|
||||
{
|
||||
topologicalOrder = DagPlanner.TopologicalSort(jobs.Select(j => j.JobId), edges);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Cycle detected - return empty order
|
||||
topologicalOrder = [];
|
||||
}
|
||||
|
||||
// Compute critical path (using a fixed estimate for simplicity)
|
||||
var criticalPath = DagPlanner.CalculateCriticalPath(jobs, edges, _ => TimeSpan.FromMinutes(5));
|
||||
|
||||
return Results.Ok(new DagResponse(
|
||||
runId,
|
||||
edgeResponses,
|
||||
topologicalOrder,
|
||||
criticalPath.CriticalPathJobIds,
|
||||
criticalPath.TotalDuration));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunEdges(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IDagEdgeRepository dagEdgeRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Verify run exists
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var edges = await dagEdgeRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
var responses = edges.Select(DagEdgeResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new DagEdgeListResponse(responses));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReadyJobs(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] IDagEdgeRepository dagEdgeRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Verify run exists
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var jobs = await jobRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
var edges = await dagEdgeRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var readyJobs = DagPlanner.GetReadyJobs(jobs, edges);
|
||||
var responses = readyJobs.Select(JobResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new JobListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBlockedJobs(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IDagEdgeRepository dagEdgeRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Verify run exists
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var edges = await dagEdgeRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
var blockedJobs = DagPlanner.GetBlockedJobs(jobId, edges);
|
||||
|
||||
return Results.Ok(new BlockedJobsResponse(jobId, blockedJobs.ToList()));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobParents(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDagEdgeRepository dagEdgeRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var edges = await dagEdgeRepository.GetParentEdgesAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
var responses = edges.Select(DagEdgeResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new DagEdgeListResponse(responses));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobChildren(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDagEdgeRepository dagEdgeRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var edges = await dagEdgeRepository.GetChildEdgesAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
var responses = edges.Select(DagEdgeResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new DagEdgeListResponse(responses));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Npgsql;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.DeadLetter;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for dead-letter store.
|
||||
/// </summary>
|
||||
public static class DeadLetterEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps dead-letter endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapDeadLetterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/deadletter")
|
||||
.WithTags("Orchestrator Dead-Letter")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// Entry management
|
||||
group.MapGet(string.Empty, ListEntries)
|
||||
.WithName("Orchestrator_ListDeadLetterEntries")
|
||||
.WithDescription(_t("orchestrator.dead_letter.list_description"));
|
||||
|
||||
group.MapGet("{entryId:guid}", GetEntry)
|
||||
.WithName("Orchestrator_GetDeadLetterEntry")
|
||||
.WithDescription(_t("orchestrator.dead_letter.get_description"));
|
||||
|
||||
group.MapGet("by-job/{jobId:guid}", GetEntryByJobId)
|
||||
.WithName("Orchestrator_GetDeadLetterEntryByJobId")
|
||||
.WithDescription(_t("orchestrator.dead_letter.get_by_job_description"));
|
||||
|
||||
group.MapGet("stats", GetStats)
|
||||
.WithName("Orchestrator_GetDeadLetterStats")
|
||||
.WithDescription(_t("orchestrator.dead_letter.stats_description"));
|
||||
|
||||
group.MapGet("export", ExportEntries)
|
||||
.WithName("Orchestrator_ExportDeadLetterEntries")
|
||||
.WithDescription(_t("orchestrator.dead_letter.export_description"));
|
||||
|
||||
group.MapGet("summary", GetActionableSummary)
|
||||
.WithName("Orchestrator_GetDeadLetterSummary")
|
||||
.WithDescription(_t("orchestrator.dead_letter.summary_description"));
|
||||
|
||||
// Replay operations
|
||||
group.MapPost("{entryId:guid}/replay", ReplayEntry)
|
||||
.WithName("Orchestrator_ReplayDeadLetterEntry")
|
||||
.WithDescription(_t("orchestrator.dead_letter.replay_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapPost("replay/batch", ReplayBatch)
|
||||
.WithName("Orchestrator_ReplayDeadLetterBatch")
|
||||
.WithDescription(_t("orchestrator.dead_letter.replay_batch_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapPost("replay/pending", ReplayPending)
|
||||
.WithName("Orchestrator_ReplayPendingDeadLetters")
|
||||
.WithDescription(_t("orchestrator.dead_letter.replay_pending_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Resolution
|
||||
group.MapPost("{entryId:guid}/resolve", ResolveEntry)
|
||||
.WithName("Orchestrator_ResolveDeadLetterEntry")
|
||||
.WithDescription(_t("orchestrator.dead_letter.resolve_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapPost("resolve/batch", ResolveBatch)
|
||||
.WithName("Orchestrator_ResolveDeadLetterBatch")
|
||||
.WithDescription(_t("orchestrator.dead_letter.resolve_batch_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Error classification reference
|
||||
group.MapGet("error-codes", ListErrorCodes)
|
||||
.WithName("Orchestrator_ListDeadLetterErrorCodes")
|
||||
.WithDescription(_t("orchestrator.dead_letter.error_codes_description"));
|
||||
|
||||
// Audit
|
||||
group.MapGet("{entryId:guid}/audit", GetReplayAudit)
|
||||
.WithName("Orchestrator_GetDeadLetterReplayAudit")
|
||||
.WithDescription(_t("orchestrator.dead_letter.replay_audit_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListEntries(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDeadLetterRepository repository,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? category = null,
|
||||
[FromQuery] string? jobType = null,
|
||||
[FromQuery] string? errorCode = null,
|
||||
[FromQuery] Guid? sourceId = null,
|
||||
[FromQuery] Guid? runId = null,
|
||||
[FromQuery] bool? isRetryable = null,
|
||||
[FromQuery] string? createdAfter = null,
|
||||
[FromQuery] string? createdBefore = null,
|
||||
[FromQuery] int? limit = null,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
|
||||
var options = new DeadLetterListOptions(
|
||||
Status: TryParseDeadLetterStatus(status),
|
||||
Category: TryParseErrorCategory(category),
|
||||
JobType: jobType,
|
||||
ErrorCode: errorCode,
|
||||
SourceId: sourceId,
|
||||
RunId: runId,
|
||||
IsRetryable: isRetryable,
|
||||
CreatedAfter: EndpointHelpers.TryParseDateTimeOffset(createdAfter),
|
||||
CreatedBefore: EndpointHelpers.TryParseDateTimeOffset(createdBefore),
|
||||
Cursor: cursor,
|
||||
Limit: effectiveLimit);
|
||||
|
||||
var entries = await repository.ListAsync(tenantId, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var totalCount = await repository.CountAsync(tenantId, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(DeadLetterEntryResponse.FromDomain).ToList();
|
||||
var nextCursor = entries.Count >= effectiveLimit
|
||||
? entries.Last().CreatedAt.ToString("O", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
return Results.Ok(new DeadLetterListResponse(responses, nextCursor, totalCount));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
|
||||
{
|
||||
return Results.Ok(new DeadLetterListResponse(new List<DeadLetterEntryResponse>(), null, 0));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEntry(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid entryId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDeadLetterRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByIdAsync(tenantId, entryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(DeadLetterEntryDetailResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEntryByJobId(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDeadLetterRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByOriginalJobIdAsync(tenantId, jobId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(DeadLetterEntryDetailResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStats(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDeadLetterRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var stats = await repository.GetStatsAsync(tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(DeadLetterStatsResponse.FromDomain(stats));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
|
||||
{
|
||||
return Results.Ok(DeadLetterStatsResponse.FromDomain(CreateEmptyStats()));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportEntries(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDeadLetterRepository repository,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? category = null,
|
||||
[FromQuery] string? jobType = null,
|
||||
[FromQuery] string? errorCode = null,
|
||||
[FromQuery] bool? isRetryable = null,
|
||||
[FromQuery] int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Clamp(limit ?? 1000, 1, 10000);
|
||||
|
||||
var options = new DeadLetterListOptions(
|
||||
Status: TryParseDeadLetterStatus(status),
|
||||
Category: TryParseErrorCategory(category),
|
||||
JobType: jobType,
|
||||
ErrorCode: errorCode,
|
||||
IsRetryable: isRetryable,
|
||||
Limit: effectiveLimit);
|
||||
|
||||
var entries = await repository.ListAsync(tenantId, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var csv = BuildDeadLetterCsv(entries);
|
||||
var payload = Encoding.UTF8.GetBytes(csv);
|
||||
var fileName = $"deadletter-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
|
||||
|
||||
return Results.File(payload, "text/csv", fileName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(BuildDeadLetterCsv(Array.Empty<DeadLetterEntry>()));
|
||||
var fileName = $"deadletter-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
|
||||
return Results.File(payload, "text/csv", fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetActionableSummary(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IDeadLetterRepository repository,
|
||||
[FromQuery] int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Clamp(limit ?? 10, 1, 50);
|
||||
|
||||
var summaries = await repository.GetActionableSummaryAsync(tenantId, effectiveLimit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new DeadLetterSummaryListResponse(
|
||||
summaries.Select(s => new DeadLetterSummaryResponse(
|
||||
s.ErrorCode,
|
||||
s.Category.ToString(),
|
||||
s.EntryCount,
|
||||
s.RetryableCount,
|
||||
s.OldestEntry,
|
||||
s.SampleReason)).ToList()));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (PostgresException ex) when (IsMissingDeadLetterTable(ex))
|
||||
{
|
||||
return Results.Ok(new DeadLetterSummaryListResponse(new List<DeadLetterSummaryResponse>()));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReplayEntry(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid entryId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IReplayManager replayManager,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var user = GetCurrentUser(context);
|
||||
|
||||
var result = await replayManager.ReplayAsync(tenantId, entryId, user, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.UnprocessableEntity(new { error = result.ErrorMessage });
|
||||
}
|
||||
|
||||
return Results.Ok(new ReplayResultResponse(
|
||||
result.Success,
|
||||
result.NewJobId,
|
||||
result.ErrorMessage,
|
||||
DeadLetterEntryResponse.FromDomain(result.UpdatedEntry)));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReplayBatch(
|
||||
HttpContext context,
|
||||
[FromBody] ReplayBatchRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IReplayManager replayManager,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var user = GetCurrentUser(context);
|
||||
|
||||
var result = await replayManager.ReplayBatchAsync(tenantId, request.EntryIds, user, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new BatchReplayResultResponse(
|
||||
result.Attempted,
|
||||
result.Succeeded,
|
||||
result.Failed,
|
||||
result.Results.Select(r => new ReplayResultResponse(
|
||||
r.Success,
|
||||
r.NewJobId,
|
||||
r.ErrorMessage,
|
||||
r.UpdatedEntry is not null ? DeadLetterEntryResponse.FromDomain(r.UpdatedEntry) : null)).ToList()));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReplayPending(
|
||||
HttpContext context,
|
||||
[FromBody] ReplayPendingRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IReplayManager replayManager,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var user = GetCurrentUser(context);
|
||||
|
||||
var result = await replayManager.ReplayPendingAsync(
|
||||
tenantId,
|
||||
request.ErrorCode,
|
||||
TryParseErrorCategory(request.Category),
|
||||
request.MaxCount ?? 100,
|
||||
user,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new BatchReplayResultResponse(
|
||||
result.Attempted,
|
||||
result.Succeeded,
|
||||
result.Failed,
|
||||
result.Results.Select(r => new ReplayResultResponse(
|
||||
r.Success,
|
||||
r.NewJobId,
|
||||
r.ErrorMessage,
|
||||
r.UpdatedEntry is not null ? DeadLetterEntryResponse.FromDomain(r.UpdatedEntry) : null)).ToList()));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResolveEntry(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid entryId,
|
||||
[FromBody] ResolveEntryRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IReplayManager replayManager,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var user = GetCurrentUser(context);
|
||||
|
||||
var entry = await replayManager.ResolveAsync(tenantId, entryId, request.Notes, user, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(DeadLetterEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResolveBatch(
|
||||
HttpContext context,
|
||||
[FromBody] ResolveBatchRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IReplayManager replayManager,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var user = GetCurrentUser(context);
|
||||
|
||||
var count = await replayManager.ResolveBatchAsync(
|
||||
tenantId, request.EntryIds, request.Notes, user, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { resolvedCount = count });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<IResult> ListErrorCodes(
|
||||
[FromServices] IErrorClassifier classifier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Return the known error codes with their classifications
|
||||
var errorCodes = new[]
|
||||
{
|
||||
// Transient errors
|
||||
DefaultErrorClassifier.ErrorCodes.NetworkTimeout,
|
||||
DefaultErrorClassifier.ErrorCodes.ConnectionRefused,
|
||||
DefaultErrorClassifier.ErrorCodes.DnsResolutionFailed,
|
||||
DefaultErrorClassifier.ErrorCodes.ServiceUnavailable,
|
||||
DefaultErrorClassifier.ErrorCodes.GatewayTimeout,
|
||||
// Not found errors
|
||||
DefaultErrorClassifier.ErrorCodes.ImageNotFound,
|
||||
DefaultErrorClassifier.ErrorCodes.SourceNotFound,
|
||||
DefaultErrorClassifier.ErrorCodes.RegistryNotFound,
|
||||
// Auth errors
|
||||
DefaultErrorClassifier.ErrorCodes.InvalidCredentials,
|
||||
DefaultErrorClassifier.ErrorCodes.TokenExpired,
|
||||
DefaultErrorClassifier.ErrorCodes.InsufficientPermissions,
|
||||
// Rate limit errors
|
||||
DefaultErrorClassifier.ErrorCodes.RateLimited,
|
||||
DefaultErrorClassifier.ErrorCodes.QuotaExceeded,
|
||||
// Validation errors
|
||||
DefaultErrorClassifier.ErrorCodes.InvalidPayload,
|
||||
DefaultErrorClassifier.ErrorCodes.InvalidConfiguration,
|
||||
// Upstream errors
|
||||
DefaultErrorClassifier.ErrorCodes.RegistryError,
|
||||
DefaultErrorClassifier.ErrorCodes.AdvisoryFeedError,
|
||||
// Internal errors
|
||||
DefaultErrorClassifier.ErrorCodes.InternalError,
|
||||
DefaultErrorClassifier.ErrorCodes.ProcessingError
|
||||
};
|
||||
|
||||
var responses = errorCodes.Select(code =>
|
||||
{
|
||||
var classified = classifier.Classify(code, string.Empty);
|
||||
return new ErrorCodeResponse(
|
||||
classified.ErrorCode,
|
||||
classified.Category.ToString(),
|
||||
classified.Description,
|
||||
classified.RemediationHint,
|
||||
classified.IsRetryable,
|
||||
classified.SuggestedRetryDelay?.TotalSeconds);
|
||||
}).ToList();
|
||||
|
||||
return Task.FromResult(Results.Ok(new ErrorCodeListResponse(responses)));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReplayAudit(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid entryId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IReplayAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var audits = await auditRepository.GetByEntryAsync(tenantId, entryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responses = audits.Select(a => new ReplayAuditResponse(
|
||||
a.AuditId,
|
||||
a.EntryId,
|
||||
a.AttemptNumber,
|
||||
a.Success,
|
||||
a.NewJobId,
|
||||
a.ErrorMessage,
|
||||
a.TriggeredBy,
|
||||
a.TriggeredAt,
|
||||
a.CompletedAt,
|
||||
a.InitiatedBy)).ToList();
|
||||
|
||||
return Results.Ok(new ReplayAuditListResponse(responses));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static DeadLetterStatus? TryParseDeadLetterStatus(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null :
|
||||
Enum.TryParse<DeadLetterStatus>(value, ignoreCase: true, out var status) ? status : null;
|
||||
|
||||
private static ErrorCategory? TryParseErrorCategory(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null :
|
||||
Enum.TryParse<ErrorCategory>(value, ignoreCase: true, out var category) ? category : null;
|
||||
|
||||
private static string GetCurrentUser(HttpContext context) =>
|
||||
context.User?.Identity?.Name ?? "anonymous";
|
||||
|
||||
private static bool IsMissingDeadLetterTable(PostgresException exception) =>
|
||||
string.Equals(exception.SqlState, "42P01", StringComparison.Ordinal);
|
||||
|
||||
private static DeadLetterStats CreateEmptyStats() =>
|
||||
new(
|
||||
TotalEntries: 0,
|
||||
PendingEntries: 0,
|
||||
ReplayingEntries: 0,
|
||||
ReplayedEntries: 0,
|
||||
ResolvedEntries: 0,
|
||||
ExhaustedEntries: 0,
|
||||
ExpiredEntries: 0,
|
||||
RetryableEntries: 0,
|
||||
ByCategory: new Dictionary<ErrorCategory, long>(),
|
||||
TopErrorCodes: new Dictionary<string, long>(),
|
||||
TopJobTypes: new Dictionary<string, long>());
|
||||
|
||||
private static string BuildDeadLetterCsv(IReadOnlyList<DeadLetterEntry> entries)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("entryId,jobId,status,errorCode,category,retryable,replayAttempts,maxReplayAttempts,failedAt,createdAt,resolvedAt,reason");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
builder.Append(EscapeCsv(entry.EntryId.ToString())).Append(',');
|
||||
builder.Append(EscapeCsv(entry.OriginalJobId.ToString())).Append(',');
|
||||
builder.Append(EscapeCsv(entry.Status.ToString())).Append(',');
|
||||
builder.Append(EscapeCsv(entry.ErrorCode)).Append(',');
|
||||
builder.Append(EscapeCsv(entry.Category.ToString())).Append(',');
|
||||
builder.Append(EscapeCsv(entry.IsRetryable.ToString(CultureInfo.InvariantCulture))).Append(',');
|
||||
builder.Append(EscapeCsv(entry.ReplayAttempts.ToString(CultureInfo.InvariantCulture))).Append(',');
|
||||
builder.Append(EscapeCsv(entry.MaxReplayAttempts.ToString(CultureInfo.InvariantCulture))).Append(',');
|
||||
builder.Append(EscapeCsv(entry.FailedAt.ToString("O", CultureInfo.InvariantCulture))).Append(',');
|
||||
builder.Append(EscapeCsv(entry.CreatedAt.ToString("O", CultureInfo.InvariantCulture))).Append(',');
|
||||
builder.Append(EscapeCsv(entry.ResolvedAt?.ToString("O", CultureInfo.InvariantCulture))).Append(',');
|
||||
builder.Append(EscapeCsv(entry.FailureReason));
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
|
||||
}
|
||||
}
|
||||
|
||||
// Response DTOs
|
||||
|
||||
public sealed record DeadLetterEntryResponse(
|
||||
Guid EntryId,
|
||||
Guid OriginalJobId,
|
||||
Guid? RunId,
|
||||
Guid? SourceId,
|
||||
string JobType,
|
||||
string Status,
|
||||
string ErrorCode,
|
||||
string FailureReason,
|
||||
string? RemediationHint,
|
||||
string Category,
|
||||
bool IsRetryable,
|
||||
int OriginalAttempts,
|
||||
int ReplayAttempts,
|
||||
int MaxReplayAttempts,
|
||||
bool CanReplay,
|
||||
DateTimeOffset FailedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset? ResolvedAt)
|
||||
{
|
||||
public static DeadLetterEntryResponse FromDomain(DeadLetterEntry entry) =>
|
||||
new(
|
||||
entry.EntryId,
|
||||
entry.OriginalJobId,
|
||||
entry.RunId,
|
||||
entry.SourceId,
|
||||
entry.JobType,
|
||||
entry.Status.ToString(),
|
||||
entry.ErrorCode,
|
||||
entry.FailureReason,
|
||||
entry.RemediationHint,
|
||||
entry.Category.ToString(),
|
||||
entry.IsRetryable,
|
||||
entry.OriginalAttempts,
|
||||
entry.ReplayAttempts,
|
||||
entry.MaxReplayAttempts,
|
||||
entry.CanReplay,
|
||||
entry.FailedAt,
|
||||
entry.CreatedAt,
|
||||
entry.ExpiresAt,
|
||||
entry.ResolvedAt);
|
||||
}
|
||||
|
||||
public sealed record DeadLetterEntryDetailResponse(
|
||||
Guid EntryId,
|
||||
Guid OriginalJobId,
|
||||
Guid? RunId,
|
||||
Guid? SourceId,
|
||||
string JobType,
|
||||
string Payload,
|
||||
string PayloadDigest,
|
||||
string IdempotencyKey,
|
||||
string? CorrelationId,
|
||||
string Status,
|
||||
string ErrorCode,
|
||||
string FailureReason,
|
||||
string? RemediationHint,
|
||||
string Category,
|
||||
bool IsRetryable,
|
||||
int OriginalAttempts,
|
||||
int ReplayAttempts,
|
||||
int MaxReplayAttempts,
|
||||
bool CanReplay,
|
||||
DateTimeOffset FailedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? ResolutionNotes,
|
||||
string CreatedBy,
|
||||
string UpdatedBy)
|
||||
{
|
||||
public static DeadLetterEntryDetailResponse FromDomain(DeadLetterEntry entry) =>
|
||||
new(
|
||||
entry.EntryId,
|
||||
entry.OriginalJobId,
|
||||
entry.RunId,
|
||||
entry.SourceId,
|
||||
entry.JobType,
|
||||
entry.Payload,
|
||||
entry.PayloadDigest,
|
||||
entry.IdempotencyKey,
|
||||
entry.CorrelationId,
|
||||
entry.Status.ToString(),
|
||||
entry.ErrorCode,
|
||||
entry.FailureReason,
|
||||
entry.RemediationHint,
|
||||
entry.Category.ToString(),
|
||||
entry.IsRetryable,
|
||||
entry.OriginalAttempts,
|
||||
entry.ReplayAttempts,
|
||||
entry.MaxReplayAttempts,
|
||||
entry.CanReplay,
|
||||
entry.FailedAt,
|
||||
entry.CreatedAt,
|
||||
entry.UpdatedAt,
|
||||
entry.ExpiresAt,
|
||||
entry.ResolvedAt,
|
||||
entry.ResolutionNotes,
|
||||
entry.CreatedBy,
|
||||
entry.UpdatedBy);
|
||||
}
|
||||
|
||||
public sealed record DeadLetterListResponse(
|
||||
IReadOnlyList<DeadLetterEntryResponse> Entries,
|
||||
string? NextCursor,
|
||||
long TotalCount);
|
||||
|
||||
public sealed record DeadLetterStatsResponse(
|
||||
long TotalEntries,
|
||||
long PendingEntries,
|
||||
long ReplayingEntries,
|
||||
long ReplayedEntries,
|
||||
long ResolvedEntries,
|
||||
long ExhaustedEntries,
|
||||
long ExpiredEntries,
|
||||
long RetryableEntries,
|
||||
IDictionary<string, long> ByCategory,
|
||||
IDictionary<string, long> TopErrorCodes,
|
||||
IDictionary<string, long> TopJobTypes)
|
||||
{
|
||||
public static DeadLetterStatsResponse FromDomain(DeadLetterStats stats) =>
|
||||
new(
|
||||
stats.TotalEntries,
|
||||
stats.PendingEntries,
|
||||
stats.ReplayingEntries,
|
||||
stats.ReplayedEntries,
|
||||
stats.ResolvedEntries,
|
||||
stats.ExhaustedEntries,
|
||||
stats.ExpiredEntries,
|
||||
stats.RetryableEntries,
|
||||
stats.ByCategory.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value),
|
||||
new Dictionary<string, long>(stats.TopErrorCodes),
|
||||
new Dictionary<string, long>(stats.TopJobTypes));
|
||||
}
|
||||
|
||||
public sealed record DeadLetterSummaryResponse(
|
||||
string ErrorCode,
|
||||
string Category,
|
||||
long EntryCount,
|
||||
long RetryableCount,
|
||||
DateTimeOffset OldestEntry,
|
||||
string? SampleReason);
|
||||
|
||||
public sealed record DeadLetterSummaryListResponse(
|
||||
IReadOnlyList<DeadLetterSummaryResponse> Summaries);
|
||||
|
||||
public sealed record ReplayResultResponse(
|
||||
bool Success,
|
||||
Guid? NewJobId,
|
||||
string? ErrorMessage,
|
||||
DeadLetterEntryResponse? UpdatedEntry);
|
||||
|
||||
public sealed record BatchReplayResultResponse(
|
||||
int Attempted,
|
||||
int Succeeded,
|
||||
int Failed,
|
||||
IReadOnlyList<ReplayResultResponse> Results);
|
||||
|
||||
public sealed record ReplayBatchRequest(
|
||||
IReadOnlyList<Guid> EntryIds);
|
||||
|
||||
public sealed record ReplayPendingRequest(
|
||||
string? ErrorCode,
|
||||
string? Category,
|
||||
int? MaxCount);
|
||||
|
||||
public sealed record ResolveEntryRequest(
|
||||
string Notes);
|
||||
|
||||
public sealed record ResolveBatchRequest(
|
||||
IReadOnlyList<Guid> EntryIds,
|
||||
string Notes);
|
||||
|
||||
public sealed record ErrorCodeResponse(
|
||||
string ErrorCode,
|
||||
string Category,
|
||||
string Description,
|
||||
string RemediationHint,
|
||||
bool IsRetryable,
|
||||
double? SuggestedRetryDelaySeconds);
|
||||
|
||||
public sealed record ErrorCodeListResponse(
|
||||
IReadOnlyList<ErrorCodeResponse> ErrorCodes);
|
||||
|
||||
public sealed record ReplayAuditResponse(
|
||||
Guid AuditId,
|
||||
Guid EntryId,
|
||||
int AttemptNumber,
|
||||
bool Success,
|
||||
Guid? NewJobId,
|
||||
string? ErrorMessage,
|
||||
string TriggeredBy,
|
||||
DateTimeOffset TriggeredAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string InitiatedBy);
|
||||
|
||||
public sealed record ReplayAuditListResponse(
|
||||
IReadOnlyList<ReplayAuditResponse> Audits);
|
||||
@@ -0,0 +1,388 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Core.Domain.Export;
|
||||
using StellaOps.JobEngine.Core.Services;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for export job management.
|
||||
/// </summary>
|
||||
public static class ExportJobEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps export job endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static void MapExportJobEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/export")
|
||||
.WithTags("Export Jobs")
|
||||
.RequireAuthorization(JobEnginePolicies.ExportViewer)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("jobs", CreateExportJob)
|
||||
.WithName("Orchestrator_CreateExportJob")
|
||||
.WithDescription(_t("orchestrator.export_job.create_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ExportOperator);
|
||||
|
||||
group.MapGet("jobs", ListExportJobs)
|
||||
.WithName("Orchestrator_ListExportJobs")
|
||||
.WithDescription(_t("orchestrator.export_job.list_description"));
|
||||
|
||||
group.MapGet("jobs/{jobId:guid}", GetExportJob)
|
||||
.WithName("Orchestrator_GetExportJob")
|
||||
.WithDescription(_t("orchestrator.export_job.get_description"));
|
||||
|
||||
group.MapPost("jobs/{jobId:guid}/cancel", CancelExportJob)
|
||||
.WithName("Orchestrator_CancelExportJob")
|
||||
.WithDescription(_t("orchestrator.export_job.cancel_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ExportOperator);
|
||||
|
||||
group.MapGet("quota", GetQuotaStatus)
|
||||
.WithName("Orchestrator_GetExportQuotaStatus")
|
||||
.WithDescription(_t("orchestrator.export_job.quota_status_description"));
|
||||
|
||||
group.MapPost("quota", EnsureQuota)
|
||||
.WithName("Orchestrator_EnsureExportQuota")
|
||||
.WithDescription(_t("orchestrator.export_job.ensure_quota_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ExportOperator);
|
||||
|
||||
group.MapGet("types", GetExportTypes)
|
||||
.WithName("Orchestrator_GetExportTypes")
|
||||
.WithDescription(_t("orchestrator.export_job.types_description"));
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<ExportJobResponse>, BadRequest<ErrorResponse>, Conflict<ErrorResponse>>> CreateExportJob(
|
||||
CreateExportJobRequest request,
|
||||
IExportJobService exportJobService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ExportType))
|
||||
{
|
||||
return TypedResults.BadRequest(new ErrorResponse("invalid_export_type", _t("orchestrator.export_job.error.export_type_required")));
|
||||
}
|
||||
|
||||
if (!ExportJobTypes.IsExportJob(request.ExportType) && !ExportJobTypes.All.Contains(request.ExportType))
|
||||
{
|
||||
return TypedResults.BadRequest(new ErrorResponse("invalid_export_type", _t("orchestrator.export_job.error.unknown_export_type", request.ExportType)));
|
||||
}
|
||||
|
||||
var payload = new ExportJobPayload(
|
||||
Format: request.Format ?? "json",
|
||||
StartTime: request.StartTime,
|
||||
EndTime: request.EndTime,
|
||||
SourceId: request.SourceId,
|
||||
ProjectId: request.ProjectId,
|
||||
EntityIds: request.EntityIds,
|
||||
MaxEntries: request.MaxEntries,
|
||||
IncludeProvenance: request.IncludeProvenance ?? true,
|
||||
SignOutput: request.SignOutput ?? true,
|
||||
Compression: request.Compression,
|
||||
DestinationUri: request.DestinationUri,
|
||||
CallbackUrl: request.CallbackUrl,
|
||||
Options: request.Options);
|
||||
|
||||
try
|
||||
{
|
||||
var job = await exportJobService.CreateExportJobAsync(
|
||||
tenantId,
|
||||
request.ExportType,
|
||||
payload,
|
||||
GetActorId(context),
|
||||
request.ProjectId,
|
||||
request.CorrelationId,
|
||||
request.Priority,
|
||||
cancellationToken);
|
||||
|
||||
var response = MapToResponse(job);
|
||||
return TypedResults.Created($"/api/v1/jobengine/export/jobs/{job.JobId}", response);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return TypedResults.Conflict(new ErrorResponse("quota_exceeded", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Ok<ExportJobListResponse>> ListExportJobs(
|
||||
IExportJobService exportJobService,
|
||||
HttpContext context,
|
||||
string? exportType = null,
|
||||
string? status = null,
|
||||
string? projectId = null,
|
||||
DateTimeOffset? createdAfter = null,
|
||||
DateTimeOffset? createdBefore = null,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
JobStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<JobStatus>(status, true, out var parsed))
|
||||
{
|
||||
statusFilter = parsed;
|
||||
}
|
||||
|
||||
var jobs = await exportJobService.ListExportJobsAsync(
|
||||
tenantId,
|
||||
exportType,
|
||||
statusFilter,
|
||||
projectId,
|
||||
createdAfter,
|
||||
createdBefore,
|
||||
limit,
|
||||
offset,
|
||||
cancellationToken);
|
||||
|
||||
var response = new ExportJobListResponse(
|
||||
Items: jobs.Select(MapToResponse).ToList(),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
HasMore: jobs.Count == limit);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportJobResponse>, NotFound>> GetExportJob(
|
||||
Guid jobId,
|
||||
IExportJobService exportJobService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
var job = await exportJobService.GetExportJobAsync(tenantId, jobId, cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(MapToResponse(job));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CancelExportJobResponse>, NotFound, BadRequest<ErrorResponse>>> CancelExportJob(
|
||||
Guid jobId,
|
||||
CancelExportJobRequest request,
|
||||
IExportJobService exportJobService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
var success = await exportJobService.CancelExportJobAsync(
|
||||
tenantId,
|
||||
jobId,
|
||||
request.Reason ?? "Canceled by user",
|
||||
GetActorId(context),
|
||||
cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
var job = await exportJobService.GetExportJobAsync(tenantId, jobId, cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.BadRequest(new ErrorResponse(
|
||||
"cannot_cancel",
|
||||
_t("orchestrator.export_job.error.cannot_cancel", job.Status)));
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new CancelExportJobResponse(jobId, true, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private static async Task<Ok<ExportQuotaStatusResponse>> GetQuotaStatus(
|
||||
IExportJobService exportJobService,
|
||||
HttpContext context,
|
||||
string? exportType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
var status = await exportJobService.GetQuotaStatusAsync(tenantId, exportType, cancellationToken);
|
||||
|
||||
var response = new ExportQuotaStatusResponse(
|
||||
MaxActive: status.MaxActive,
|
||||
CurrentActive: status.CurrentActive,
|
||||
MaxPerHour: status.MaxPerHour,
|
||||
CurrentHourCount: status.CurrentHourCount,
|
||||
AvailableTokens: status.AvailableTokens,
|
||||
Paused: status.Paused,
|
||||
PauseReason: status.PauseReason,
|
||||
CanCreateJob: status.CanCreateJob,
|
||||
EstimatedWaitSeconds: status.EstimatedWaitTime?.TotalSeconds);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<Created<QuotaResponse>> EnsureQuota(
|
||||
EnsureExportQuotaRequest request,
|
||||
IExportJobService exportJobService,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
|
||||
var quota = await exportJobService.EnsureQuotaAsync(
|
||||
tenantId,
|
||||
request.ExportType,
|
||||
GetActorId(context),
|
||||
cancellationToken);
|
||||
|
||||
var response = QuotaResponse.FromDomain(quota);
|
||||
|
||||
return TypedResults.Created($"/api/v1/jobengine/quotas/{quota.QuotaId}", response);
|
||||
}
|
||||
|
||||
private static Ok<ExportTypesResponse> GetExportTypes()
|
||||
{
|
||||
var types = ExportJobTypes.All.Select(jobType =>
|
||||
{
|
||||
var rateLimit = ExportJobPolicy.RateLimits.GetForJobType(jobType);
|
||||
var target = ExportJobTypes.GetExportTarget(jobType) ?? "unknown";
|
||||
|
||||
return new ExportTypeInfo(
|
||||
JobType: jobType,
|
||||
Target: target,
|
||||
MaxConcurrent: rateLimit.MaxConcurrent,
|
||||
MaxPerHour: rateLimit.MaxPerHour,
|
||||
EstimatedDurationSeconds: rateLimit.EstimatedDurationSeconds);
|
||||
}).ToList();
|
||||
|
||||
return TypedResults.Ok(new ExportTypesResponse(
|
||||
Types: types,
|
||||
DefaultQuota: new DefaultQuotaInfo(
|
||||
MaxActive: ExportJobPolicy.QuotaDefaults.MaxActive,
|
||||
MaxPerHour: ExportJobPolicy.QuotaDefaults.MaxPerHour,
|
||||
BurstCapacity: ExportJobPolicy.QuotaDefaults.BurstCapacity,
|
||||
RefillRate: ExportJobPolicy.QuotaDefaults.RefillRate,
|
||||
DefaultPriority: ExportJobPolicy.QuotaDefaults.DefaultPriority,
|
||||
MaxAttempts: ExportJobPolicy.QuotaDefaults.MaxAttempts,
|
||||
DefaultLeaseSeconds: ExportJobPolicy.QuotaDefaults.DefaultLeaseSeconds,
|
||||
RecommendedHeartbeatInterval: ExportJobPolicy.QuotaDefaults.RecommendedHeartbeatInterval)));
|
||||
}
|
||||
|
||||
private static string GetTenantId(HttpContext context) =>
|
||||
context.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault() ?? "default";
|
||||
|
||||
private static string GetActorId(HttpContext context) =>
|
||||
context.User.Identity?.Name ?? "anonymous";
|
||||
|
||||
private static ExportJobResponse MapToResponse(Job job) => new(
|
||||
JobId: job.JobId,
|
||||
TenantId: job.TenantId,
|
||||
ProjectId: job.ProjectId,
|
||||
ExportType: job.JobType,
|
||||
Status: job.Status.ToString(),
|
||||
Priority: job.Priority,
|
||||
Attempt: job.Attempt,
|
||||
MaxAttempts: job.MaxAttempts,
|
||||
PayloadDigest: job.PayloadDigest,
|
||||
IdempotencyKey: job.IdempotencyKey,
|
||||
CorrelationId: job.CorrelationId,
|
||||
WorkerId: job.WorkerId,
|
||||
LeaseUntil: job.LeaseUntil,
|
||||
CreatedAt: job.CreatedAt,
|
||||
ScheduledAt: job.ScheduledAt,
|
||||
LeasedAt: job.LeasedAt,
|
||||
CompletedAt: job.CompletedAt,
|
||||
Reason: job.Reason,
|
||||
CreatedBy: job.CreatedBy);
|
||||
}
|
||||
|
||||
// Request/Response records
|
||||
|
||||
public sealed record CreateExportJobRequest(
|
||||
string ExportType,
|
||||
string? Format,
|
||||
DateTimeOffset? StartTime,
|
||||
DateTimeOffset? EndTime,
|
||||
Guid? SourceId,
|
||||
string? ProjectId,
|
||||
IReadOnlyList<Guid>? EntityIds,
|
||||
int? MaxEntries,
|
||||
bool? IncludeProvenance,
|
||||
bool? SignOutput,
|
||||
string? Compression,
|
||||
string? DestinationUri,
|
||||
string? CallbackUrl,
|
||||
string? CorrelationId,
|
||||
int? Priority,
|
||||
IReadOnlyDictionary<string, string>? Options);
|
||||
|
||||
public sealed record ExportJobResponse(
|
||||
Guid JobId,
|
||||
string TenantId,
|
||||
string? ProjectId,
|
||||
string ExportType,
|
||||
string Status,
|
||||
int Priority,
|
||||
int Attempt,
|
||||
int MaxAttempts,
|
||||
string PayloadDigest,
|
||||
string IdempotencyKey,
|
||||
string? CorrelationId,
|
||||
string? WorkerId,
|
||||
DateTimeOffset? LeaseUntil,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ScheduledAt,
|
||||
DateTimeOffset? LeasedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? Reason,
|
||||
string CreatedBy);
|
||||
|
||||
public sealed record ExportJobListResponse(
|
||||
IReadOnlyList<ExportJobResponse> Items,
|
||||
int Limit,
|
||||
int Offset,
|
||||
bool HasMore);
|
||||
|
||||
public sealed record CancelExportJobRequest(string? Reason);
|
||||
|
||||
public sealed record CancelExportJobResponse(
|
||||
Guid JobId,
|
||||
bool Canceled,
|
||||
DateTimeOffset CanceledAt);
|
||||
|
||||
public sealed record ExportQuotaStatusResponse(
|
||||
int MaxActive,
|
||||
int CurrentActive,
|
||||
int MaxPerHour,
|
||||
int CurrentHourCount,
|
||||
double AvailableTokens,
|
||||
bool Paused,
|
||||
string? PauseReason,
|
||||
bool CanCreateJob,
|
||||
double? EstimatedWaitSeconds);
|
||||
|
||||
public sealed record EnsureExportQuotaRequest(string ExportType);
|
||||
|
||||
public sealed record ExportTypesResponse(
|
||||
IReadOnlyList<ExportTypeInfo> Types,
|
||||
DefaultQuotaInfo DefaultQuota);
|
||||
|
||||
public sealed record ExportTypeInfo(
|
||||
string JobType,
|
||||
string Target,
|
||||
int MaxConcurrent,
|
||||
int MaxPerHour,
|
||||
int EstimatedDurationSeconds);
|
||||
|
||||
public sealed record DefaultQuotaInfo(
|
||||
int MaxActive,
|
||||
int MaxPerHour,
|
||||
int BurstCapacity,
|
||||
double RefillRate,
|
||||
int DefaultPriority,
|
||||
int MaxAttempts,
|
||||
int DefaultLeaseSeconds,
|
||||
int RecommendedHeartbeatInterval);
|
||||
|
||||
public sealed record ErrorResponse(string Error, string Message);
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Services;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoint for first signal (TTFS).
|
||||
/// </summary>
|
||||
public static class FirstSignalEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapFirstSignalEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/runs")
|
||||
.WithTags("Orchestrator Runs")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("{runId:guid}/first-signal", GetFirstSignal)
|
||||
.WithName("Orchestrator_GetFirstSignal")
|
||||
.WithDescription(_t("orchestrator.first_signal.get_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFirstSignal(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IFirstSignalService firstSignalService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await firstSignalService
|
||||
.GetFirstSignalAsync(runId, tenantId, ifNoneMatch, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
context.Response.Headers["Cache-Status"] = result.CacheHit ? "hit" : "miss";
|
||||
if (!string.IsNullOrWhiteSpace(result.Source))
|
||||
{
|
||||
context.Response.Headers["X-FirstSignal-Source"] = result.Source;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ETag))
|
||||
{
|
||||
context.Response.Headers.ETag = result.ETag;
|
||||
context.Response.Headers.CacheControl = "private, max-age=60";
|
||||
}
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
FirstSignalResultStatus.Found => Results.Ok(MapToResponse(runId, result)),
|
||||
FirstSignalResultStatus.NotModified => Results.StatusCode(StatusCodes.Status304NotModified),
|
||||
FirstSignalResultStatus.NotFound => Results.NotFound(),
|
||||
FirstSignalResultStatus.NotAvailable => Results.NoContent(),
|
||||
_ => Results.Problem(_t("orchestrator.first_signal.error.server_error"))
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static FirstSignalResponse MapToResponse(Guid runId, FirstSignalResult result)
|
||||
{
|
||||
if (result.Signal is null)
|
||||
{
|
||||
return new FirstSignalResponse
|
||||
{
|
||||
RunId = runId,
|
||||
FirstSignal = null,
|
||||
SummaryEtag = result.ETag ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
var signal = result.Signal;
|
||||
|
||||
return new FirstSignalResponse
|
||||
{
|
||||
RunId = runId,
|
||||
SummaryEtag = result.ETag ?? string.Empty,
|
||||
FirstSignal = new FirstSignalDto
|
||||
{
|
||||
Type = signal.Kind.ToString().ToLowerInvariant(),
|
||||
Stage = signal.Phase.ToString().ToLowerInvariant(),
|
||||
Step = null,
|
||||
Message = signal.Summary,
|
||||
At = signal.Timestamp,
|
||||
Artifact = new FirstSignalArtifactDto
|
||||
{
|
||||
Kind = signal.Scope.Type,
|
||||
Range = null
|
||||
},
|
||||
LastKnownOutcome = signal.LastKnownOutcome is null
|
||||
? null
|
||||
: new FirstSignalLastKnownOutcomeDto
|
||||
{
|
||||
SignatureId = signal.LastKnownOutcome.SignatureId,
|
||||
ErrorCode = signal.LastKnownOutcome.ErrorCode,
|
||||
Token = signal.LastKnownOutcome.Token,
|
||||
Excerpt = signal.LastKnownOutcome.Excerpt,
|
||||
Confidence = signal.LastKnownOutcome.Confidence,
|
||||
FirstSeenAt = signal.LastKnownOutcome.FirstSeenAt,
|
||||
HitCount = signal.LastKnownOutcome.HitCount
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.JobEngine.Infrastructure.Postgres;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Health and readiness probe endpoints.
|
||||
/// </summary>
|
||||
public static class HealthEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps health endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapHealthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/healthz", GetHealth)
|
||||
.WithName("Orchestrator_Health")
|
||||
.WithTags("Health")
|
||||
.WithDescription(_t("orchestrator.health.liveness_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/readyz", GetReadiness)
|
||||
.WithName("Orchestrator_Readiness")
|
||||
.WithTags("Health")
|
||||
.WithDescription(_t("orchestrator.health.readiness_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/livez", GetLiveness)
|
||||
.WithName("Orchestrator_Liveness")
|
||||
.WithTags("Health")
|
||||
.WithDescription(_t("orchestrator.health.liveness_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/health/details", GetHealthDetails)
|
||||
.WithName("Orchestrator_HealthDetails")
|
||||
.WithTags("Health")
|
||||
.WithDescription(_t("orchestrator.health.deep_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IResult GetHealth([FromServices] TimeProvider timeProvider)
|
||||
{
|
||||
return Results.Ok(new HealthResponse("ok", timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReadiness(
|
||||
[FromServices] JobEngineDataSource dataSource,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check database connectivity
|
||||
var dbHealthy = await CheckDatabaseAsync(dataSource, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!dbHealthy)
|
||||
{
|
||||
return Results.Json(
|
||||
new ReadinessResponse("not_ready", timeProvider.GetUtcNow(), new Dictionary<string, string>
|
||||
{
|
||||
["database"] = "unhealthy"
|
||||
}),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Results.Ok(new ReadinessResponse("ready", timeProvider.GetUtcNow(), new Dictionary<string, string>
|
||||
{
|
||||
["database"] = "healthy"
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Json(
|
||||
new ReadinessResponse("not_ready", timeProvider.GetUtcNow(), new Dictionary<string, string>
|
||||
{
|
||||
["database"] = $"error: {ex.Message}"
|
||||
}),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetLiveness([FromServices] TimeProvider timeProvider)
|
||||
{
|
||||
// Liveness just checks the process is alive
|
||||
return Results.Ok(new HealthResponse("alive", timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetHealthDetails(
|
||||
[FromServices] JobEngineDataSource dataSource,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var checks = new Dictionary<string, HealthCheckResult>();
|
||||
var overallHealthy = true;
|
||||
|
||||
// Database check
|
||||
try
|
||||
{
|
||||
var dbHealthy = await CheckDatabaseAsync(dataSource, cancellationToken).ConfigureAwait(false);
|
||||
checks["database"] = new HealthCheckResult(
|
||||
dbHealthy ? "healthy" : "unhealthy",
|
||||
dbHealthy ? null : "Connection test failed",
|
||||
timeProvider.GetUtcNow());
|
||||
overallHealthy &= dbHealthy;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
checks["database"] = new HealthCheckResult("unhealthy", ex.Message, timeProvider.GetUtcNow());
|
||||
overallHealthy = false;
|
||||
}
|
||||
|
||||
// Memory check
|
||||
var memoryInfo = GC.GetGCMemoryInfo();
|
||||
var memoryUsedMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
|
||||
var memoryLimitMb = memoryInfo.TotalAvailableMemoryBytes / (1024.0 * 1024.0);
|
||||
var memoryHealthy = memoryUsedMb < memoryLimitMb * 0.9; // < 90% threshold
|
||||
|
||||
checks["memory"] = new HealthCheckResult(
|
||||
memoryHealthy ? "healthy" : "degraded",
|
||||
$"Used: {memoryUsedMb:F2} MB",
|
||||
timeProvider.GetUtcNow());
|
||||
|
||||
// Thread pool check
|
||||
ThreadPool.GetAvailableThreads(out var workerThreads, out var completionPortThreads);
|
||||
ThreadPool.GetMaxThreads(out var maxWorkerThreads, out var maxCompletionPortThreads);
|
||||
var threadPoolHealthy = workerThreads > maxWorkerThreads * 0.1; // > 10% available
|
||||
|
||||
checks["threadPool"] = new HealthCheckResult(
|
||||
threadPoolHealthy ? "healthy" : "degraded",
|
||||
$"Worker threads available: {workerThreads}/{maxWorkerThreads}",
|
||||
timeProvider.GetUtcNow());
|
||||
|
||||
var response = new HealthDetailsResponse(
|
||||
overallHealthy ? "healthy" : "unhealthy",
|
||||
timeProvider.GetUtcNow(),
|
||||
checks);
|
||||
|
||||
return overallHealthy
|
||||
? Results.Ok(response)
|
||||
: Results.Json(response, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckDatabaseAsync(JobEngineDataSource dataSource, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use a system tenant for health checks
|
||||
await using var connection = await dataSource.OpenConnectionAsync("_system", "health", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1";
|
||||
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Basic health response.
|
||||
/// </summary>
|
||||
public sealed record HealthResponse(string Status, DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Readiness response with dependency status.
|
||||
/// </summary>
|
||||
public sealed record ReadinessResponse(
|
||||
string Status,
|
||||
DateTimeOffset Timestamp,
|
||||
IReadOnlyDictionary<string, string> Dependencies);
|
||||
|
||||
/// <summary>
|
||||
/// Individual health check result.
|
||||
/// </summary>
|
||||
public sealed record HealthCheckResult(
|
||||
string Status,
|
||||
string? Details,
|
||||
DateTimeOffset CheckedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Detailed health response with all checks.
|
||||
/// </summary>
|
||||
public sealed record HealthDetailsResponse(
|
||||
string Status,
|
||||
DateTimeOffset Timestamp,
|
||||
IReadOnlyDictionary<string, HealthCheckResult> Checks);
|
||||
@@ -0,0 +1,212 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
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 jobs.
|
||||
/// </summary>
|
||||
public static class JobEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps job endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapJobEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/jobs")
|
||||
.WithTags("Orchestrator Jobs")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet(string.Empty, ListJobs)
|
||||
.WithName("Orchestrator_ListJobs")
|
||||
.WithDescription(_t("orchestrator.job.list_description"));
|
||||
|
||||
group.MapGet("{jobId:guid}", GetJob)
|
||||
.WithName("Orchestrator_GetJob")
|
||||
.WithDescription(_t("orchestrator.job.get_description"));
|
||||
|
||||
group.MapGet("{jobId:guid}/detail", GetJobDetail)
|
||||
.WithName("Orchestrator_GetJobDetail")
|
||||
.WithDescription(_t("orchestrator.job.get_detail_description"));
|
||||
|
||||
group.MapGet("summary", GetJobSummary)
|
||||
.WithName("Orchestrator_GetJobSummary")
|
||||
.WithDescription(_t("orchestrator.job.get_summary_description"));
|
||||
|
||||
group.MapGet("by-idempotency-key/{key}", GetJobByIdempotencyKey)
|
||||
.WithName("Orchestrator_GetJobByIdempotencyKey")
|
||||
.WithDescription(_t("orchestrator.job.get_by_idempotency_key_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListJobs(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository repository,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? jobType = null,
|
||||
[FromQuery] string? projectId = null,
|
||||
[FromQuery] string? createdAfter = null,
|
||||
[FromQuery] string? createdBefore = 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 parsedStatus = EndpointHelpers.TryParseJobStatus(status);
|
||||
var parsedCreatedAfter = EndpointHelpers.TryParseDateTimeOffset(createdAfter);
|
||||
var parsedCreatedBefore = EndpointHelpers.TryParseDateTimeOffset(createdBefore);
|
||||
|
||||
var jobs = await repository.ListAsync(
|
||||
tenantId,
|
||||
parsedStatus,
|
||||
jobType,
|
||||
projectId,
|
||||
parsedCreatedAfter,
|
||||
parsedCreatedBefore,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = jobs.Select(JobResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new JobListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJob(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var job = await repository.GetByIdAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(JobResponse.FromDomain(job));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobDetail(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
DeprecationHeaders.Apply(context.Response, "/api/v1/jobengine/jobs/{jobId}");
|
||||
|
||||
var job = await repository.GetByIdAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(JobDetailResponse.FromDomain(job));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobSummary(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository repository,
|
||||
[FromQuery] string? jobType = null,
|
||||
[FromQuery] string? projectId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
DeprecationHeaders.Apply(context.Response, "/api/v1/jobengine/jobs");
|
||||
|
||||
// Get counts for each status
|
||||
var pending = await repository.CountAsync(tenantId, Core.Domain.JobStatus.Pending, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
var scheduled = await repository.CountAsync(tenantId, Core.Domain.JobStatus.Scheduled, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
var leased = await repository.CountAsync(tenantId, Core.Domain.JobStatus.Leased, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
var succeeded = await repository.CountAsync(tenantId, Core.Domain.JobStatus.Succeeded, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
var failed = await repository.CountAsync(tenantId, Core.Domain.JobStatus.Failed, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
var canceled = await repository.CountAsync(tenantId, Core.Domain.JobStatus.Canceled, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
var timedOut = await repository.CountAsync(tenantId, Core.Domain.JobStatus.TimedOut, jobType, projectId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var summary = new JobSummary(
|
||||
TotalJobs: pending + scheduled + leased + succeeded + failed + canceled + timedOut,
|
||||
PendingJobs: pending,
|
||||
ScheduledJobs: scheduled,
|
||||
LeasedJobs: leased,
|
||||
SucceededJobs: succeeded,
|
||||
FailedJobs: failed,
|
||||
CanceledJobs: canceled,
|
||||
TimedOutJobs: timedOut);
|
||||
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobByIdempotencyKey(
|
||||
HttpContext context,
|
||||
[FromRoute] string key,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.job.error.idempotency_key_required") });
|
||||
}
|
||||
|
||||
var job = await repository.GetByIdempotencyKeyAsync(tenantId, key, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(JobResponse.FromDomain(job));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Metrics.Kpi;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Quality KPI endpoints for explainable triage metrics.
|
||||
/// </summary>
|
||||
public static class KpiEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps KPI endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapKpiEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/metrics/kpis")
|
||||
.WithTags("Quality KPIs")
|
||||
.RequireAuthorization(JobEnginePolicies.ObservabilityRead)
|
||||
.RequireTenant();
|
||||
|
||||
// GET /api/v1/metrics/kpis
|
||||
group.MapGet("/", GetQualityKpis)
|
||||
.WithName("Orchestrator_GetQualityKpis")
|
||||
.WithDescription(_t("orchestrator.kpi.quality_description"));
|
||||
|
||||
// GET /api/v1/metrics/kpis/reachability
|
||||
group.MapGet("/reachability", GetReachabilityKpis)
|
||||
.WithName("Orchestrator_GetReachabilityKpis")
|
||||
.WithDescription(_t("orchestrator.kpi.reachability_description"));
|
||||
|
||||
// GET /api/v1/metrics/kpis/explainability
|
||||
group.MapGet("/explainability", GetExplainabilityKpis)
|
||||
.WithName("Orchestrator_GetExplainabilityKpis")
|
||||
.WithDescription(_t("orchestrator.kpi.explainability_description"));
|
||||
|
||||
// GET /api/v1/metrics/kpis/runtime
|
||||
group.MapGet("/runtime", GetRuntimeKpis)
|
||||
.WithName("Orchestrator_GetRuntimeKpis")
|
||||
.WithDescription(_t("orchestrator.kpi.runtime_description"));
|
||||
|
||||
// GET /api/v1/metrics/kpis/replay
|
||||
group.MapGet("/replay", GetReplayKpis)
|
||||
.WithName("Orchestrator_GetReplayKpis")
|
||||
.WithDescription(_t("orchestrator.kpi.replay_description"));
|
||||
|
||||
// GET /api/v1/metrics/kpis/trend
|
||||
group.MapGet("/trend", GetKpiTrend)
|
||||
.WithName("Orchestrator_GetKpiTrend")
|
||||
.WithDescription(_t("orchestrator.kpi.trend_description"));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetQualityKpis(
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IKpiCollector collector,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var start = from ?? now.AddDays(-7);
|
||||
var end = to ?? now;
|
||||
|
||||
var kpis = await collector.CollectAsync(start, end, tenant, ct);
|
||||
return Results.Ok(kpis);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReachabilityKpis(
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IKpiCollector collector,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var kpis = await collector.CollectAsync(
|
||||
from ?? now.AddDays(-7),
|
||||
to ?? now,
|
||||
tenant,
|
||||
ct);
|
||||
return Results.Ok(kpis.Reachability);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExplainabilityKpis(
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IKpiCollector collector,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var kpis = await collector.CollectAsync(
|
||||
from ?? now.AddDays(-7),
|
||||
to ?? now,
|
||||
tenant,
|
||||
ct);
|
||||
return Results.Ok(kpis.Explainability);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRuntimeKpis(
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IKpiCollector collector,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var kpis = await collector.CollectAsync(
|
||||
from ?? now.AddDays(-7),
|
||||
to ?? now,
|
||||
tenant,
|
||||
ct);
|
||||
return Results.Ok(kpis.Runtime);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReplayKpis(
|
||||
[FromQuery] DateTimeOffset? from,
|
||||
[FromQuery] DateTimeOffset? to,
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IKpiCollector collector,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var kpis = await collector.CollectAsync(
|
||||
from ?? now.AddDays(-7),
|
||||
to ?? now,
|
||||
tenant,
|
||||
ct);
|
||||
return Results.Ok(kpis.Replay);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetKpiTrend(
|
||||
[FromServices] IKpiTrendService trendService,
|
||||
CancellationToken ct,
|
||||
[FromQuery] int days = 30,
|
||||
[FromQuery] string? tenant = null)
|
||||
{
|
||||
var trend = await trendService.GetTrendAsync(days, tenant, ct);
|
||||
return Results.Ok(trend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
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 ledger operations.
|
||||
/// </summary>
|
||||
public static class LedgerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps ledger endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapLedgerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/ledger")
|
||||
.WithTags("Orchestrator Ledger")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// Ledger entry operations
|
||||
group.MapGet(string.Empty, ListLedgerEntries)
|
||||
.WithName("Orchestrator_ListLedgerEntries")
|
||||
.WithDescription(_t("orchestrator.ledger.list_description"));
|
||||
|
||||
group.MapGet("{ledgerId:guid}", GetLedgerEntry)
|
||||
.WithName("Orchestrator_GetLedgerEntry")
|
||||
.WithDescription(_t("orchestrator.ledger.get_description"));
|
||||
|
||||
group.MapGet("run/{runId:guid}", GetByRunId)
|
||||
.WithName("Orchestrator_GetLedgerByRunId")
|
||||
.WithDescription(_t("orchestrator.ledger.get_by_run_description"));
|
||||
|
||||
group.MapGet("source/{sourceId:guid}", GetBySource)
|
||||
.WithName("Orchestrator_GetLedgerBySource")
|
||||
.WithDescription(_t("orchestrator.ledger.get_by_source_description"));
|
||||
|
||||
group.MapGet("latest", GetLatestEntry)
|
||||
.WithName("Orchestrator_GetLatestLedgerEntry")
|
||||
.WithDescription(_t("orchestrator.ledger.get_latest_description"));
|
||||
|
||||
group.MapGet("sequence/{startSeq:long}/{endSeq:long}", GetBySequenceRange)
|
||||
.WithName("Orchestrator_GetLedgerBySequence")
|
||||
.WithDescription(_t("orchestrator.ledger.get_by_sequence_description"));
|
||||
|
||||
// Summary and verification
|
||||
group.MapGet("summary", GetLedgerSummary)
|
||||
.WithName("Orchestrator_GetLedgerSummary")
|
||||
.WithDescription(_t("orchestrator.ledger.summary_description"));
|
||||
|
||||
group.MapGet("verify", VerifyLedgerChain)
|
||||
.WithName("Orchestrator_VerifyLedgerChain")
|
||||
.WithDescription(_t("orchestrator.ledger.verify_chain_description"));
|
||||
|
||||
// Export operations
|
||||
group.MapGet("exports", ListExports)
|
||||
.WithName("Orchestrator_ListLedgerExports")
|
||||
.WithDescription(_t("orchestrator.ledger.list_exports_description"));
|
||||
|
||||
group.MapGet("exports/{exportId:guid}", GetExport)
|
||||
.WithName("Orchestrator_GetLedgerExport")
|
||||
.WithDescription(_t("orchestrator.ledger.get_export_description"));
|
||||
|
||||
group.MapPost("exports", CreateExport)
|
||||
.WithName("Orchestrator_CreateLedgerExport")
|
||||
.WithDescription(_t("orchestrator.ledger.create_export_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.ExportOperator);
|
||||
|
||||
// Manifest operations
|
||||
group.MapGet("manifests", ListManifests)
|
||||
.WithName("Orchestrator_ListManifests")
|
||||
.WithDescription(_t("orchestrator.ledger.list_manifests_description"));
|
||||
|
||||
group.MapGet("manifests/{manifestId:guid}", GetManifest)
|
||||
.WithName("Orchestrator_GetManifest")
|
||||
.WithDescription(_t("orchestrator.ledger.get_manifest_description"));
|
||||
|
||||
group.MapGet("manifests/subject/{subjectId:guid}", GetManifestBySubject)
|
||||
.WithName("Orchestrator_GetManifestBySubject")
|
||||
.WithDescription(_t("orchestrator.ledger.get_manifest_by_subject_description"));
|
||||
|
||||
group.MapGet("manifests/{manifestId:guid}/verify", VerifyManifest)
|
||||
.WithName("Orchestrator_VerifyManifest")
|
||||
.WithDescription(_t("orchestrator.ledger.verify_manifest_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListLedgerEntries(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] string? runType = null,
|
||||
[FromQuery] Guid? sourceId = null,
|
||||
[FromQuery] string? finalStatus = null,
|
||||
[FromQuery] DateTimeOffset? startTime = null,
|
||||
[FromQuery] DateTimeOffset? endTime = 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);
|
||||
|
||||
RunStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(finalStatus) && Enum.TryParse<RunStatus>(finalStatus, true, out var rs))
|
||||
{
|
||||
parsedStatus = rs;
|
||||
}
|
||||
|
||||
var entries = await repository.ListAsync(
|
||||
tenantId,
|
||||
runType,
|
||||
sourceId,
|
||||
parsedStatus,
|
||||
startTime,
|
||||
endTime,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new LedgerEntryListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLedgerEntry(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid ledgerId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByIdAsync(tenantId, ledgerId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetByRunId(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBySource(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sourceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = EndpointHelpers.GetLimit(limit);
|
||||
|
||||
var entries = await repository.GetBySourceAsync(
|
||||
tenantId,
|
||||
sourceId,
|
||||
effectiveLimit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList();
|
||||
return Results.Ok(new LedgerEntryListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLatestEntry(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var entry = await repository.GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerEntryResponse.FromDomain(entry));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBySequenceRange(
|
||||
HttpContext context,
|
||||
[FromRoute] long startSeq,
|
||||
[FromRoute] long endSeq,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
if (startSeq < 1 || endSeq < startSeq)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.ledger.error.invalid_sequence_range") });
|
||||
}
|
||||
|
||||
var entries = await repository.GetBySequenceRangeAsync(
|
||||
tenantId,
|
||||
startSeq,
|
||||
endSeq,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = entries.Select(LedgerEntryResponse.FromDomain).ToList();
|
||||
return Results.Ok(new LedgerEntryListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLedgerSummary(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var summary = await repository.GetSummaryAsync(tenantId, since, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(LedgerSummaryResponse.FromDomain(summary));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyLedgerChain(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerRepository repository,
|
||||
[FromQuery] long? startSeq = null,
|
||||
[FromQuery] long? endSeq = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await repository.VerifyChainAsync(tenantId, startSeq, endSeq, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Infrastructure.JobEngineMetrics.LedgerChainVerified(tenantId, result.IsValid);
|
||||
|
||||
return Results.Ok(ChainVerificationResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListExports(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerExportRepository repository,
|
||||
[FromQuery] string? status = 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);
|
||||
|
||||
LedgerExportStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<LedgerExportStatus>(status, true, out var es))
|
||||
{
|
||||
parsedStatus = es;
|
||||
}
|
||||
|
||||
var exports = await repository.ListAsync(
|
||||
tenantId,
|
||||
parsedStatus,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = exports.Select(LedgerExportResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new LedgerExportListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExport(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid exportId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerExportRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var export = await repository.GetByIdAsync(tenantId, exportId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (export is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(LedgerExportResponse.FromDomain(export));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateExport(
|
||||
HttpContext context,
|
||||
[FromBody] CreateLedgerExportRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ILedgerExportRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// Validate format
|
||||
var validFormats = new[] { "json", "ndjson", "csv" };
|
||||
if (!validFormats.Contains(request.Format?.ToLowerInvariant()))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.ledger.error.invalid_format", string.Join(", ", validFormats)) });
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
if (request.StartTime.HasValue && request.EndTime.HasValue && request.StartTime > request.EndTime)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.ledger.error.start_before_end") });
|
||||
}
|
||||
|
||||
var export = LedgerExport.CreateRequest(
|
||||
tenantId: tenantId,
|
||||
format: request.Format!,
|
||||
requestedBy: actorId,
|
||||
requestedAt: now,
|
||||
startTime: request.StartTime,
|
||||
endTime: request.EndTime,
|
||||
runTypeFilter: request.RunTypeFilter,
|
||||
sourceIdFilter: request.SourceIdFilter);
|
||||
|
||||
await repository.CreateAsync(export, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/jobengine/ledger/exports/{export.ExportId}",
|
||||
LedgerExportResponse.FromDomain(export));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListManifests(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
[FromQuery] string? provenanceType = 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);
|
||||
|
||||
ProvenanceType? parsedType = null;
|
||||
if (!string.IsNullOrEmpty(provenanceType) && Enum.TryParse<ProvenanceType>(provenanceType, true, out var pt))
|
||||
{
|
||||
parsedType = pt;
|
||||
}
|
||||
|
||||
var manifests = await repository.ListAsync(
|
||||
tenantId,
|
||||
parsedType,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = manifests.Select(ManifestResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new ManifestListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetManifest(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid manifestId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var manifest = await repository.GetByIdAsync(tenantId, manifestId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ManifestDetailResponse.FromDomain(manifest));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetManifestBySubject(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid subjectId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
[FromQuery] string? provenanceType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
ProvenanceType parsedType = ProvenanceType.Run;
|
||||
if (!string.IsNullOrEmpty(provenanceType) && Enum.TryParse<ProvenanceType>(provenanceType, true, out var pt))
|
||||
{
|
||||
parsedType = pt;
|
||||
}
|
||||
|
||||
var manifest = await repository.GetBySubjectAsync(tenantId, parsedType, subjectId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(ManifestDetailResponse.FromDomain(manifest));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyManifest(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid manifestId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IManifestRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var manifest = await repository.GetByIdAsync(tenantId, manifestId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var payloadValid = manifest.VerifyPayloadIntegrity();
|
||||
string? validationError = null;
|
||||
|
||||
if (!payloadValid)
|
||||
{
|
||||
validationError = _t("orchestrator.ledger.error.payload_digest_mismatch");
|
||||
}
|
||||
else if (manifest.IsExpired)
|
||||
{
|
||||
validationError = _t("orchestrator.ledger.error.manifest_expired");
|
||||
}
|
||||
|
||||
Infrastructure.JobEngineMetrics.ManifestVerified(tenantId, payloadValid && !manifest.IsExpired);
|
||||
|
||||
return Results.Ok(new ManifestVerificationResponse(
|
||||
ManifestId: manifestId,
|
||||
PayloadIntegrityValid: payloadValid,
|
||||
IsExpired: manifest.IsExpired,
|
||||
IsSigned: manifest.IsSigned,
|
||||
ValidationError: validationError));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI discovery and specification endpoints.
|
||||
/// </summary>
|
||||
public static class OpenApiEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps OpenAPI discovery endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOpenApiEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context) =>
|
||||
{
|
||||
var version = OpenApiDocuments.GetServiceVersion();
|
||||
var discovery = OpenApiDocuments.CreateDiscoveryDocument(version);
|
||||
|
||||
context.Response.Headers.CacheControl = "private, max-age=300";
|
||||
context.Response.Headers.ETag = $"W/\"oas-{version}\"";
|
||||
context.Response.Headers["X-StellaOps-Service"] = "jobengine";
|
||||
context.Response.Headers["X-StellaOps-Api-Version"] = version;
|
||||
|
||||
return Results.Json(discovery, OpenApiDocuments.SerializerOptions);
|
||||
})
|
||||
.WithName("Orchestrator_OpenApiDiscovery")
|
||||
.WithTags("OpenAPI")
|
||||
.WithDescription("Return the OpenAPI discovery document for the Orchestrator service, including the service name, current version, and a link to the full OpenAPI specification. The response is cached for 5 minutes and includes ETag-based conditional caching support.")
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGet("/openapi/jobengine.json", () =>
|
||||
{
|
||||
var version = OpenApiDocuments.GetServiceVersion();
|
||||
var spec = OpenApiDocuments.CreateSpecification(version);
|
||||
return Results.Json(spec, OpenApiDocuments.SerializerOptions);
|
||||
})
|
||||
.WithName("Orchestrator_OpenApiSpec")
|
||||
.WithTags("OpenAPI")
|
||||
.WithDescription("Return the full OpenAPI 3.x specification for the Orchestrator service as a JSON document. Used by the Router to aggregate the service's endpoint metadata and by developer tooling to generate clients and documentation.")
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
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>
|
||||
/// Pack registry endpoints for pack management, versioning, and discovery.
|
||||
/// Per 150.B-PacksRegistry: Registry API for pack CRUD operations.
|
||||
/// </summary>
|
||||
public static class PackRegistryEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maps pack registry endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapPackRegistryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/registry/packs")
|
||||
.WithTags("Orchestrator Pack Registry")
|
||||
.RequireAuthorization(JobEnginePolicies.PacksRead)
|
||||
.RequireTenant();
|
||||
|
||||
// Pack CRUD endpoints
|
||||
group.MapPost("", CreatePack)
|
||||
.WithName("Registry_CreatePack")
|
||||
.WithDescription(_t("orchestrator.pack_registry.create_pack_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
group.MapGet("{packId:guid}", GetPackById)
|
||||
.WithName("Registry_GetPackById")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_pack_by_id_description"));
|
||||
|
||||
group.MapGet("by-name/{name}", GetPackByName)
|
||||
.WithName("Registry_GetPackByName")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_pack_by_name_description"));
|
||||
|
||||
group.MapGet("", ListPacks)
|
||||
.WithName("Registry_ListPacks")
|
||||
.WithDescription(_t("orchestrator.pack_registry.list_packs_description"));
|
||||
|
||||
group.MapPatch("{packId:guid}", UpdatePack)
|
||||
.WithName("Registry_UpdatePack")
|
||||
.WithDescription(_t("orchestrator.pack_registry.update_pack_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
group.MapPost("{packId:guid}/status", UpdatePackStatus)
|
||||
.WithName("Registry_UpdatePackStatus")
|
||||
.WithDescription(_t("orchestrator.pack_registry.update_pack_status_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
group.MapDelete("{packId:guid}", DeletePack)
|
||||
.WithName("Registry_DeletePack")
|
||||
.WithDescription(_t("orchestrator.pack_registry.delete_pack_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
// Pack version endpoints
|
||||
group.MapPost("{packId:guid}/versions", CreatePackVersion)
|
||||
.WithName("Registry_CreatePackVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.create_version_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
group.MapGet("{packId:guid}/versions", ListVersions)
|
||||
.WithName("Registry_ListVersions")
|
||||
.WithDescription(_t("orchestrator.pack_registry.list_versions_description"));
|
||||
|
||||
group.MapGet("{packId:guid}/versions/{version}", GetVersion)
|
||||
.WithName("Registry_GetVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_version_description"));
|
||||
|
||||
group.MapGet("{packId:guid}/versions/latest", GetLatestVersion)
|
||||
.WithName("Registry_GetLatestVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_latest_version_description"));
|
||||
|
||||
group.MapPatch("{packId:guid}/versions/{packVersionId:guid}", UpdateVersion)
|
||||
.WithName("Registry_UpdateVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.update_version_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
group.MapPost("{packId:guid}/versions/{packVersionId:guid}/status", UpdateVersionStatus)
|
||||
.WithName("Registry_UpdateVersionStatus")
|
||||
.WithDescription(_t("orchestrator.pack_registry.update_version_status_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
group.MapPost("{packId:guid}/versions/{packVersionId:guid}/sign", SignVersion)
|
||||
.WithName("Registry_SignVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.sign_version_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksApprove);
|
||||
|
||||
group.MapPost("{packId:guid}/versions/{packVersionId:guid}/download", DownloadVersion)
|
||||
.WithName("Registry_DownloadVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.download_version_description"));
|
||||
|
||||
group.MapDelete("{packId:guid}/versions/{packVersionId:guid}", DeleteVersion)
|
||||
.WithName("Registry_DeleteVersion")
|
||||
.WithDescription(_t("orchestrator.pack_registry.delete_version_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.PacksWrite);
|
||||
|
||||
// Search and discovery endpoints
|
||||
group.MapGet("search", SearchPacks)
|
||||
.WithName("Registry_SearchPacks")
|
||||
.WithDescription(_t("orchestrator.pack_registry.search_packs_description"));
|
||||
|
||||
group.MapGet("by-tag/{tag}", GetPacksByTag)
|
||||
.WithName("Registry_GetPacksByTag")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_packs_by_tag_description"));
|
||||
|
||||
group.MapGet("popular", GetPopularPacks)
|
||||
.WithName("Registry_GetPopularPacks")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_popular_packs_description"));
|
||||
|
||||
group.MapGet("recent", GetRecentPacks)
|
||||
.WithName("Registry_GetRecentPacks")
|
||||
.WithDescription(_t("orchestrator.pack_registry.get_recent_packs_description"));
|
||||
|
||||
// Statistics endpoint
|
||||
group.MapGet("stats", GetStats)
|
||||
.WithName("Registry_GetStats")
|
||||
.WithDescription(_t("orchestrator.pack_registry.stats_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
// ========== Pack CRUD Endpoints ==========
|
||||
|
||||
private static async Task<IResult> CreatePack(
|
||||
HttpContext context,
|
||||
[FromBody] CreatePackRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", _t("orchestrator.pack_registry.error.name_required"), null, null));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", _t("orchestrator.pack_registry.error.display_name_required"), null, null));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// Check for existing pack with same name
|
||||
var existing = await repository.GetPackByNameAsync(tenantId, request.Name.ToLowerInvariant(), cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"duplicate_name", _t("orchestrator.pack_registry.error.pack_name_exists", request.Name), existing.PackId, null));
|
||||
}
|
||||
|
||||
var pack = Pack.Create(
|
||||
packId: Guid.NewGuid(),
|
||||
tenantId: tenantId,
|
||||
projectId: request.ProjectId,
|
||||
name: request.Name,
|
||||
displayName: request.DisplayName,
|
||||
description: request.Description,
|
||||
createdBy: actor,
|
||||
metadata: request.Metadata,
|
||||
tags: request.Tags,
|
||||
iconUri: request.IconUri,
|
||||
createdAt: now);
|
||||
|
||||
await repository.CreatePackAsync(pack, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v1/jobengine/registry/packs/{pack.PackId}", PackResponse.FromDomain(pack));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPackById(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var pack = await repository.GetPackByIdAsync(tenantId, packId, cancellationToken);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", _t("orchestrator.pack_registry.error.pack_id_not_found", packId), packId, null));
|
||||
}
|
||||
|
||||
return Results.Ok(PackResponse.FromDomain(pack));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPackByName(
|
||||
HttpContext context,
|
||||
[FromRoute] string name,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var pack = await repository.GetPackByNameAsync(tenantId, name.ToLowerInvariant(), cancellationToken);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Pack '{name}' not found", null, null));
|
||||
}
|
||||
|
||||
return Results.Ok(PackResponse.FromDomain(pack));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPacks(
|
||||
HttpContext context,
|
||||
[FromQuery] string? projectId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
|
||||
var effectiveOffset = offset ?? 0;
|
||||
|
||||
PackStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<PackStatus>(status, true, out var parsed))
|
||||
{
|
||||
statusFilter = parsed;
|
||||
}
|
||||
|
||||
var packs = await repository.ListPacksAsync(
|
||||
tenantId, projectId, statusFilter, search, tag,
|
||||
effectiveLimit, effectiveOffset, cancellationToken);
|
||||
|
||||
var totalCount = await repository.CountPacksAsync(
|
||||
tenantId, projectId, statusFilter, search, tag, cancellationToken);
|
||||
|
||||
var responses = packs.Select(PackResponse.FromDomain).ToList();
|
||||
var nextCursor = responses.Count == effectiveLimit
|
||||
? (effectiveOffset + effectiveLimit).ToString()
|
||||
: null;
|
||||
|
||||
return Results.Ok(new PackListResponse(responses, totalCount, nextCursor));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePack(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromBody] UpdatePackRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var pack = await repository.GetPackByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Pack {packId} not found", packId, null));
|
||||
}
|
||||
|
||||
if (pack.IsTerminal)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"terminal_status", "Cannot update a pack in terminal status", packId, null));
|
||||
}
|
||||
|
||||
var updated = pack with
|
||||
{
|
||||
DisplayName = request.DisplayName ?? pack.DisplayName,
|
||||
Description = request.Description ?? pack.Description,
|
||||
Metadata = request.Metadata ?? pack.Metadata,
|
||||
Tags = request.Tags ?? pack.Tags,
|
||||
IconUri = request.IconUri ?? pack.IconUri,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
await repository.UpdatePackAsync(updated, cancellationToken);
|
||||
|
||||
return Results.Ok(PackResponse.FromDomain(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePackStatus(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromBody] UpdatePackStatusRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "Status is required", packId, null));
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<PackStatus>(request.Status, true, out var newStatus))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_status", $"Invalid status: {request.Status}", packId, null));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var pack = await repository.GetPackByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Pack {packId} not found", packId, null));
|
||||
}
|
||||
|
||||
// Validate status transition
|
||||
var canTransition = newStatus switch
|
||||
{
|
||||
PackStatus.Published => pack.CanPublish,
|
||||
PackStatus.Deprecated => pack.CanDeprecate,
|
||||
PackStatus.Archived => pack.CanArchive,
|
||||
PackStatus.Draft => false, // Cannot go back to draft
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!canTransition)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"invalid_transition", $"Cannot transition from {pack.Status} to {newStatus}", packId, null));
|
||||
}
|
||||
|
||||
DateTimeOffset? publishedAt = newStatus == PackStatus.Published ? now : pack.PublishedAt;
|
||||
string? publishedBy = newStatus == PackStatus.Published ? actor : pack.PublishedBy;
|
||||
|
||||
await repository.UpdatePackStatusAsync(
|
||||
tenantId, packId, newStatus, actor, publishedAt, publishedBy, cancellationToken);
|
||||
|
||||
var updated = pack.WithStatus(newStatus, actor, now);
|
||||
return Results.Ok(PackResponse.FromDomain(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePack(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var pack = await repository.GetPackByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Pack {packId} not found", packId, null));
|
||||
}
|
||||
|
||||
if (pack.Status != PackStatus.Draft)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"not_draft", "Only draft packs can be deleted", packId, null));
|
||||
}
|
||||
|
||||
if (pack.VersionCount > 0)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"has_versions", "Cannot delete pack with versions", packId, null));
|
||||
}
|
||||
|
||||
var deleted = await repository.DeletePackAsync(tenantId, packId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"delete_failed", "Failed to delete pack", packId, null));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ========== Pack Version Endpoints ==========
|
||||
|
||||
private static async Task<IResult> CreatePackVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromBody] CreatePackVersionRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Version))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "Version is required", packId, null));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactUri))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "ArtifactUri is required", packId, null));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "ArtifactDigest is required", packId, null));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var pack = await repository.GetPackByIdAsync(tenantId, packId, cancellationToken);
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Pack {packId} not found", packId, null));
|
||||
}
|
||||
|
||||
if (!pack.CanAddVersion)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"cannot_add_version", $"Cannot add version to pack in {pack.Status} status", packId, null));
|
||||
}
|
||||
|
||||
// Check for duplicate version
|
||||
var existing = await repository.GetVersionAsync(tenantId, packId, request.Version, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"duplicate_version", $"Version {request.Version} already exists", packId, existing.PackVersionId));
|
||||
}
|
||||
|
||||
var version = PackVersion.Create(
|
||||
packVersionId: Guid.NewGuid(),
|
||||
tenantId: tenantId,
|
||||
packId: packId,
|
||||
version: request.Version,
|
||||
semVer: request.SemVer,
|
||||
artifactUri: request.ArtifactUri,
|
||||
artifactDigest: request.ArtifactDigest,
|
||||
artifactMimeType: request.ArtifactMimeType,
|
||||
artifactSizeBytes: request.ArtifactSizeBytes,
|
||||
manifestJson: request.ManifestJson,
|
||||
manifestDigest: request.ManifestDigest,
|
||||
releaseNotes: request.ReleaseNotes,
|
||||
minEngineVersion: request.MinEngineVersion,
|
||||
dependencies: request.Dependencies,
|
||||
createdBy: actor,
|
||||
metadata: request.Metadata,
|
||||
createdAt: now);
|
||||
|
||||
await repository.CreateVersionAsync(version, cancellationToken);
|
||||
|
||||
// Update pack version count
|
||||
var updatedPack = pack.WithVersionAdded(request.Version, actor, now);
|
||||
await repository.UpdatePackAsync(updatedPack, cancellationToken);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/jobengine/registry/packs/{packId}/versions/{version.PackVersionId}",
|
||||
PackVersionResponse.FromDomain(version));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListVersions(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
|
||||
var effectiveOffset = offset ?? 0;
|
||||
|
||||
PackVersionStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<PackVersionStatus>(status, true, out var parsed))
|
||||
{
|
||||
statusFilter = parsed;
|
||||
}
|
||||
|
||||
var versions = await repository.ListVersionsAsync(
|
||||
tenantId, packId, statusFilter, effectiveLimit, effectiveOffset, cancellationToken);
|
||||
|
||||
var totalCount = await repository.CountVersionsAsync(
|
||||
tenantId, packId, statusFilter, cancellationToken);
|
||||
|
||||
var responses = versions.Select(PackVersionResponse.FromDomain).ToList();
|
||||
var nextCursor = responses.Count == effectiveLimit
|
||||
? (effectiveOffset + effectiveLimit).ToString()
|
||||
: null;
|
||||
|
||||
return Results.Ok(new PackVersionListResponse(responses, totalCount, nextCursor));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromRoute] string version,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var packVersion = await repository.GetVersionAsync(tenantId, packId, version, cancellationToken);
|
||||
|
||||
if (packVersion is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Version {version} not found for pack {packId}", packId, null));
|
||||
}
|
||||
|
||||
return Results.Ok(PackVersionResponse.FromDomain(packVersion));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLatestVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromQuery] bool? includePrerelease,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var version = await repository.GetLatestVersionAsync(
|
||||
tenantId, packId, includePrerelease ?? false, cancellationToken);
|
||||
|
||||
if (version is null)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"No published versions found for pack {packId}", packId, null));
|
||||
}
|
||||
|
||||
return Results.Ok(PackVersionResponse.FromDomain(version));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromRoute] Guid packVersionId,
|
||||
[FromBody] UpdatePackVersionRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var version = await repository.GetVersionByIdAsync(tenantId, packVersionId, cancellationToken);
|
||||
if (version is null || version.PackId != packId)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Version {packVersionId} not found", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (version.IsTerminal)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"terminal_status", "Cannot update version in terminal status", packId, packVersionId));
|
||||
}
|
||||
|
||||
var updated = version with
|
||||
{
|
||||
ReleaseNotes = request.ReleaseNotes ?? version.ReleaseNotes,
|
||||
Metadata = request.Metadata ?? version.Metadata,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actor
|
||||
};
|
||||
|
||||
await repository.UpdateVersionAsync(updated, cancellationToken);
|
||||
|
||||
return Results.Ok(PackVersionResponse.FromDomain(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateVersionStatus(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromRoute] Guid packVersionId,
|
||||
[FromBody] UpdatePackVersionStatusRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "Status is required", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<PackVersionStatus>(request.Status, true, out var newStatus))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_status", $"Invalid status: {request.Status}", packId, packVersionId));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var version = await repository.GetVersionByIdAsync(tenantId, packVersionId, cancellationToken);
|
||||
if (version is null || version.PackId != packId)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Version {packVersionId} not found", packId, packVersionId));
|
||||
}
|
||||
|
||||
// Validate status transition
|
||||
var canTransition = newStatus switch
|
||||
{
|
||||
PackVersionStatus.Published => version.CanPublish,
|
||||
PackVersionStatus.Deprecated => version.CanDeprecate,
|
||||
PackVersionStatus.Archived => version.CanArchive,
|
||||
PackVersionStatus.Draft => false,
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!canTransition)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"invalid_transition", $"Cannot transition from {version.Status} to {newStatus}", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (newStatus == PackVersionStatus.Deprecated && string.IsNullOrWhiteSpace(request.DeprecationReason))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "DeprecationReason is required when deprecating", packId, packVersionId));
|
||||
}
|
||||
|
||||
DateTimeOffset? publishedAt = newStatus == PackVersionStatus.Published ? now : version.PublishedAt;
|
||||
string? publishedBy = newStatus == PackVersionStatus.Published ? actor : version.PublishedBy;
|
||||
DateTimeOffset? deprecatedAt = newStatus == PackVersionStatus.Deprecated ? now : version.DeprecatedAt;
|
||||
string? deprecatedBy = newStatus == PackVersionStatus.Deprecated ? actor : version.DeprecatedBy;
|
||||
|
||||
await repository.UpdateVersionStatusAsync(
|
||||
tenantId, packVersionId, newStatus, actor,
|
||||
publishedAt, publishedBy,
|
||||
deprecatedAt, deprecatedBy, request.DeprecationReason,
|
||||
cancellationToken);
|
||||
|
||||
var updated = newStatus == PackVersionStatus.Deprecated
|
||||
? version.WithDeprecation(actor, request.DeprecationReason, now)
|
||||
: version.WithStatus(newStatus, actor, now);
|
||||
|
||||
return Results.Ok(PackVersionResponse.FromDomain(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> SignVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromRoute] Guid packVersionId,
|
||||
[FromBody] SignPackVersionRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SignatureUri))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "SignatureUri is required", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SignatureAlgorithm))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "SignatureAlgorithm is required", packId, packVersionId));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actor = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var version = await repository.GetVersionByIdAsync(tenantId, packVersionId, cancellationToken);
|
||||
if (version is null || version.PackId != packId)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Version {packVersionId} not found", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (version.IsSigned)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"already_signed", "Version is already signed", packId, packVersionId));
|
||||
}
|
||||
|
||||
await repository.UpdateVersionSignatureAsync(
|
||||
tenantId, packVersionId,
|
||||
request.SignatureUri, request.SignatureAlgorithm,
|
||||
actor, now,
|
||||
cancellationToken);
|
||||
|
||||
var signed = version.WithSignature(request.SignatureUri, request.SignatureAlgorithm, actor, now);
|
||||
return Results.Ok(PackVersionResponse.FromDomain(signed));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromRoute] Guid packVersionId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var version = await repository.GetVersionByIdAsync(tenantId, packVersionId, cancellationToken);
|
||||
if (version is null || version.PackId != packId)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Version {packVersionId} not found", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (version.Status != PackVersionStatus.Published)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"not_published", "Only published versions can be downloaded", packId, packVersionId));
|
||||
}
|
||||
|
||||
// Increment download count
|
||||
await repository.IncrementDownloadCountAsync(tenantId, packVersionId, cancellationToken);
|
||||
|
||||
return Results.Ok(new PackVersionDownloadResponse(
|
||||
version.PackVersionId,
|
||||
version.Version,
|
||||
version.ArtifactUri,
|
||||
version.ArtifactDigest,
|
||||
version.ArtifactMimeType,
|
||||
version.ArtifactSizeBytes,
|
||||
version.SignatureUri,
|
||||
version.SignatureAlgorithm));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteVersion(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packId,
|
||||
[FromRoute] Guid packVersionId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var version = await repository.GetVersionByIdAsync(tenantId, packVersionId, cancellationToken);
|
||||
if (version is null || version.PackId != packId)
|
||||
{
|
||||
return Results.NotFound(new PackRegistryErrorResponse(
|
||||
"not_found", $"Version {packVersionId} not found", packId, packVersionId));
|
||||
}
|
||||
|
||||
if (version.Status != PackVersionStatus.Draft)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"not_draft", "Only draft versions can be deleted", packId, packVersionId));
|
||||
}
|
||||
|
||||
var deleted = await repository.DeleteVersionAsync(tenantId, packVersionId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.Conflict(new PackRegistryErrorResponse(
|
||||
"delete_failed", "Failed to delete version", packId, packVersionId));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ========== Search and Discovery Endpoints ==========
|
||||
|
||||
private static async Task<IResult> SearchPacks(
|
||||
HttpContext context,
|
||||
[FromQuery] string query,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Results.BadRequest(new PackRegistryErrorResponse(
|
||||
"invalid_request", "Query is required", null, null));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
|
||||
|
||||
PackStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<PackStatus>(status, true, out var parsed))
|
||||
{
|
||||
statusFilter = parsed;
|
||||
}
|
||||
|
||||
var packs = await repository.SearchPacksAsync(
|
||||
tenantId, query, statusFilter, effectiveLimit, cancellationToken);
|
||||
|
||||
var responses = packs.Select(PackResponse.FromDomain).ToList();
|
||||
return Results.Ok(new PackSearchResponse(responses, query));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPacksByTag(
|
||||
HttpContext context,
|
||||
[FromRoute] string tag,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
|
||||
var effectiveOffset = offset ?? 0;
|
||||
|
||||
var packs = await repository.GetPacksByTagAsync(
|
||||
tenantId, tag, effectiveLimit, effectiveOffset, cancellationToken);
|
||||
|
||||
var responses = packs.Select(PackResponse.FromDomain).ToList();
|
||||
return Results.Ok(new PackListResponse(responses, responses.Count, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPopularPacks(
|
||||
HttpContext context,
|
||||
[FromQuery] int? limit,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Min(limit ?? 10, 50);
|
||||
|
||||
var packs = await repository.GetPopularPacksAsync(tenantId, effectiveLimit, cancellationToken);
|
||||
|
||||
var responses = packs.Select(PackResponse.FromDomain).ToList();
|
||||
return Results.Ok(new PackListResponse(responses, responses.Count, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRecentPacks(
|
||||
HttpContext context,
|
||||
[FromQuery] int? limit,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var effectiveLimit = Math.Min(limit ?? 10, 50);
|
||||
|
||||
var packs = await repository.GetRecentPacksAsync(tenantId, effectiveLimit, cancellationToken);
|
||||
|
||||
var responses = packs.Select(PackResponse.FromDomain).ToList();
|
||||
return Results.Ok(new PackListResponse(responses, responses.Count, null));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStats(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRegistryRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var stats = await repository.GetStatsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new PackRegistryStatsResponse(
|
||||
stats.TotalPacks,
|
||||
stats.PublishedPacks,
|
||||
stats.TotalVersions,
|
||||
stats.PublishedVersions,
|
||||
stats.TotalDownloads,
|
||||
stats.LastUpdatedAt));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Core.Services;
|
||||
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 governance management.
|
||||
/// </summary>
|
||||
public static class QuotaGovernanceEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps quota governance endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapQuotaGovernanceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/quota-governance")
|
||||
.WithTags("Orchestrator Quota Governance")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// Policy management
|
||||
group.MapGet("policies", ListPolicies)
|
||||
.WithName("Orchestrator_ListQuotaAllocationPolicies")
|
||||
.WithDescription(_t("orchestrator.quota_governance.list_description"));
|
||||
|
||||
group.MapGet("policies/{policyId:guid}", GetPolicy)
|
||||
.WithName("Orchestrator_GetQuotaAllocationPolicy")
|
||||
.WithDescription(_t("orchestrator.quota_governance.get_description"));
|
||||
|
||||
group.MapPost("policies", CreatePolicy)
|
||||
.WithName("Orchestrator_CreateQuotaAllocationPolicy")
|
||||
.WithDescription(_t("orchestrator.quota_governance.create_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Quota);
|
||||
|
||||
group.MapPut("policies/{policyId:guid}", UpdatePolicy)
|
||||
.WithName("Orchestrator_UpdateQuotaAllocationPolicy")
|
||||
.WithDescription(_t("orchestrator.quota_governance.update_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Quota);
|
||||
|
||||
group.MapDelete("policies/{policyId:guid}", DeletePolicy)
|
||||
.WithName("Orchestrator_DeleteQuotaAllocationPolicy")
|
||||
.WithDescription(_t("orchestrator.quota_governance.delete_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Quota);
|
||||
|
||||
// Quota allocation calculations
|
||||
group.MapGet("allocation", CalculateAllocation)
|
||||
.WithName("Orchestrator_CalculateQuotaAllocation")
|
||||
.WithDescription(_t("orchestrator.quota_governance.evaluate_description"));
|
||||
|
||||
// Quota requests
|
||||
group.MapPost("request", RequestQuota)
|
||||
.WithName("Orchestrator_RequestQuota")
|
||||
.WithDescription(_t("orchestrator.quota_governance.snapshot_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Quota);
|
||||
|
||||
group.MapPost("release", ReleaseQuota)
|
||||
.WithName("Orchestrator_ReleaseQuota")
|
||||
.WithDescription(_t("orchestrator.quota_governance.simulate_description"))
|
||||
.RequireAuthorization(JobEnginePolicies.Quota);
|
||||
|
||||
// Status and summary
|
||||
group.MapGet("status", GetTenantStatus)
|
||||
.WithName("Orchestrator_GetTenantQuotaStatus")
|
||||
.WithDescription(_t("orchestrator.quota_governance.priority_description"));
|
||||
|
||||
group.MapGet("summary", GetSummary)
|
||||
.WithName("Orchestrator_GetQuotaGovernanceSummary")
|
||||
.WithDescription(_t("orchestrator.quota_governance.audit_description"));
|
||||
|
||||
// Scheduling check
|
||||
group.MapGet("can-schedule", CanSchedule)
|
||||
.WithName("Orchestrator_CanScheduleJob")
|
||||
.WithDescription(_t("orchestrator.quota_governance.reorder_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPolicies(
|
||||
HttpContext context,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
[FromQuery] bool? enabled = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var policies = await service.ListPoliciesAsync(enabled, cancellationToken).ConfigureAwait(false);
|
||||
var responses = policies.Select(QuotaAllocationPolicyResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new QuotaAllocationPolicyListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid policyId,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var policy = await service.GetPolicyAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (policy is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(QuotaAllocationPolicyResponse.FromDomain(policy));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePolicy(
|
||||
HttpContext context,
|
||||
[FromBody] CreateQuotaAllocationPolicyRequest request,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Enum.TryParse<AllocationStrategy>(request.Strategy, ignoreCase: true, out var strategy))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.quota_governance.error.invalid_strategy", request.Strategy) });
|
||||
}
|
||||
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var policy = new QuotaAllocationPolicy(
|
||||
PolicyId: Guid.NewGuid(),
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Strategy: strategy,
|
||||
TotalCapacity: request.TotalCapacity,
|
||||
MinimumPerTenant: request.MinimumPerTenant,
|
||||
MaximumPerTenant: request.MaximumPerTenant,
|
||||
ReservedCapacity: request.ReservedCapacity,
|
||||
AllowBurst: request.AllowBurst,
|
||||
BurstMultiplier: request.BurstMultiplier,
|
||||
Priority: request.Priority,
|
||||
Active: request.Active,
|
||||
JobType: request.JobType,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
UpdatedBy: actorId);
|
||||
|
||||
var created = await service.CreatePolicyAsync(policy, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/jobengine/quota-governance/policies/{created.PolicyId}",
|
||||
QuotaAllocationPolicyResponse.FromDomain(created));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid policyId,
|
||||
[FromBody] UpdateQuotaAllocationPolicyRequest request,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await service.GetPolicyAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
AllocationStrategy? newStrategy = null;
|
||||
if (!string.IsNullOrEmpty(request.Strategy))
|
||||
{
|
||||
if (!Enum.TryParse<AllocationStrategy>(request.Strategy, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.quota_governance.error.invalid_strategy", request.Strategy) });
|
||||
}
|
||||
newStrategy = parsed;
|
||||
}
|
||||
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Strategy = newStrategy ?? existing.Strategy,
|
||||
TotalCapacity = request.TotalCapacity ?? existing.TotalCapacity,
|
||||
MinimumPerTenant = request.MinimumPerTenant ?? existing.MinimumPerTenant,
|
||||
MaximumPerTenant = request.MaximumPerTenant ?? existing.MaximumPerTenant,
|
||||
ReservedCapacity = request.ReservedCapacity ?? existing.ReservedCapacity,
|
||||
AllowBurst = request.AllowBurst ?? existing.AllowBurst,
|
||||
BurstMultiplier = request.BurstMultiplier ?? existing.BurstMultiplier,
|
||||
Priority = request.Priority ?? existing.Priority,
|
||||
Active = request.Active ?? existing.Active,
|
||||
JobType = request.JobType ?? existing.JobType,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = actorId
|
||||
};
|
||||
|
||||
var result = await service.UpdatePolicyAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(QuotaAllocationPolicyResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePolicy(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid policyId,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await service.DeletePolicyAsync(policyId, 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> CalculateAllocation(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
[FromQuery] string? jobType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await service.CalculateAllocationAsync(tenantId, jobType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(QuotaAllocationResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestQuota(
|
||||
HttpContext context,
|
||||
[FromBody] RequestQuotaRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request.RequestedAmount <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.quota_governance.error.amount_positive") });
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await service.RequestQuotaAsync(tenantId, request.JobType, request.RequestedAmount, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(QuotaRequestResponse.FromDomain(result));
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReleaseQuota(
|
||||
HttpContext context,
|
||||
[FromBody] ReleaseQuotaRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request.ReleasedAmount <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("orchestrator.quota_governance.error.amount_positive") });
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
await service.ReleaseQuotaAsync(tenantId, request.JobType, request.ReleasedAmount, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { released = true, amount = request.ReleasedAmount });
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTenantStatus(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
[FromQuery] string? jobType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var status = await service.GetTenantStatusAsync(tenantId, jobType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(TenantQuotaStatusResponse.FromDomain(status));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSummary(
|
||||
HttpContext context,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
[FromQuery] Guid? policyId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var summary = await service.GetSummaryAsync(policyId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(QuotaGovernanceSummaryResponse.FromDomain(summary));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CanSchedule(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IQuotaGovernanceService service,
|
||||
[FromQuery] string? jobType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var result = await service.CanScheduleAsync(tenantId, jobType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SchedulingCheckResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// v2 contract adapters for Pack-driven release control routes.
|
||||
/// </summary>
|
||||
public static class ReleaseControlV2Endpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseControlV2Endpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapApprovalsV2(app);
|
||||
MapRunsV2(app);
|
||||
MapEnvironmentsV2(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapApprovalsV2(IEndpointRouteBuilder app)
|
||||
{
|
||||
var approvals = app.MapGroup("/api/v1/approvals")
|
||||
.WithTags("Approvals v2")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
|
||||
approvals.MapGet(string.Empty, ListApprovals)
|
||||
.WithName("ApprovalsV2_List")
|
||||
.WithDescription("Return the v2 approval queue for the calling tenant, including per-request digest confidence, reachability-weighted risk score, and ops-data integrity score. Optionally filtered by status and target environment. Designed for the enhanced approval UX.");
|
||||
|
||||
approvals.MapGet("/{id}", GetApprovalDetail)
|
||||
.WithName("ApprovalsV2_Get")
|
||||
.WithDescription("Return the v2 decision packet for the specified approval, including the full policy gate evaluation trace, reachability-adjusted finding counts, confidence bands, and all structured evidence references required to make an informed approval decision.");
|
||||
|
||||
approvals.MapGet("/{id}/gates", GetApprovalGates)
|
||||
.WithName("ApprovalsV2_Gates")
|
||||
.WithDescription("Return the detailed gate evaluation trace for the specified v2 approval, showing each policy gate's inputs, computed verdict, confidence weight, and any override history. Used by approvers to understand the basis for automated gate results.");
|
||||
|
||||
approvals.MapGet("/{id}/evidence", GetApprovalEvidence)
|
||||
.WithName("ApprovalsV2_Evidence")
|
||||
.WithDescription("Return the structured evidence reference set attached to the specified v2 approval decision packet, including SBOM digests, attestation references, scan results, and provenance records. Used to verify the completeness of the evidence chain before approving.");
|
||||
|
||||
approvals.MapGet("/{id}/security-snapshot", GetApprovalSecuritySnapshot)
|
||||
.WithName("ApprovalsV2_SecuritySnapshot")
|
||||
.WithDescription("Return the security snapshot computed for the specified approval context, including reachability-adjusted critical and high finding counts (CritR, HighR), SBOM coverage percentage, and the weighted risk score used in the approval decision packet.");
|
||||
|
||||
approvals.MapGet("/{id}/ops-health", GetApprovalOpsHealth)
|
||||
.WithName("ApprovalsV2_OpsHealth")
|
||||
.WithDescription("Return the operational data-integrity confidence indicators for the specified approval, including staleness of scan data, missing coverage gaps, and pipeline signal freshness. Low confidence scores reduce the defensibility of approval decisions.");
|
||||
|
||||
approvals.MapPost("/{id}/decision", PostApprovalDecision)
|
||||
.WithName("ApprovalsV2_Decision")
|
||||
.WithDescription("Apply a structured decision action (approve, reject, defer, escalate) to the specified v2 approval, attributing the decision to the calling principal with an optional comment. Returns 409 if the approval is not in a state that accepts decisions.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
}
|
||||
|
||||
private static void MapRunsV2(IEndpointRouteBuilder app)
|
||||
{
|
||||
static void MapRunGroup(RouteGroupBuilder runs)
|
||||
{
|
||||
runs.MapGet("/{id}", GetRunDetail)
|
||||
.WithDescription("Return the promotion run detail timeline for the specified run ID, including each pipeline stage with status, duration, and attached evidence references. Provides the full chronological execution narrative for a release promotion run.");
|
||||
|
||||
runs.MapGet("/{id}/steps", GetRunSteps)
|
||||
.WithDescription("Return the checkpoint-level step list for the specified promotion run, with per-step status, start/end timestamps, and whether the step produced captured evidence. Used to navigate individual steps in a long-running promotion pipeline.");
|
||||
|
||||
runs.MapGet("/{id}/steps/{stepId}", GetRunStepDetail)
|
||||
.WithDescription("Return the detailed record for a single promotion run step including its structured log output, captured evidence references, policy gate results, and duration. Used for deep inspection of a specific checkpoint within a promotion run.");
|
||||
|
||||
runs.MapPost("/{id}/rollback", TriggerRollback)
|
||||
.WithDescription("Initiate a rollback of the specified promotion run, computing a guard-state projection that identifies any post-deployment state that must be unwound before the rollback can proceed. Returns the rollback plan with an estimated blast radius assessment.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
}
|
||||
|
||||
var apiRuns = app.MapGroup("/api/v1/runs")
|
||||
.WithTags("Runs v2")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
MapRunGroup(apiRuns);
|
||||
apiRuns.WithGroupName("runs-v2");
|
||||
|
||||
var legacyV1Runs = app.MapGroup("/v1/runs")
|
||||
.WithTags("Runs v2")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
MapRunGroup(legacyV1Runs);
|
||||
legacyV1Runs.WithGroupName("runs-v1-compat");
|
||||
}
|
||||
|
||||
private static void MapEnvironmentsV2(IEndpointRouteBuilder app)
|
||||
{
|
||||
var environments = app.MapGroup("/api/v1/environments")
|
||||
.WithTags("Environments v2")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
|
||||
environments.MapGet("/{id}", GetEnvironmentDetail)
|
||||
.WithName("EnvironmentsV2_Get")
|
||||
.WithDescription("Return the standardized environment detail header for the specified environment ID, including its name, tier (dev/stage/prod), current active release, and promotion pipeline position. Used to populate the environment context in release dashboards.");
|
||||
|
||||
environments.MapGet("/{id}/deployments", GetEnvironmentDeployments)
|
||||
.WithName("EnvironmentsV2_Deployments")
|
||||
.WithDescription("Return the deployment history for the specified environment ordered by deployment timestamp descending, including each release version, deployment status, and rollback availability. Used for environment-scoped audit and change management views.");
|
||||
|
||||
environments.MapGet("/{id}/security-snapshot", GetEnvironmentSecuritySnapshot)
|
||||
.WithName("EnvironmentsV2_SecuritySnapshot")
|
||||
.WithDescription("Return the current security posture snapshot for the specified environment, including reachability-adjusted critical and high finding counts, SBOM coverage, and the top-ranked risks by exploitability. Refreshed on each new deployment or scan cycle.");
|
||||
|
||||
environments.MapGet("/{id}/evidence", GetEnvironmentEvidence)
|
||||
.WithName("EnvironmentsV2_Evidence")
|
||||
.WithDescription("Return the evidence snapshot and export references for the specified environment, including the active attestation bundle, SBOM digest, scan result references, and the evidence locker ID for compliance archiving. Used for environment-level attestation workflows.");
|
||||
|
||||
environments.MapGet("/{id}/ops-health", GetEnvironmentOpsHealth)
|
||||
.WithName("EnvironmentsV2_OpsHealth")
|
||||
.WithDescription("Return the operational data-confidence and health signals for the specified environment, including scan data staleness, missing SBOM coverage, pipeline signal freshness, and any active incidents affecting the environment's reliability score.");
|
||||
}
|
||||
|
||||
private static IResult ListApprovals(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? targetEnvironment)
|
||||
{
|
||||
var rows = ApprovalEndpoints.SeedData.Approvals
|
||||
.Select(ApprovalEndpoints.WithDerivedSignals)
|
||||
.Select(ApprovalEndpoints.ToSummary)
|
||||
.OrderByDescending(row => row.RequestedAt, StringComparer.Ordinal)
|
||||
.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
rows = rows.Where(row => string.Equals(row.Status, status, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(targetEnvironment))
|
||||
{
|
||||
rows = rows.Where(row => string.Equals(row.TargetEnvironment, targetEnvironment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Results.Ok(rows.ToList());
|
||||
}
|
||||
|
||||
private static IResult GetApprovalDetail(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(approval);
|
||||
}
|
||||
|
||||
private static IResult GetApprovalGates(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
decisionDigest = approval.DecisionDigest,
|
||||
gates = approval.GateResults.OrderBy(g => g.GateName, StringComparer.Ordinal).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetApprovalEvidence(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
packet = approval.EvidencePacket,
|
||||
manifestDigest = approval.ManifestDigest,
|
||||
decisionDigest = approval.DecisionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetApprovalSecuritySnapshot(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
manifestDigest = approval.ManifestDigest,
|
||||
risk = approval.RiskSnapshot,
|
||||
reachability = approval.ReachabilityCoverage,
|
||||
topFindings = BuildTopFindings(approval),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetApprovalOpsHealth(string id)
|
||||
{
|
||||
var approval = FindApproval(id);
|
||||
return approval is null ? Results.NotFound() : Results.Ok(new
|
||||
{
|
||||
approvalId = approval.Id,
|
||||
opsConfidence = approval.OpsConfidence,
|
||||
impactedJobs = BuildImpactedJobs(approval.TargetEnvironment),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult PostApprovalDecision(string id, [FromBody] ApprovalDecisionRequest request)
|
||||
{
|
||||
var idx = ApprovalEndpoints.SeedData.Approvals.FindIndex(approval =>
|
||||
string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (idx < 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var approval = ApprovalEndpoints.WithDerivedSignals(ApprovalEndpoints.SeedData.Approvals[idx]);
|
||||
var normalizedAction = (request.Action ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var actor = string.IsNullOrWhiteSpace(request.Actor) ? "release-manager" : request.Actor.Trim();
|
||||
var timestamp = DateTimeOffset.Parse("2026-02-19T03:20:00Z").ToString("O");
|
||||
|
||||
var nextStatus = normalizedAction switch
|
||||
{
|
||||
"approve" => approval.CurrentApprovals + 1 >= approval.RequiredApprovals ? "approved" : approval.Status,
|
||||
"reject" => "rejected",
|
||||
"defer" => "pending",
|
||||
"escalate" => "pending",
|
||||
_ => approval.Status,
|
||||
};
|
||||
|
||||
var updated = approval with
|
||||
{
|
||||
Status = nextStatus,
|
||||
CurrentApprovals = normalizedAction == "approve"
|
||||
? Math.Min(approval.RequiredApprovals, approval.CurrentApprovals + 1)
|
||||
: approval.CurrentApprovals,
|
||||
Actions = approval.Actions
|
||||
.Concat(new[]
|
||||
{
|
||||
new ApprovalEndpoints.ApprovalActionRecordDto
|
||||
{
|
||||
Id = $"act-{approval.Actions.Count + 1}",
|
||||
ApprovalId = approval.Id,
|
||||
Action = normalizedAction is "approve" or "reject" ? normalizedAction : "comment",
|
||||
Actor = actor,
|
||||
Comment = string.IsNullOrWhiteSpace(request.Comment)
|
||||
? $"Decision action: {normalizedAction}"
|
||||
: request.Comment.Trim(),
|
||||
Timestamp = timestamp,
|
||||
},
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
ApprovalEndpoints.SeedData.Approvals[idx] = updated;
|
||||
return Results.Ok(ApprovalEndpoints.WithDerivedSignals(updated));
|
||||
}
|
||||
|
||||
private static IResult GetRunDetail(string id)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(run with
|
||||
{
|
||||
Steps = run.Steps.OrderBy(step => step.Order).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetRunSteps(string id)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(run.Steps.OrderBy(step => step.Order).ToList());
|
||||
}
|
||||
|
||||
private static IResult GetRunStepDetail(string id, string stepId)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var step = run.Steps.FirstOrDefault(item => string.Equals(item.StepId, stepId, StringComparison.OrdinalIgnoreCase));
|
||||
if (step is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(step);
|
||||
}
|
||||
|
||||
private static IResult TriggerRollback(string id, [FromBody] RollbackRequest? request)
|
||||
{
|
||||
if (!RunCatalog.TryGetValue(id, out var run))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rollbackAllowed = string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(run.Status, "warning", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(run.Status, "degraded", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!rollbackAllowed)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = "rollback_guard_blocked",
|
||||
reason = "Rollback is only allowed when run status is failed/warning/degraded.",
|
||||
});
|
||||
}
|
||||
|
||||
var rollbackRunId = $"rb-{id}";
|
||||
return Results.Accepted($"/api/v1/runs/{rollbackRunId}", new
|
||||
{
|
||||
rollbackRunId,
|
||||
sourceRunId = id,
|
||||
scope = request?.Scope ?? "full",
|
||||
status = "queued",
|
||||
requestedAt = "2026-02-19T03:22:00Z",
|
||||
preview = request?.Preview ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentDetail(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(env);
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentDeployments(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(env.RecentDeployments.OrderByDescending(item => item.DeployedAt).ToList());
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentSecuritySnapshot(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
environmentId = env.EnvironmentId,
|
||||
manifestDigest = env.ManifestDigest,
|
||||
risk = env.RiskSnapshot,
|
||||
reachability = env.ReachabilityCoverage,
|
||||
sbomStatus = env.SbomStatus,
|
||||
topFindings = env.TopFindings,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentEvidence(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
environmentId = env.EnvironmentId,
|
||||
evidence = env.Evidence,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetEnvironmentOpsHealth(string id)
|
||||
{
|
||||
if (!EnvironmentCatalog.TryGetValue(id, out var env))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
environmentId = env.EnvironmentId,
|
||||
opsConfidence = env.OpsConfidence,
|
||||
impactedJobs = BuildImpactedJobs(env.EnvironmentName),
|
||||
});
|
||||
}
|
||||
|
||||
private static ApprovalEndpoints.ApprovalDto? FindApproval(string id)
|
||||
{
|
||||
var approval = ApprovalEndpoints.SeedData.Approvals
|
||||
.FirstOrDefault(item => string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
return approval is null ? null : ApprovalEndpoints.WithDerivedSignals(approval);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object> BuildTopFindings(ApprovalEndpoints.ApprovalDto approval)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
cve = "CVE-2026-1234",
|
||||
component = approval.ReleaseComponents.FirstOrDefault()?.Name ?? "unknown-component",
|
||||
severity = "critical",
|
||||
reachability = "reachable",
|
||||
},
|
||||
new
|
||||
{
|
||||
cve = "CVE-2026-2088",
|
||||
component = approval.ReleaseComponents.Skip(1).FirstOrDefault()?.Name ?? approval.ReleaseComponents.FirstOrDefault()?.Name ?? "unknown-component",
|
||||
severity = "high",
|
||||
reachability = "not_reachable",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object> BuildImpactedJobs(string targetEnvironment)
|
||||
{
|
||||
var ops = ReleaseControlSignalCatalog.GetOpsConfidence(targetEnvironment);
|
||||
return ops.Signals
|
||||
.Select((signal, index) => new
|
||||
{
|
||||
job = $"ops-job-{index + 1}",
|
||||
signal,
|
||||
status = ops.Status,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, RunDetailDto> RunCatalog =
|
||||
new Dictionary<string, RunDetailDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["run-001"] = new(
|
||||
RunId: "run-001",
|
||||
ReleaseId: "rel-002",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000002",
|
||||
Status: "warning",
|
||||
StartedAt: "2026-02-19T02:10:00Z",
|
||||
CompletedAt: "2026-02-19T02:19:00Z",
|
||||
RollbackGuard: "armed",
|
||||
Steps:
|
||||
[
|
||||
new RunStepDto("step-01", 1, "Materialize Inputs", "passed", "2026-02-19T02:10:00Z", "2026-02-19T02:11:00Z", "/api/v1/evidence/thread/sha256-materialize", "/logs/run-001/step-01.log"),
|
||||
new RunStepDto("step-02", 2, "Policy Evaluation", "passed", "2026-02-19T02:11:00Z", "2026-02-19T02:13:00Z", "/api/v1/evidence/thread/sha256-policy", "/logs/run-001/step-02.log"),
|
||||
new RunStepDto("step-03", 3, "Deploy Stage", "warning", "2026-02-19T02:13:00Z", "2026-02-19T02:19:00Z", "/api/v1/evidence/thread/sha256-deploy", "/logs/run-001/step-03.log"),
|
||||
]),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, EnvironmentDetailDto> EnvironmentCatalog =
|
||||
new Dictionary<string, EnvironmentDetailDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["env-production"] = new(
|
||||
EnvironmentId: "env-production",
|
||||
EnvironmentName: "production",
|
||||
Region: "us-east",
|
||||
DeployStatus: "degraded",
|
||||
SbomStatus: "stale",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000002",
|
||||
RiskSnapshot: ReleaseControlSignalCatalog.GetRiskSnapshot("rel-002", "production"),
|
||||
ReachabilityCoverage: ReleaseControlSignalCatalog.GetCoverage("rel-002"),
|
||||
OpsConfidence: ReleaseControlSignalCatalog.GetOpsConfidence("production"),
|
||||
TopFindings:
|
||||
[
|
||||
"CVE-2026-1234 reachable in user-service",
|
||||
"Runtime ingest lag reduces confidence to WARN",
|
||||
],
|
||||
RecentDeployments:
|
||||
[
|
||||
new EnvironmentDeploymentDto("run-001", "rel-002", "1.3.0-rc1", "warning", "2026-02-19T02:19:00Z"),
|
||||
new EnvironmentDeploymentDto("run-000", "rel-001", "1.2.3", "passed", "2026-02-18T08:30:00Z"),
|
||||
],
|
||||
Evidence: new EnvironmentEvidenceDto(
|
||||
"env-snapshot-production-20260219",
|
||||
"sha256:evidence-production-20260219",
|
||||
"/api/v1/evidence/thread/sha256:evidence-production-20260219")),
|
||||
["env-staging"] = new(
|
||||
EnvironmentId: "env-staging",
|
||||
EnvironmentName: "staging",
|
||||
Region: "us-east",
|
||||
DeployStatus: "healthy",
|
||||
SbomStatus: "fresh",
|
||||
ManifestDigest: "sha256:beef000000000000000000000000000000000000000000000000000000000001",
|
||||
RiskSnapshot: ReleaseControlSignalCatalog.GetRiskSnapshot("rel-001", "staging"),
|
||||
ReachabilityCoverage: ReleaseControlSignalCatalog.GetCoverage("rel-001"),
|
||||
OpsConfidence: ReleaseControlSignalCatalog.GetOpsConfidence("staging"),
|
||||
TopFindings:
|
||||
[
|
||||
"No critical reachable findings.",
|
||||
],
|
||||
RecentDeployments:
|
||||
[
|
||||
new EnvironmentDeploymentDto("run-000", "rel-001", "1.2.3", "passed", "2026-02-18T08:30:00Z"),
|
||||
],
|
||||
Evidence: new EnvironmentEvidenceDto(
|
||||
"env-snapshot-staging-20260219",
|
||||
"sha256:evidence-staging-20260219",
|
||||
"/api/v1/evidence/thread/sha256:evidence-staging-20260219")),
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ApprovalDecisionRequest(string Action, string? Comment, string? Actor);
|
||||
|
||||
public sealed record RollbackRequest(string? Scope, bool? Preview);
|
||||
|
||||
public sealed record RunDetailDto(
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string ManifestDigest,
|
||||
string Status,
|
||||
string StartedAt,
|
||||
string CompletedAt,
|
||||
string RollbackGuard,
|
||||
IReadOnlyList<RunStepDto> Steps);
|
||||
|
||||
public sealed record RunStepDto(
|
||||
string StepId,
|
||||
int Order,
|
||||
string Name,
|
||||
string Status,
|
||||
string StartedAt,
|
||||
string CompletedAt,
|
||||
string EvidenceThreadLink,
|
||||
string LogArtifactLink);
|
||||
|
||||
public sealed record EnvironmentDetailDto(
|
||||
string EnvironmentId,
|
||||
string EnvironmentName,
|
||||
string Region,
|
||||
string DeployStatus,
|
||||
string SbomStatus,
|
||||
string ManifestDigest,
|
||||
PromotionRiskSnapshot RiskSnapshot,
|
||||
HybridReachabilityCoverage ReachabilityCoverage,
|
||||
OpsDataConfidence OpsConfidence,
|
||||
IReadOnlyList<string> TopFindings,
|
||||
IReadOnlyList<EnvironmentDeploymentDto> RecentDeployments,
|
||||
EnvironmentEvidenceDto Evidence);
|
||||
|
||||
public sealed record EnvironmentDeploymentDto(
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string ReleaseVersion,
|
||||
string Status,
|
||||
string DeployedAt);
|
||||
|
||||
public sealed record EnvironmentEvidenceDto(
|
||||
string SnapshotId,
|
||||
string DecisionDigest,
|
||||
string ThreadLink);
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release dashboard endpoints consumed by the Console control plane.
|
||||
/// </summary>
|
||||
public static class ReleaseDashboardEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseDashboardEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapForPrefix(app, "/api/v1/release-orchestrator", includeRouteNames: true);
|
||||
MapForPrefix(app, "/api/release-orchestrator", includeRouteNames: false);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapForPrefix(IEndpointRouteBuilder app, string prefix, bool includeRouteNames)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("ReleaseDashboard")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
|
||||
var dashboard = group.MapGet("/dashboard", GetDashboard)
|
||||
.WithDescription("Return a consolidated release dashboard snapshot for the Console control plane, including pending approvals, active promotions, recent deployments, and environment health indicators. Used by the UI to populate the main release management view.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
dashboard.WithName("ReleaseDashboard_Get");
|
||||
}
|
||||
|
||||
var approve = group.MapPost("/promotions/{id}/approve", ApprovePromotion)
|
||||
.WithDescription("Record an approval decision on the specified pending promotion request, allowing the associated release to advance to the next environment. The calling principal must hold the release approval scope. Returns 404 when the promotion ID does not exist.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
approve.WithName("ReleaseDashboard_ApprovePromotion");
|
||||
}
|
||||
|
||||
var reject = group.MapPost("/promotions/{id}/reject", RejectPromotion)
|
||||
.WithDescription("Record a rejection decision on the specified pending promotion request with an optional rejection reason, blocking the release from advancing. The calling principal must hold the release approval scope. Returns 404 when the promotion ID does not exist.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
reject.WithName("ReleaseDashboard_RejectPromotion");
|
||||
}
|
||||
}
|
||||
|
||||
private static IResult GetDashboard()
|
||||
{
|
||||
return Results.Ok(ReleaseDashboardSnapshotBuilder.Build());
|
||||
}
|
||||
|
||||
private static IResult ApprovePromotion(string id)
|
||||
{
|
||||
var exists = ApprovalEndpoints.SeedData.Approvals
|
||||
.Any(approval => string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return exists
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { message = $"Promotion '{id}' was not found." });
|
||||
}
|
||||
|
||||
private static IResult RejectPromotion(string id, [FromBody] RejectPromotionRequest? request)
|
||||
{
|
||||
var exists = ApprovalEndpoints.SeedData.Approvals
|
||||
.Any(approval => string.Equals(approval.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return exists
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { message = $"Promotion '{id}' was not found." });
|
||||
}
|
||||
|
||||
public sealed record RejectPromotionRequest(string? Reason);
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Release management endpoints for the Orchestrator service.
|
||||
/// Provides CRUD and lifecycle operations for managed releases.
|
||||
/// Routes: /api/release-orchestrator/releases
|
||||
/// </summary>
|
||||
public static class ReleaseEndpoints
|
||||
{
|
||||
private static readonly DateTimeOffset PreviewEvaluatedAt = DateTimeOffset.Parse("2026-02-19T03:15:00Z");
|
||||
|
||||
public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapReleaseGroup(app, "/api/release-orchestrator/releases", includeRouteNames: true);
|
||||
MapReleaseGroup(app, "/api/v1/release-orchestrator/releases", includeRouteNames: false);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapReleaseGroup(
|
||||
IEndpointRouteBuilder app,
|
||||
string prefix,
|
||||
bool includeRouteNames)
|
||||
{
|
||||
var group = app.MapGroup(prefix)
|
||||
.WithTags("Releases")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseRead)
|
||||
.RequireTenant();
|
||||
|
||||
var list = group.MapGet(string.Empty, ListReleases)
|
||||
.WithDescription("Return a paginated list of releases for the calling tenant, optionally filtered by status, environment, project, and creation time window. Each release record includes its name, version, current status, component count, and lifecycle timestamps.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
list.WithName("Release_List");
|
||||
}
|
||||
|
||||
var detail = group.MapGet("/{id}", GetRelease)
|
||||
.WithDescription("Return the full release record for the specified ID including name, version, status, component list, approval gate state, and event history summary. Returns 404 when the release does not exist in the tenant.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
detail.WithName("Release_Get");
|
||||
}
|
||||
|
||||
var create = group.MapPost(string.Empty, CreateRelease)
|
||||
.WithDescription("Create a new release record in Draft state. The release captures an intent to promote a versioned set of components through defined environments. Returns 409 if a release with the same name and version already exists.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
create.WithName("Release_Create");
|
||||
}
|
||||
|
||||
var update = group.MapPatch("/{id}", UpdateRelease)
|
||||
.WithDescription("Update mutable metadata on the specified release including description, target environment, and custom labels. Status transitions must be performed through the dedicated lifecycle endpoints. Returns 404 when the release does not exist.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
update.WithName("Release_Update");
|
||||
}
|
||||
|
||||
var remove = group.MapDelete("/{id}", DeleteRelease)
|
||||
.WithDescription("Permanently remove the specified release record. Only releases in Draft or Failed status can be deleted; returns 409 for releases in other states. All associated components and events are removed with the release record.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
remove.WithName("Release_Delete");
|
||||
}
|
||||
|
||||
var ready = group.MapPost("/{id}/ready", MarkReady)
|
||||
.WithDescription("Transition the specified release from Draft to Ready state, signalling that all components are assembled and the release is eligible for promotion gate evaluation. Returns 409 if the release is not in Draft state or required components are missing.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
ready.WithName("Release_MarkReady");
|
||||
}
|
||||
|
||||
var promote = group.MapPost("/{id}/promote", RequestPromotion)
|
||||
.WithDescription("Initiate the promotion workflow to advance the specified release to its next target environment, triggering policy gate evaluation. The promotion runs asynchronously; poll the release record or subscribe to events for outcome updates.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
promote.WithName("Release_Promote");
|
||||
}
|
||||
|
||||
var deploy = group.MapPost("/{id}/deploy", Deploy)
|
||||
.WithDescription("Trigger deployment of the specified release to its current target environment. Deployment is orchestrated by the platform and may include pre-deployment checks, artifact staging, and post-deployment validation. Returns 409 if gates have not been satisfied.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
deploy.WithName("Release_Deploy");
|
||||
}
|
||||
|
||||
var rollback = group.MapPost("/{id}/rollback", Rollback)
|
||||
.WithDescription("Initiate a rollback of the specified deployed release to the previous stable version in the current environment. The rollback is audited and creates a new release event. Returns 409 if the release is not in Deployed state or no prior stable version exists.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseApprove);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
rollback.WithName("Release_Rollback");
|
||||
}
|
||||
|
||||
var clone = group.MapPost("/{id}/clone", CloneRelease)
|
||||
.WithDescription("Create a new release by copying the components, labels, and target environment from the specified source release, applying a new name and version. The cloned release starts in Draft state and is independent of the source.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
clone.WithName("Release_Clone");
|
||||
}
|
||||
|
||||
var components = group.MapGet("/{releaseId}/components", GetComponents)
|
||||
.WithDescription("Return the list of components registered in the specified release including their artifact references, versions, content digests, and current deployment status. Returns 404 when the release does not exist.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
components.WithName("Release_GetComponents");
|
||||
}
|
||||
|
||||
var addComponent = group.MapPost("/{releaseId}/components", AddComponent)
|
||||
.WithDescription("Register a new component in the specified release, supplying the artifact reference and content digest. Components must be added before the release is marked Ready. Returns 409 if a component with the same name is already registered.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
addComponent.WithName("Release_AddComponent");
|
||||
}
|
||||
|
||||
var updateComponent = group.MapPatch("/{releaseId}/components/{componentId}", UpdateComponent)
|
||||
.WithDescription("Update the artifact reference, version, or content digest of the specified release component. Returns 404 when the component does not exist within the release or the release itself does not exist in the tenant.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
updateComponent.WithName("Release_UpdateComponent");
|
||||
}
|
||||
|
||||
var removeComponent = group.MapDelete("/{releaseId}/components/{componentId}", RemoveComponent)
|
||||
.WithDescription("Remove the specified component from the release. Only permitted when the release is in Draft state; returns 409 for releases that are Ready or beyond. Returns 404 when the component or release does not exist in the tenant.")
|
||||
.RequireAuthorization(JobEnginePolicies.ReleaseWrite);
|
||||
if (includeRouteNames)
|
||||
{
|
||||
removeComponent.WithName("Release_RemoveComponent");
|
||||
}
|
||||
|
||||
var events = group.MapGet("/{releaseId}/events", GetEvents)
|
||||
.WithDescription("Return the chronological event log for the specified release including status transitions, gate evaluations, approval decisions, deployment actions, and rollback events. Useful for audit trails and post-incident analysis.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
events.WithName("Release_GetEvents");
|
||||
}
|
||||
|
||||
var preview = group.MapGet("/{releaseId}/promotion-preview", GetPromotionPreview)
|
||||
.WithDescription("Evaluate and return the gate check results for the specified release's next promotion without committing any state change. Returns the verdict for each configured policy gate so operators can assess promotion eligibility before triggering it.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
preview.WithName("Release_PromotionPreview");
|
||||
}
|
||||
|
||||
var targets = group.MapGet("/{releaseId}/available-environments", GetAvailableEnvironments)
|
||||
.WithDescription("Return the list of environment targets that the specified release can be promoted to from its current state, based on the configured promotion pipeline and the caller's access rights. Returns 404 when the release does not exist.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
targets.WithName("Release_AvailableEnvironments");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
private static IResult ListReleases(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? statuses,
|
||||
[FromQuery] string? environment,
|
||||
[FromQuery] string? sortField,
|
||||
[FromQuery] string? sortOrder,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] int? pageSize)
|
||||
{
|
||||
var releases = SeedData.Releases.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.ToLowerInvariant();
|
||||
releases = releases.Where(r =>
|
||||
r.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Version.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Description.Contains(term, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statuses))
|
||||
{
|
||||
var statusList = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
releases = releases.Where(r => statusList.Contains(r.Status, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
releases = releases.Where(r =>
|
||||
string.Equals(r.CurrentEnvironment, environment, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.TargetEnvironment, environment, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var sorted = (sortField?.ToLowerInvariant(), sortOrder?.ToLowerInvariant()) switch
|
||||
{
|
||||
("name", "asc") => releases.OrderBy(r => r.Name),
|
||||
("name", _) => releases.OrderByDescending(r => r.Name),
|
||||
("version", "asc") => releases.OrderBy(r => r.Version),
|
||||
("version", _) => releases.OrderByDescending(r => r.Version),
|
||||
("status", "asc") => releases.OrderBy(r => r.Status),
|
||||
("status", _) => releases.OrderByDescending(r => r.Status),
|
||||
(_, "asc") => releases.OrderBy(r => r.CreatedAt),
|
||||
_ => releases.OrderByDescending(r => r.CreatedAt),
|
||||
};
|
||||
|
||||
var all = sorted.ToList();
|
||||
var effectivePage = Math.Max(page ?? 1, 1);
|
||||
var effectivePageSize = Math.Clamp(pageSize ?? 20, 1, 100);
|
||||
var items = all.Skip((effectivePage - 1) * effectivePageSize).Take(effectivePageSize).ToList();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
items,
|
||||
total = all.Count,
|
||||
page = effectivePage,
|
||||
pageSize = effectivePageSize,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetRelease(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
return release is not null ? Results.Ok(release) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult CreateRelease([FromBody] CreateReleaseDto request, [FromServices] TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
var release = new ManagedReleaseDto
|
||||
{
|
||||
Id = $"rel-{Guid.NewGuid():N}"[..11],
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
Description = request.Description ?? "",
|
||||
Status = "draft",
|
||||
CurrentEnvironment = null,
|
||||
TargetEnvironment = request.TargetEnvironment,
|
||||
ComponentCount = 0,
|
||||
CreatedAt = now,
|
||||
CreatedBy = "api",
|
||||
UpdatedAt = now,
|
||||
DeployedAt = null,
|
||||
DeploymentStrategy = request.DeploymentStrategy ?? "rolling",
|
||||
};
|
||||
return Results.Created($"/api/release-orchestrator/releases/{release.Id}", release);
|
||||
}
|
||||
|
||||
private static IResult UpdateRelease(string id, [FromBody] UpdateReleaseDto request)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Name = request.Name ?? release.Name,
|
||||
Description = request.Description ?? release.Description,
|
||||
TargetEnvironment = request.TargetEnvironment ?? release.TargetEnvironment,
|
||||
DeploymentStrategy = request.DeploymentStrategy ?? release.DeploymentStrategy,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult DeleteRelease(string id)
|
||||
{
|
||||
var exists = SeedData.Releases.Any(r => r.Id == id);
|
||||
return exists ? Results.NoContent() : Results.NotFound();
|
||||
}
|
||||
|
||||
private static IResult MarkReady(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
return Results.Ok(release with { Status = "ready", UpdatedAt = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
private static IResult RequestPromotion(
|
||||
string id,
|
||||
[FromBody] PromoteDto request,
|
||||
[FromServices] TimeProvider time)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
|
||||
var targetEnvironment = ResolveTargetEnvironment(request);
|
||||
var existing = ApprovalEndpoints.SeedData.Approvals
|
||||
.Select(ApprovalEndpoints.WithDerivedSignals)
|
||||
.FirstOrDefault(a =>
|
||||
string.Equals(a.ReleaseId, id, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(a.TargetEnvironment, targetEnvironment, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(a.Status, "pending", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Ok(ApprovalEndpoints.ToSummary(existing));
|
||||
}
|
||||
|
||||
var nextId = $"apr-{ApprovalEndpoints.SeedData.Approvals.Count + 1:000}";
|
||||
var now = time.GetUtcNow().ToString("O");
|
||||
var approval = ApprovalEndpoints.WithDerivedSignals(new ApprovalEndpoints.ApprovalDto
|
||||
{
|
||||
Id = nextId,
|
||||
ReleaseId = release.Id,
|
||||
ReleaseName = release.Name,
|
||||
ReleaseVersion = release.Version,
|
||||
SourceEnvironment = release.CurrentEnvironment ?? "staging",
|
||||
TargetEnvironment = targetEnvironment,
|
||||
RequestedBy = "release-orchestrator",
|
||||
RequestedAt = now,
|
||||
Urgency = request.Urgency ?? "normal",
|
||||
Justification = string.IsNullOrWhiteSpace(request.Justification)
|
||||
? $"Promotion requested for {release.Name} {release.Version}."
|
||||
: request.Justification.Trim(),
|
||||
Status = "pending",
|
||||
CurrentApprovals = 0,
|
||||
RequiredApprovals = 2,
|
||||
GatesPassed = true,
|
||||
ScheduledTime = request.ScheduledTime,
|
||||
ExpiresAt = time.GetUtcNow().AddHours(48).ToString("O"),
|
||||
GateResults = new List<ApprovalEndpoints.GateResultDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GateId = "g-security",
|
||||
GateName = "Security Snapshot",
|
||||
Type = "security",
|
||||
Status = "passed",
|
||||
Message = "Critical reachable findings within policy threshold.",
|
||||
Details = new Dictionary<string, object>(),
|
||||
EvaluatedAt = now,
|
||||
},
|
||||
new()
|
||||
{
|
||||
GateId = "g-ops",
|
||||
GateName = "Data Integrity",
|
||||
Type = "quality",
|
||||
Status = "warning",
|
||||
Message = "Runtime ingest lag reduces confidence for production decisions.",
|
||||
Details = new Dictionary<string, object>(),
|
||||
EvaluatedAt = now,
|
||||
},
|
||||
},
|
||||
ReleaseComponents = BuildReleaseComponents(release.Id),
|
||||
});
|
||||
|
||||
ApprovalEndpoints.SeedData.Approvals.Add(approval);
|
||||
return Results.Ok(ApprovalEndpoints.ToSummary(approval));
|
||||
}
|
||||
|
||||
private static IResult Deploy(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Status = "deployed",
|
||||
CurrentEnvironment = release.TargetEnvironment,
|
||||
TargetEnvironment = null,
|
||||
DeployedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult Rollback(string id)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Status = "rolled_back",
|
||||
CurrentEnvironment = null,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult CloneRelease(string id, [FromBody] CloneReleaseDto request)
|
||||
{
|
||||
var release = SeedData.Releases.FirstOrDefault(r => r.Id == id);
|
||||
if (release is null) return Results.NotFound();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return Results.Ok(release with
|
||||
{
|
||||
Id = $"rel-{Guid.NewGuid():N}"[..11],
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
Status = "draft",
|
||||
CurrentEnvironment = null,
|
||||
TargetEnvironment = null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
DeployedAt = null,
|
||||
CreatedBy = "api",
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetComponents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
return Results.Ok(components);
|
||||
}
|
||||
|
||||
private static IResult AddComponent(string releaseId, [FromBody] AddComponentDto request)
|
||||
{
|
||||
var component = new ReleaseComponentDto
|
||||
{
|
||||
Id = $"comp-{Guid.NewGuid():N}"[..12],
|
||||
ReleaseId = releaseId,
|
||||
Name = request.Name,
|
||||
ImageRef = request.ImageRef,
|
||||
Digest = request.Digest,
|
||||
Tag = request.Tag,
|
||||
Version = request.Version,
|
||||
Type = request.Type,
|
||||
ConfigOverrides = request.ConfigOverrides ?? new Dictionary<string, string>(),
|
||||
};
|
||||
return Results.Created($"/api/release-orchestrator/releases/{releaseId}/components/{component.Id}", component);
|
||||
}
|
||||
|
||||
private static IResult UpdateComponent(string releaseId, string componentId, [FromBody] UpdateComponentDto request)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
return Results.NotFound();
|
||||
var comp = components.FirstOrDefault(c => c.Id == componentId);
|
||||
if (comp is null) return Results.NotFound();
|
||||
return Results.Ok(comp with { ConfigOverrides = request.ConfigOverrides ?? comp.ConfigOverrides });
|
||||
}
|
||||
|
||||
private static IResult RemoveComponent(string releaseId, string componentId)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult GetEvents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Events.TryGetValue(releaseId, out var events))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
return Results.Ok(events);
|
||||
}
|
||||
|
||||
private static IResult GetPromotionPreview(string releaseId, [FromQuery] string? targetEnvironmentId)
|
||||
{
|
||||
var targetEnvironment = targetEnvironmentId == "env-production" ? "production" : "staging";
|
||||
var risk = ReleaseControlSignalCatalog.GetRiskSnapshot(releaseId, targetEnvironment);
|
||||
var coverage = ReleaseControlSignalCatalog.GetCoverage(releaseId);
|
||||
var ops = ReleaseControlSignalCatalog.GetOpsConfidence(targetEnvironment);
|
||||
var manifestDigest = ResolveManifestDigest(releaseId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
releaseId,
|
||||
releaseName = "Platform Release",
|
||||
sourceEnvironment = "staging",
|
||||
targetEnvironment,
|
||||
manifestDigest,
|
||||
riskSnapshot = risk,
|
||||
reachabilityCoverage = coverage,
|
||||
opsConfidence = ops,
|
||||
gateResults = new[]
|
||||
{
|
||||
new { gateId = "g1", gateName = "Security Scan", type = "security", status = "passed", message = "No blocking vulnerabilities found", details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
new { gateId = "g2", gateName = "Policy Compliance", type = "policy", status = "passed", message = "All policies satisfied", details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
new { gateId = "g3", gateName = "Ops Data Integrity", type = "quality", status = ops.Status == "healthy" ? "passed" : "warning", message = ops.Summary, details = new Dictionary<string, object>(), evaluatedAt = PreviewEvaluatedAt },
|
||||
},
|
||||
allGatesPassed = true,
|
||||
requiredApprovers = 2,
|
||||
estimatedDeployTime = 300,
|
||||
warnings = ops.Status == "healthy"
|
||||
? Array.Empty<string>()
|
||||
: new[] { "Data-integrity confidence is degraded; decision remains auditable but requires explicit acknowledgment." },
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetAvailableEnvironments(string releaseId)
|
||||
{
|
||||
return Results.Ok(new[]
|
||||
{
|
||||
new { id = "env-staging", name = "Staging", tier = "staging", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("staging") },
|
||||
new { id = "env-production", name = "Production", tier = "production", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("production") },
|
||||
new { id = "env-canary", name = "Canary", tier = "production", opsConfidence = ReleaseControlSignalCatalog.GetOpsConfidence("canary") },
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveTargetEnvironment(PromoteDto request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.TargetEnvironment))
|
||||
{
|
||||
return request.TargetEnvironment.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return request.TargetEnvironmentId switch
|
||||
{
|
||||
"env-production" => "production",
|
||||
"env-canary" => "canary",
|
||||
_ => "staging",
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveManifestDigest(string releaseId)
|
||||
{
|
||||
if (SeedData.Components.TryGetValue(releaseId, out var components) && components.Count > 0)
|
||||
{
|
||||
var digestSeed = string.Join('|', components.Select(component => component.Digest));
|
||||
return $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(digestSeed))).ToLowerInvariant()[..64]}";
|
||||
}
|
||||
|
||||
return $"sha256:{releaseId.Replace("-", string.Empty, StringComparison.Ordinal).PadRight(64, '0')[..64]}";
|
||||
}
|
||||
|
||||
private static List<ApprovalEndpoints.ReleaseComponentSummaryDto> BuildReleaseComponents(string releaseId)
|
||||
{
|
||||
if (!SeedData.Components.TryGetValue(releaseId, out var components))
|
||||
{
|
||||
return new List<ApprovalEndpoints.ReleaseComponentSummaryDto>();
|
||||
}
|
||||
|
||||
return components
|
||||
.OrderBy(component => component.Name, StringComparer.Ordinal)
|
||||
.Select(component => new ApprovalEndpoints.ReleaseComponentSummaryDto
|
||||
{
|
||||
Name = component.Name,
|
||||
Version = component.Version,
|
||||
Digest = component.Digest,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ---- DTOs ----
|
||||
|
||||
public sealed record ManagedReleaseDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? CurrentEnvironment { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public DateTimeOffset? DeployedAt { get; init; }
|
||||
public string DeploymentStrategy { get; init; } = "rolling";
|
||||
}
|
||||
|
||||
public sealed record ReleaseComponentDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public Dictionary<string, string> ConfigOverrides { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record ReleaseEventDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ReleaseId { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public Dictionary<string, object> Metadata { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record CreateReleaseDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? DeploymentStrategy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateReleaseDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? DeploymentStrategy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PromoteDto
|
||||
{
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? TargetEnvironmentId { get; init; }
|
||||
public string? Urgency { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ScheduledTime { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CloneReleaseDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AddComponentDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public Dictionary<string, string>? ConfigOverrides { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateComponentDto
|
||||
{
|
||||
public Dictionary<string, string>? ConfigOverrides { get; init; }
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class SeedData
|
||||
{
|
||||
public static readonly List<ManagedReleaseDto> Releases = new()
|
||||
{
|
||||
new() { Id = "rel-001", Name = "Platform Release", Version = "1.2.3", Description = "Feature release with API improvements and bug fixes", Status = "deployed", CurrentEnvironment = "production", TargetEnvironment = null, ComponentCount = 3, CreatedAt = DateTimeOffset.Parse("2026-01-10T08:00:00Z"), CreatedBy = "deploy-bot", UpdatedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"), DeployedAt = DateTimeOffset.Parse("2026-01-11T14:30:00Z"), DeploymentStrategy = "rolling" },
|
||||
new() { Id = "rel-002", Name = "Platform Release", Version = "1.3.0-rc1", Description = "Release candidate for next major version", Status = "ready", CurrentEnvironment = "staging", TargetEnvironment = "production", ComponentCount = 4, CreatedAt = DateTimeOffset.Parse("2026-01-11T10:00:00Z"), CreatedBy = "ci-pipeline", UpdatedAt = DateTimeOffset.Parse("2026-01-12T09:00:00Z"), DeploymentStrategy = "blue_green" },
|
||||
new() { Id = "rel-003", Name = "Hotfix", Version = "1.2.4", Description = "Critical security patch", Status = "deploying", CurrentEnvironment = "staging", TargetEnvironment = "production", ComponentCount = 1, CreatedAt = DateTimeOffset.Parse("2026-01-12T06:00:00Z"), CreatedBy = "security-team", UpdatedAt = DateTimeOffset.Parse("2026-01-12T10:00:00Z"), DeploymentStrategy = "rolling" },
|
||||
new() { Id = "rel-004", Name = "Feature Branch", Version = "2.0.0-alpha", Description = "New architecture preview", Status = "draft", TargetEnvironment = "dev", ComponentCount = 5, CreatedAt = DateTimeOffset.Parse("2026-01-08T15:00:00Z"), CreatedBy = "dev-team", UpdatedAt = DateTimeOffset.Parse("2026-01-10T11:00:00Z"), DeploymentStrategy = "recreate" },
|
||||
new() { Id = "rel-005", Name = "Platform Release", Version = "1.2.2", Description = "Previous stable release", Status = "rolled_back", ComponentCount = 3, CreatedAt = DateTimeOffset.Parse("2026-01-05T12:00:00Z"), CreatedBy = "deploy-bot", UpdatedAt = DateTimeOffset.Parse("2026-01-10T08:00:00Z"), DeployedAt = DateTimeOffset.Parse("2026-01-06T10:00:00Z"), DeploymentStrategy = "rolling" },
|
||||
};
|
||||
|
||||
public static readonly Dictionary<string, List<ReleaseComponentDto>> Components = new()
|
||||
{
|
||||
["rel-001"] = new()
|
||||
{
|
||||
new() { Id = "comp-001", ReleaseId = "rel-001", Name = "api-service", ImageRef = "registry.example.com/api-service", Digest = "sha256:abc123def456", Tag = "v1.2.3", Version = "1.2.3", Type = "container" },
|
||||
new() { Id = "comp-002", ReleaseId = "rel-001", Name = "worker-service", ImageRef = "registry.example.com/worker-service", Digest = "sha256:def456abc789", Tag = "v1.2.3", Version = "1.2.3", Type = "container" },
|
||||
new() { Id = "comp-003", ReleaseId = "rel-001", Name = "web-app", ImageRef = "registry.example.com/web-app", Digest = "sha256:789abc123def", Tag = "v1.2.3", Version = "1.2.3", Type = "container" },
|
||||
},
|
||||
["rel-002"] = new()
|
||||
{
|
||||
new() { Id = "comp-004", ReleaseId = "rel-002", Name = "api-service", ImageRef = "registry.example.com/api-service", Digest = "sha256:new123new456", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" },
|
||||
new() { Id = "comp-005", ReleaseId = "rel-002", Name = "worker-service", ImageRef = "registry.example.com/worker-service", Digest = "sha256:new456new789", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" },
|
||||
new() { Id = "comp-006", ReleaseId = "rel-002", Name = "web-app", ImageRef = "registry.example.com/web-app", Digest = "sha256:new789newabc", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "container" },
|
||||
new() { Id = "comp-007", ReleaseId = "rel-002", Name = "migration", ImageRef = "registry.example.com/migration", Digest = "sha256:mig123mig456", Tag = "v1.3.0-rc1", Version = "1.3.0-rc1", Type = "script" },
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly Dictionary<string, List<ReleaseEventDto>> Events = new()
|
||||
{
|
||||
["rel-001"] = new()
|
||||
{
|
||||
new() { Id = "evt-001", ReleaseId = "rel-001", Type = "created", Environment = null, Actor = "deploy-bot", Message = "Release created", Timestamp = DateTimeOffset.Parse("2026-01-10T08:00:00Z") },
|
||||
new() { Id = "evt-002", ReleaseId = "rel-001", Type = "promoted", Environment = "dev", Actor = "deploy-bot", Message = "Promoted to dev", Timestamp = DateTimeOffset.Parse("2026-01-10T09:00:00Z") },
|
||||
new() { Id = "evt-003", ReleaseId = "rel-001", Type = "deployed", Environment = "dev", Actor = "deploy-bot", Message = "Successfully deployed to dev", Timestamp = DateTimeOffset.Parse("2026-01-10T09:30:00Z") },
|
||||
new() { Id = "evt-004", ReleaseId = "rel-001", Type = "approved", Environment = "staging", Actor = "qa-team", Message = "Approved for staging", Timestamp = DateTimeOffset.Parse("2026-01-10T14:00:00Z") },
|
||||
new() { Id = "evt-005", ReleaseId = "rel-001", Type = "deployed", Environment = "staging", Actor = "deploy-bot", Message = "Successfully deployed to staging", Timestamp = DateTimeOffset.Parse("2026-01-10T14:30:00Z") },
|
||||
new() { Id = "evt-006", ReleaseId = "rel-001", Type = "approved", Environment = "production", Actor = "release-manager", Message = "Approved for production", Timestamp = DateTimeOffset.Parse("2026-01-11T10:00:00Z") },
|
||||
new() { Id = "evt-007", ReleaseId = "rel-001", Type = "deployed", Environment = "production", Actor = "deploy-bot", Message = "Successfully deployed to production", Timestamp = DateTimeOffset.Parse("2026-01-11T14:30:00Z") },
|
||||
},
|
||||
["rel-002"] = new()
|
||||
{
|
||||
new() { Id = "evt-008", ReleaseId = "rel-002", Type = "created", Environment = null, Actor = "ci-pipeline", Message = "Release created from CI", Timestamp = DateTimeOffset.Parse("2026-01-11T10:00:00Z") },
|
||||
new() { Id = "evt-009", ReleaseId = "rel-002", Type = "deployed", Environment = "staging", Actor = "deploy-bot", Message = "Deployed to staging for testing", Timestamp = DateTimeOffset.Parse("2026-01-11T12:00:00Z") },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
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 runs (batch executions).
|
||||
/// </summary>
|
||||
public static class RunEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps run endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/runs")
|
||||
.WithTags("Orchestrator Runs")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet(string.Empty, ListRuns)
|
||||
.WithName("Orchestrator_ListRuns")
|
||||
.WithDescription(_t("orchestrator.run.list_description"));
|
||||
|
||||
group.MapGet("{runId:guid}", GetRun)
|
||||
.WithName("Orchestrator_GetRun")
|
||||
.WithDescription(_t("orchestrator.run.get_description"));
|
||||
|
||||
group.MapGet("{runId:guid}/jobs", GetRunJobs)
|
||||
.WithName("Orchestrator_GetRunJobs")
|
||||
.WithDescription(_t("orchestrator.run.get_jobs_description"));
|
||||
|
||||
group.MapGet("{runId:guid}/summary", GetRunSummary)
|
||||
.WithName("Orchestrator_GetRunSummary")
|
||||
.WithDescription(_t("orchestrator.run.get_summary_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRuns(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository repository,
|
||||
[FromQuery] Guid? sourceId = null,
|
||||
[FromQuery] string? runType = null,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? projectId = null,
|
||||
[FromQuery] string? createdAfter = null,
|
||||
[FromQuery] string? createdBefore = 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 parsedStatus = EndpointHelpers.TryParseRunStatus(status);
|
||||
var parsedCreatedAfter = EndpointHelpers.TryParseDateTimeOffset(createdAfter);
|
||||
var parsedCreatedBefore = EndpointHelpers.TryParseDateTimeOffset(createdBefore);
|
||||
|
||||
var runs = await repository.ListAsync(
|
||||
tenantId,
|
||||
sourceId,
|
||||
runType,
|
||||
parsedStatus,
|
||||
projectId,
|
||||
parsedCreatedAfter,
|
||||
parsedCreatedBefore,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = runs.Select(RunResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new RunListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRun(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var run = await repository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(RunResponse.FromDomain(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunJobs(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Verify run exists
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var jobs = await jobRepository.GetByRunIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
var responses = jobs.Select(JobResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(new JobListResponse(responses, null));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunSummary(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Return the aggregate counts from the run itself
|
||||
var summary = new
|
||||
{
|
||||
runId = run.RunId,
|
||||
status = run.Status.ToString().ToLowerInvariant(),
|
||||
totalJobs = run.TotalJobs,
|
||||
completedJobs = run.CompletedJobs,
|
||||
succeededJobs = run.SucceededJobs,
|
||||
failedJobs = run.FailedJobs,
|
||||
pendingJobs = run.TotalJobs - run.CompletedJobs,
|
||||
createdAt = run.CreatedAt,
|
||||
startedAt = run.StartedAt,
|
||||
completedAt = run.CompletedAt
|
||||
};
|
||||
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.JobEngine.Core.Scale;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for autoscaling metrics and load shedding status.
|
||||
/// </summary>
|
||||
public static class ScaleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps scale endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapScaleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/scale")
|
||||
.WithTags("Scaling")
|
||||
.AllowAnonymous();
|
||||
|
||||
// Autoscaling metrics for KEDA/HPA
|
||||
group.MapGet("/metrics", GetAutoscaleMetrics)
|
||||
.WithName("Orchestrator_AutoscaleMetrics")
|
||||
.WithDescription(_t("orchestrator.scale.metrics_description"));
|
||||
|
||||
// Prometheus-compatible metrics endpoint
|
||||
group.MapGet("/metrics/prometheus", GetPrometheusMetrics)
|
||||
.WithName("Orchestrator_PrometheusScaleMetrics")
|
||||
.WithDescription(_t("orchestrator.scale.prometheus_description"));
|
||||
|
||||
// Load shedding status
|
||||
group.MapGet("/load", GetLoadStatus)
|
||||
.WithName("Orchestrator_LoadStatus")
|
||||
.WithDescription(_t("orchestrator.scale.load_description"));
|
||||
|
||||
// Scale snapshot for debugging
|
||||
group.MapGet("/snapshot", GetScaleSnapshot)
|
||||
.WithName("Orchestrator_ScaleSnapshot")
|
||||
.WithDescription(_t("orchestrator.scale.snapshot_description"));
|
||||
|
||||
// Startup probe (slower to pass, includes warmup check)
|
||||
app.MapGet("/startupz", GetStartupStatus)
|
||||
.WithName("Orchestrator_StartupProbe")
|
||||
.WithTags("Health")
|
||||
.WithDescription(_t("orchestrator.scale.startupz_description"))
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IResult GetAutoscaleMetrics(
|
||||
[FromServices] ScaleMetrics scaleMetrics)
|
||||
{
|
||||
var metrics = scaleMetrics.GetAutoscaleMetrics();
|
||||
return Results.Ok(metrics);
|
||||
}
|
||||
|
||||
private static IResult GetPrometheusMetrics(
|
||||
[FromServices] ScaleMetrics scaleMetrics,
|
||||
[FromServices] LoadShedder loadShedder)
|
||||
{
|
||||
var metrics = scaleMetrics.GetAutoscaleMetrics();
|
||||
var loadStatus = loadShedder.GetStatus();
|
||||
|
||||
// Format as Prometheus text exposition
|
||||
var lines = new List<string>
|
||||
{
|
||||
"# HELP orchestrator_queue_depth Current number of pending jobs",
|
||||
"# TYPE orchestrator_queue_depth gauge",
|
||||
$"orchestrator_queue_depth {metrics.QueueDepth}",
|
||||
"",
|
||||
"# HELP orchestrator_active_jobs Current number of active jobs",
|
||||
"# TYPE orchestrator_active_jobs gauge",
|
||||
$"orchestrator_active_jobs {metrics.ActiveJobs}",
|
||||
"",
|
||||
"# HELP orchestrator_dispatch_latency_p95_ms P95 dispatch latency in milliseconds",
|
||||
"# TYPE orchestrator_dispatch_latency_p95_ms gauge",
|
||||
$"orchestrator_dispatch_latency_p95_ms {metrics.DispatchLatencyP95Ms:F2}",
|
||||
"",
|
||||
"# HELP orchestrator_dispatch_latency_p99_ms P99 dispatch latency in milliseconds",
|
||||
"# TYPE orchestrator_dispatch_latency_p99_ms gauge",
|
||||
$"orchestrator_dispatch_latency_p99_ms {metrics.DispatchLatencyP99Ms:F2}",
|
||||
"",
|
||||
"# HELP orchestrator_recommended_replicas Recommended replica count for autoscaling",
|
||||
"# TYPE orchestrator_recommended_replicas gauge",
|
||||
$"orchestrator_recommended_replicas {metrics.RecommendedReplicas}",
|
||||
"",
|
||||
"# HELP orchestrator_under_pressure Whether the system is under pressure (1=yes, 0=no)",
|
||||
"# TYPE orchestrator_under_pressure gauge",
|
||||
$"orchestrator_under_pressure {(metrics.IsUnderPressure ? 1 : 0)}",
|
||||
"",
|
||||
"# HELP orchestrator_load_factor Current load factor (1.0 = at target)",
|
||||
"# TYPE orchestrator_load_factor gauge",
|
||||
$"orchestrator_load_factor {loadStatus.LoadFactor:F3}",
|
||||
"",
|
||||
"# HELP orchestrator_load_shedding_state Current load shedding state (0=normal, 1=warning, 2=critical, 3=emergency)",
|
||||
"# TYPE orchestrator_load_shedding_state gauge",
|
||||
$"orchestrator_load_shedding_state {(int)loadStatus.State}",
|
||||
"",
|
||||
"# HELP orchestrator_scale_samples Number of latency samples in measurement window",
|
||||
"# TYPE orchestrator_scale_samples gauge",
|
||||
$"orchestrator_scale_samples {metrics.SamplesInWindow}"
|
||||
};
|
||||
|
||||
return Results.Text(string.Join("\n", lines), "text/plain");
|
||||
}
|
||||
|
||||
private static IResult GetLoadStatus(
|
||||
[FromServices] LoadShedder loadShedder)
|
||||
{
|
||||
var status = loadShedder.GetStatus();
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static IResult GetScaleSnapshot(
|
||||
[FromServices] ScaleMetrics scaleMetrics,
|
||||
[FromServices] LoadShedder loadShedder)
|
||||
{
|
||||
var snapshot = scaleMetrics.GetSnapshot();
|
||||
var loadStatus = loadShedder.GetStatus();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
snapshot.Timestamp,
|
||||
snapshot.TotalQueueDepth,
|
||||
snapshot.TotalActiveJobs,
|
||||
DispatchLatency = new
|
||||
{
|
||||
snapshot.DispatchLatency.Count,
|
||||
snapshot.DispatchLatency.Min,
|
||||
snapshot.DispatchLatency.Max,
|
||||
snapshot.DispatchLatency.Avg,
|
||||
snapshot.DispatchLatency.P50,
|
||||
snapshot.DispatchLatency.P95,
|
||||
snapshot.DispatchLatency.P99
|
||||
},
|
||||
LoadShedding = new
|
||||
{
|
||||
loadStatus.State,
|
||||
loadStatus.LoadFactor,
|
||||
loadStatus.IsSheddingLoad,
|
||||
loadStatus.AcceptingPriority,
|
||||
loadStatus.RecommendedDelayMs
|
||||
},
|
||||
QueueDepthByKey = snapshot.QueueDepthByKey,
|
||||
ActiveJobsByKey = snapshot.ActiveJobsByKey
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetStartupStatus(
|
||||
[FromServices] ScaleMetrics scaleMetrics,
|
||||
[FromServices] StartupProbe startupProbe)
|
||||
{
|
||||
if (!startupProbe.IsReady)
|
||||
{
|
||||
return Results.Json(new StartupResponse(
|
||||
Status: "starting",
|
||||
Ready: false,
|
||||
UptimeSeconds: startupProbe.UptimeSeconds,
|
||||
WarmupComplete: startupProbe.WarmupComplete,
|
||||
Message: startupProbe.StatusMessage),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Results.Ok(new StartupResponse(
|
||||
Status: "started",
|
||||
Ready: true,
|
||||
UptimeSeconds: startupProbe.UptimeSeconds,
|
||||
WarmupComplete: startupProbe.WarmupComplete,
|
||||
Message: "Service is ready"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup probe response.
|
||||
/// </summary>
|
||||
public sealed record StartupResponse(
|
||||
string Status,
|
||||
bool Ready,
|
||||
double UptimeSeconds,
|
||||
bool WarmupComplete,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Startup probe service that tracks warmup status.
|
||||
/// </summary>
|
||||
public sealed class StartupProbe
|
||||
{
|
||||
private readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow;
|
||||
private readonly TimeSpan _minWarmupTime;
|
||||
private volatile bool _warmupComplete;
|
||||
private string _statusMessage = "Starting up";
|
||||
|
||||
public StartupProbe(TimeSpan? minWarmupTime = null)
|
||||
{
|
||||
_minWarmupTime = minWarmupTime ?? TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the service is ready.
|
||||
/// </summary>
|
||||
public bool IsReady => WarmupComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether warmup has completed.
|
||||
/// </summary>
|
||||
public bool WarmupComplete
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_warmupComplete) return true;
|
||||
|
||||
// Auto-complete warmup after minimum time
|
||||
if (UptimeSeconds >= _minWarmupTime.TotalSeconds)
|
||||
{
|
||||
_warmupComplete = true;
|
||||
_statusMessage = "Warmup complete";
|
||||
}
|
||||
|
||||
return _warmupComplete;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the uptime in seconds.
|
||||
/// </summary>
|
||||
public double UptimeSeconds => (DateTimeOffset.UtcNow - _startTime).TotalSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current status message.
|
||||
/// </summary>
|
||||
public string StatusMessage => _statusMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Marks warmup as complete.
|
||||
/// </summary>
|
||||
public void MarkWarmupComplete()
|
||||
{
|
||||
_warmupComplete = true;
|
||||
_statusMessage = "Warmup complete";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status message.
|
||||
/// </summary>
|
||||
public void SetStatus(string message)
|
||||
{
|
||||
_statusMessage = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Core.SloManagement;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for SLO management.
|
||||
/// </summary>
|
||||
public static class SloEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps SLO endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapSloEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/slos")
|
||||
.WithTags("Orchestrator SLOs")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
// SLO CRUD operations
|
||||
group.MapGet(string.Empty, ListSlos)
|
||||
.WithName("Orchestrator_ListSlos")
|
||||
.WithDescription("Return a cursor-paginated list of Service Level Objectives defined for the calling tenant, optionally filtered by enabled state and job type. Each SLO record includes its target metric, threshold, evaluation window, and current enabled state.");
|
||||
|
||||
group.MapGet("{sloId:guid}", GetSlo)
|
||||
.WithName("Orchestrator_GetSlo")
|
||||
.WithDescription("Return the full definition of the specified SLO including its target metric type (success rate, p95 latency, throughput), threshold value, evaluation window, job type scope, and enabled state. Returns 404 when the SLO does not exist in the tenant.");
|
||||
|
||||
group.MapPost(string.Empty, CreateSlo)
|
||||
.WithName("Orchestrator_CreateSlo")
|
||||
.WithDescription("Create a new Service Level Objective for the calling tenant. The SLO is disabled by default and must be explicitly enabled. Specify the metric type, threshold, evaluation window, and the job type it governs.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapPut("{sloId:guid}", UpdateSlo)
|
||||
.WithName("Orchestrator_UpdateSlo")
|
||||
.WithDescription("Update the definition of the specified SLO including threshold, evaluation window, and description. The SLO must be disabled before structural changes can be applied. Returns 404 when the SLO does not exist in the tenant.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapDelete("{sloId:guid}", DeleteSlo)
|
||||
.WithName("Orchestrator_DeleteSlo")
|
||||
.WithDescription("Permanently remove the specified SLO definition and all associated alert thresholds. Active alerts linked to this SLO are automatically resolved. Returns 404 when the SLO does not exist in the tenant.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// SLO state
|
||||
group.MapGet("{sloId:guid}/state", GetSloState)
|
||||
.WithName("Orchestrator_GetSloState")
|
||||
.WithDescription("Return the current evaluation state of the specified SLO including the measured metric value, the computed burn rate relative to the threshold, and whether the SLO is currently in breach. Updated on each evaluation cycle.");
|
||||
|
||||
group.MapGet("states", GetAllSloStates)
|
||||
.WithName("Orchestrator_GetAllSloStates")
|
||||
.WithDescription("Return the current evaluation state for all enabled SLOs in the calling tenant in a single response. Useful for operations dashboards that need a snapshot of overall SLO health without polling each SLO individually.");
|
||||
|
||||
// SLO control
|
||||
group.MapPost("{sloId:guid}/enable", EnableSlo)
|
||||
.WithName("Orchestrator_EnableSlo")
|
||||
.WithDescription("Activate the specified SLO so that it is included in evaluation cycles and can generate alerts when its threshold is breached. The SLO must be in a disabled state; enabling an already-active SLO is a no-op.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapPost("{sloId:guid}/disable", DisableSlo)
|
||||
.WithName("Orchestrator_DisableSlo")
|
||||
.WithDescription("Deactivate the specified SLO, pausing evaluation and suppressing new alerts. Any active alerts are automatically acknowledged. The SLO definition is retained and can be re-enabled without data loss.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Alert thresholds
|
||||
group.MapGet("{sloId:guid}/thresholds", ListThresholds)
|
||||
.WithName("Orchestrator_ListAlertThresholds")
|
||||
.WithDescription("Return all alert thresholds configured for the specified SLO including their severity level, burn rate multiplier trigger, and notification channel references. Thresholds define the graduated alerting behaviour as an SLO degrades.");
|
||||
|
||||
group.MapPost("{sloId:guid}/thresholds", CreateThreshold)
|
||||
.WithName("Orchestrator_CreateAlertThreshold")
|
||||
.WithDescription("Add a new alert threshold to the specified SLO. Each threshold specifies a severity level and the burn rate or metric value at which the alert fires. Multiple thresholds at different severities implement graduated alerting.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapDelete("{sloId:guid}/thresholds/{thresholdId:guid}", DeleteThreshold)
|
||||
.WithName("Orchestrator_DeleteAlertThreshold")
|
||||
.WithDescription("Remove the specified alert threshold from its parent SLO. In-flight alerts generated by this threshold are not automatically resolved. Returns 404 when the threshold ID does not belong to the SLO in the calling tenant.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Alerts
|
||||
group.MapGet("alerts", ListAlerts)
|
||||
.WithName("Orchestrator_ListSloAlerts")
|
||||
.WithDescription("Return a paginated list of SLO alerts for the calling tenant, optionally filtered by SLO ID, severity, status (firing, acknowledged, resolved), and time window. Each alert record includes the SLO reference, breach value, and lifecycle timestamps.");
|
||||
|
||||
group.MapGet("alerts/{alertId:guid}", GetAlert)
|
||||
.WithName("Orchestrator_GetSloAlert")
|
||||
.WithDescription("Return the full alert record for the specified ID including the SLO reference, fired-at timestamp, breach metric value, current status, and the acknowledge/resolve attribution if applicable. Returns 404 when the alert does not exist in the tenant.");
|
||||
|
||||
group.MapPost("alerts/{alertId:guid}/acknowledge", AcknowledgeAlert)
|
||||
.WithName("Orchestrator_AcknowledgeAlert")
|
||||
.WithDescription("Acknowledge the specified SLO alert, recording the calling principal and timestamp. Acknowledgment suppresses repeat notifications for the breach period but does not resolve the alert; the SLO violation must be corrected for resolution.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
group.MapPost("alerts/{alertId:guid}/resolve", ResolveAlert)
|
||||
.WithName("Orchestrator_ResolveAlert")
|
||||
.WithDescription("Mark the specified SLO alert as resolved, attributing the resolution to the calling principal. Resolved alerts are archived and excluded from active alert counts. Use when the underlying SLO breach has been addressed and the system is within threshold.")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate);
|
||||
|
||||
// Summary
|
||||
group.MapGet("summary", GetSloSummary)
|
||||
.WithName("Orchestrator_GetSloSummary")
|
||||
.WithDescription("Return a tenant-wide SLO health summary including total SLO count, count of SLOs currently in breach, count of enabled SLOs, and the number of active (unresolved) alerts grouped by severity. Used for high-level service health dashboards.");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSlos(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromQuery] bool? enabled = null,
|
||||
[FromQuery] string? jobType = 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 slos = await repository.ListAsync(
|
||||
tenantId,
|
||||
enabledOnly: enabled ?? false,
|
||||
jobType: jobType,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Apply pagination manually since ListAsync doesn't support it directly
|
||||
var paged = slos.Skip(offset).Take(effectiveLimit).ToList();
|
||||
var responses = paged.Select(SloResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new SloListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSlo(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var slo = await repository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(SloResponse.FromDomain(slo));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSlo(
|
||||
HttpContext context,
|
||||
[FromBody] CreateSloRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
// Parse and validate type
|
||||
if (!TryParseSloType(request.Type, out var sloType))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid SLO type. Must be 'availability', 'latency', or 'throughput'" });
|
||||
}
|
||||
|
||||
// Parse and validate window
|
||||
if (!TryParseSloWindow(request.Window, out var window))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid window. Must be '1h', '1d', '7d', or '30d'" });
|
||||
}
|
||||
|
||||
// Create SLO based on type
|
||||
Slo slo = sloType switch
|
||||
{
|
||||
SloType.Availability => Slo.CreateAvailability(
|
||||
tenantId, request.Name, request.Target, window, actorId, now,
|
||||
request.Description, request.JobType, request.SourceId),
|
||||
|
||||
SloType.Latency => Slo.CreateLatency(
|
||||
tenantId, request.Name,
|
||||
request.LatencyPercentile ?? 0.95,
|
||||
request.LatencyTargetSeconds ?? 1.0,
|
||||
request.Target, window, actorId, now,
|
||||
request.Description, request.JobType, request.SourceId),
|
||||
|
||||
SloType.Throughput => Slo.CreateThroughput(
|
||||
tenantId, request.Name,
|
||||
request.ThroughputMinimum ?? 1,
|
||||
request.Target, window, actorId, now,
|
||||
request.Description, request.JobType, request.SourceId),
|
||||
|
||||
_ => throw new InvalidOperationException($"Unknown SLO type: {sloType}")
|
||||
};
|
||||
|
||||
await repository.CreateAsync(slo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/jobengine/slos/{slo.SloId}", SloResponse.FromDomain(slo));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSlo(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromBody] UpdateSloRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var slo = await repository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var updated = slo.Update(
|
||||
updatedAt: now,
|
||||
name: request.Name,
|
||||
description: request.Description,
|
||||
target: request.Target,
|
||||
enabled: request.Enabled,
|
||||
updatedBy: actorId);
|
||||
|
||||
await repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SloResponse.FromDomain(updated));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteSlo(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var deleted = await repository.DeleteAsync(tenantId, sloId, 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> GetSloState(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromServices] IBurnRateEngine burnRateEngine,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var slo = await repository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var state = await burnRateEngine.ComputeStateAsync(slo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new SloWithStateResponse(
|
||||
Slo: SloResponse.FromDomain(slo),
|
||||
State: SloStateResponse.FromDomain(state)));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAllSloStates(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromServices] IBurnRateEngine burnRateEngine,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var states = await burnRateEngine.ComputeAllStatesAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var slos = await repository.ListAsync(tenantId, enabledOnly: true, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var sloMap = slos.ToDictionary(s => s.SloId);
|
||||
var responses = states
|
||||
.Where(s => sloMap.ContainsKey(s.SloId))
|
||||
.Select(s => new SloWithStateResponse(
|
||||
Slo: SloResponse.FromDomain(sloMap[s.SloId]),
|
||||
State: SloStateResponse.FromDomain(s)))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(responses);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> EnableSlo(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var slo = await repository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var enabled = slo.Enable(actorId, now);
|
||||
await repository.UpdateAsync(enabled, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SloResponse.FromDomain(enabled));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DisableSlo(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var slo = await repository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var disabled = slo.Disable(actorId, now);
|
||||
await repository.UpdateAsync(disabled, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SloResponse.FromDomain(disabled));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListThresholds(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository sloRepository,
|
||||
[FromServices] IAlertThresholdRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var slo = await sloRepository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var thresholds = await repository.ListBySloAsync(sloId, cancellationToken).ConfigureAwait(false);
|
||||
var responses = thresholds.Select(AlertThresholdResponse.FromDomain).ToList();
|
||||
|
||||
return Results.Ok(responses);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateThreshold(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromBody] CreateAlertThresholdRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository sloRepository,
|
||||
[FromServices] IAlertThresholdRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var actorId = context.User?.Identity?.Name ?? "system";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var slo = await sloRepository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryParseAlertSeverity(request.Severity, out var severity))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Invalid severity. Must be 'info', 'warning', 'critical', or 'emergency'" });
|
||||
}
|
||||
|
||||
var threshold = AlertBudgetThreshold.Create(
|
||||
sloId: sloId,
|
||||
tenantId: tenantId,
|
||||
budgetConsumedThreshold: request.BudgetConsumedThreshold,
|
||||
severity: severity,
|
||||
createdBy: actorId,
|
||||
createdAt: now,
|
||||
burnRateThreshold: request.BurnRateThreshold,
|
||||
notificationChannel: request.NotificationChannel,
|
||||
notificationEndpoint: request.NotificationEndpoint,
|
||||
cooldown: request.CooldownMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.CooldownMinutes.Value)
|
||||
: null);
|
||||
|
||||
await repository.CreateAsync(threshold, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/jobengine/slos/{sloId}/thresholds/{threshold.ThresholdId}",
|
||||
AlertThresholdResponse.FromDomain(threshold));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteThreshold(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sloId,
|
||||
[FromRoute] Guid thresholdId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository sloRepository,
|
||||
[FromServices] IAlertThresholdRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var slo = await sloRepository.GetByIdAsync(tenantId, sloId, cancellationToken).ConfigureAwait(false);
|
||||
if (slo is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var deleted = await repository.DeleteAsync(tenantId, thresholdId, 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> ListAlerts(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloAlertRepository repository,
|
||||
[FromQuery] Guid? sloId = null,
|
||||
[FromQuery] bool? acknowledged = null,
|
||||
[FromQuery] bool? resolved = 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 alerts = await repository.ListAsync(
|
||||
tenantId, sloId, acknowledged, resolved, effectiveLimit, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var responses = alerts.Select(SloAlertResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new SloAlertListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAlert(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid alertId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloAlertRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var alert = await repository.GetByIdAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (alert is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(SloAlertResponse.FromDomain(alert));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> AcknowledgeAlert(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid alertId,
|
||||
[FromBody] AcknowledgeAlertRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloAlertRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var alert = await repository.GetByIdAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (alert is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (alert.IsAcknowledged)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Alert is already acknowledged" });
|
||||
}
|
||||
|
||||
var acknowledged = alert.Acknowledge(request.AcknowledgedBy, timeProvider.GetUtcNow());
|
||||
await repository.UpdateAsync(acknowledged, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SloAlertResponse.FromDomain(acknowledged));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResolveAlert(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid alertId,
|
||||
[FromBody] ResolveAlertRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloAlertRepository repository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
var alert = await repository.GetByIdAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (alert is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (alert.IsResolved)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Alert is already resolved" });
|
||||
}
|
||||
|
||||
var resolved = alert.Resolve(request.ResolutionNotes, timeProvider.GetUtcNow());
|
||||
await repository.UpdateAsync(resolved, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SloAlertResponse.FromDomain(resolved));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSloSummary(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISloRepository sloRepository,
|
||||
[FromServices] ISloAlertRepository alertRepository,
|
||||
[FromServices] IBurnRateEngine burnRateEngine,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var slos = await sloRepository.ListAsync(tenantId, enabledOnly: false, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var enabledSlos = slos.Where(s => s.Enabled).ToList();
|
||||
var states = await burnRateEngine.ComputeAllStatesAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var activeAlertCount = await alertRepository.GetActiveAlertCountAsync(tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var alerts = await alertRepository.ListAsync(tenantId, null, false, false, 100, 0, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var unacknowledgedAlerts = alerts.Count(a => !a.IsAcknowledged && !a.IsResolved);
|
||||
var criticalAlerts = alerts.Count(a => !a.IsResolved &&
|
||||
(a.Severity == AlertSeverity.Critical || a.Severity == AlertSeverity.Emergency));
|
||||
|
||||
// Find SLOs at risk (budget consumed > 50% or burn rate > 2x)
|
||||
var sloMap = enabledSlos.ToDictionary(s => s.SloId);
|
||||
var slosAtRisk = states
|
||||
.Where(s => sloMap.ContainsKey(s.SloId) && (s.BudgetConsumed >= 0.5 || s.BurnRate >= 2.0))
|
||||
.OrderByDescending(s => s.BudgetConsumed)
|
||||
.Take(10)
|
||||
.Select(s => new SloWithStateResponse(
|
||||
Slo: SloResponse.FromDomain(sloMap[s.SloId]),
|
||||
State: SloStateResponse.FromDomain(s)))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new SloSummaryResponse(
|
||||
TotalSlos: slos.Count,
|
||||
EnabledSlos: enabledSlos.Count,
|
||||
ActiveAlerts: activeAlertCount,
|
||||
UnacknowledgedAlerts: unacknowledgedAlerts,
|
||||
CriticalAlerts: criticalAlerts,
|
||||
SlosAtRisk: slosAtRisk));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseSloType(string value, out SloType type)
|
||||
{
|
||||
type = value.ToLowerInvariant() switch
|
||||
{
|
||||
"availability" => SloType.Availability,
|
||||
"latency" => SloType.Latency,
|
||||
"throughput" => SloType.Throughput,
|
||||
_ => default
|
||||
};
|
||||
return value.ToLowerInvariant() is "availability" or "latency" or "throughput";
|
||||
}
|
||||
|
||||
private static bool TryParseSloWindow(string value, out SloWindow window)
|
||||
{
|
||||
window = value.ToLowerInvariant() switch
|
||||
{
|
||||
"1h" or "one_hour" => SloWindow.OneHour,
|
||||
"1d" or "one_day" => SloWindow.OneDay,
|
||||
"7d" or "seven_days" => SloWindow.SevenDays,
|
||||
"30d" or "thirty_days" => SloWindow.ThirtyDays,
|
||||
_ => default
|
||||
};
|
||||
return value.ToLowerInvariant() is "1h" or "one_hour" or "1d" or "one_day" or "7d" or "seven_days" or "30d" or "thirty_days";
|
||||
}
|
||||
|
||||
private static bool TryParseAlertSeverity(string value, out AlertSeverity severity)
|
||||
{
|
||||
severity = value.ToLowerInvariant() switch
|
||||
{
|
||||
"info" => AlertSeverity.Info,
|
||||
"warning" => AlertSeverity.Warning,
|
||||
"critical" => AlertSeverity.Critical,
|
||||
"emergency" => AlertSeverity.Emergency,
|
||||
_ => default
|
||||
};
|
||||
return value.ToLowerInvariant() is "info" or "warning" or "critical" or "emergency";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Infrastructure.Repositories;
|
||||
using StellaOps.JobEngine.WebService.Contracts;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for job sources.
|
||||
/// </summary>
|
||||
public static class SourceEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps source endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapSourceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/sources")
|
||||
.WithTags("Orchestrator Sources")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet(string.Empty, ListSources)
|
||||
.WithName("Orchestrator_ListSources")
|
||||
.WithDescription("Return a cursor-paginated list of job sources registered for the calling tenant, optionally filtered by source type and enabled state. Sources represent the external integrations or internal triggers that produce jobs for the orchestrator.");
|
||||
|
||||
group.MapGet("{sourceId:guid}", GetSource)
|
||||
.WithName("Orchestrator_GetSource")
|
||||
.WithDescription("Return the configuration and status record for a single job source identified by its GUID. Returns 404 when no source with that ID exists in the tenant.");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSources(
|
||||
HttpContext context,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISourceRepository repository,
|
||||
[FromQuery] string? sourceType = null,
|
||||
[FromQuery] bool? enabled = 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 sources = await repository.ListAsync(
|
||||
tenantId,
|
||||
sourceType,
|
||||
enabled,
|
||||
effectiveLimit,
|
||||
offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responses = sources.Select(SourceResponse.FromDomain).ToList();
|
||||
var nextCursor = EndpointHelpers.CreateNextCursor(offset, effectiveLimit, responses.Count);
|
||||
|
||||
return Results.Ok(new SourceListResponse(responses, nextCursor));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSource(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid sourceId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] ISourceRepository repository,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
var source = await repository.GetByIdAsync(tenantId, sourceId, cancellationToken).ConfigureAwait(false);
|
||||
if (source is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(SourceResponse.FromDomain(source));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Infrastructure.Repositories;
|
||||
using StellaOps.JobEngine.WebService.Services;
|
||||
using StellaOps.JobEngine.WebService.Streaming;
|
||||
|
||||
namespace StellaOps.JobEngine.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Server-Sent Events streaming endpoints for real-time updates.
|
||||
/// </summary>
|
||||
public static class StreamEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps stream endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapStreamEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/stream")
|
||||
.WithTags("Orchestrator Streams")
|
||||
.RequireAuthorization(JobEnginePolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("jobs/{jobId:guid}", StreamJob)
|
||||
.WithName("Orchestrator_StreamJob")
|
||||
.WithDescription("Open a Server-Sent Events (SSE) stream delivering real-time status change events for the specified job. The stream closes when the job reaches a terminal state (Succeeded, Failed, Canceled, TimedOut) or the client disconnects. Returns 404 if the job does not exist.");
|
||||
|
||||
group.MapGet("runs/{runId:guid}", StreamRun)
|
||||
.WithName("Orchestrator_StreamRun")
|
||||
.WithDescription("Open a Server-Sent Events (SSE) stream delivering real-time run progress events including individual job status changes and aggregate counters. The stream closes when all jobs in the run reach terminal states or the client disconnects.");
|
||||
|
||||
group.MapGet("pack-runs/{packRunId:guid}", StreamPackRun)
|
||||
.WithName("Orchestrator_StreamPackRun")
|
||||
.WithDescription("Open a Server-Sent Events (SSE) stream delivering real-time log lines and status transitions for the specified pack run. Log lines are emitted in append order; the stream closes when the pack run completes or is canceled.");
|
||||
|
||||
group.MapGet("pack-runs/{packRunId:guid}/ws", StreamPackRunWebSocket)
|
||||
.WithName("Orchestrator_StreamPackRunWebSocket")
|
||||
.WithDescription("Establish a WebSocket connection for real-time log and status streaming of the specified pack run. Functionally equivalent to the SSE endpoint but uses the WebSocket protocol for environments where SSE is not supported. Requires an HTTP upgrade handshake.");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task StreamJob(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] IJobStreamCoordinator streamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.ResolveForStreaming(context);
|
||||
|
||||
var job = await jobRepository.GetByIdAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Job not found" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await streamCoordinator.StreamAsync(context, tenantId, job, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Client disconnected
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { error = ex.Message }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamRun(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid runId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IRunStreamCoordinator streamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.ResolveForStreaming(context);
|
||||
|
||||
var run = await runRepository.GetByIdAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Run not found" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await streamCoordinator.StreamAsync(context, tenantId, run, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Client disconnected
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { error = ex.Message }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamPackRun(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packRunId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IPackRunStreamCoordinator streamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = tenantResolver.ResolveForStreaming(context);
|
||||
var packRun = await packRunRepository.GetByIdAsync(tenantId, packRunId, cancellationToken).ConfigureAwait(false);
|
||||
if (packRun is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Pack run not found" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await streamCoordinator.StreamAsync(context, tenantId, packRun, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { error = ex.Message }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamPackRunWebSocket(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid packRunId,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IPackRunRepository packRunRepository,
|
||||
[FromServices] IPackRunStreamCoordinator streamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Expected WebSocket request" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.ResolveForStreaming(context);
|
||||
var packRun = await packRunRepository.GetByIdAsync(tenantId, packRunId, cancellationToken).ConfigureAwait(false);
|
||||
if (packRun is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Pack run not found" }, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||
await streamCoordinator.StreamWebSocketAsync(socket, tenantId, packRun, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.JobEngine.Core.Domain;
|
||||
using StellaOps.JobEngine.Infrastructure;
|
||||
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>
|
||||
/// Worker endpoints for job claim, heartbeat, progress, and completion.
|
||||
/// </summary>
|
||||
public static class WorkerEndpoints
|
||||
{
|
||||
private const int DefaultLeaseSeconds = 300; // 5 minutes
|
||||
private const int MaxLeaseSeconds = 3600; // 1 hour
|
||||
private const int DefaultExtendSeconds = 300;
|
||||
private const int MaxExtendSeconds = 1800; // 30 minutes
|
||||
|
||||
/// <summary>
|
||||
/// Maps worker endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapWorkerEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/jobengine/worker")
|
||||
.WithTags("Orchestrator Workers")
|
||||
.RequireAuthorization(JobEnginePolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("claim", ClaimJob)
|
||||
.WithName("Orchestrator_ClaimJob")
|
||||
.WithDescription(_t("orchestrator.worker.claim_description"));
|
||||
|
||||
group.MapPost("jobs/{jobId:guid}/heartbeat", Heartbeat)
|
||||
.WithName("Orchestrator_Heartbeat")
|
||||
.WithDescription(_t("orchestrator.worker.heartbeat_description"));
|
||||
|
||||
group.MapPost("jobs/{jobId:guid}/progress", ReportProgress)
|
||||
.WithName("Orchestrator_ReportProgress")
|
||||
.WithDescription(_t("orchestrator.worker.progress_description"));
|
||||
|
||||
group.MapPost("jobs/{jobId:guid}/complete", CompleteJob)
|
||||
.WithName("Orchestrator_CompleteJob")
|
||||
.WithDescription(_t("orchestrator.worker.complete_description"));
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClaimJob(
|
||||
HttpContext context,
|
||||
[FromBody] ClaimRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.WorkerId))
|
||||
{
|
||||
return Results.BadRequest(new WorkerErrorResponse(
|
||||
"invalid_request",
|
||||
_t("orchestrator.worker.error.worker_id_required"),
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Idempotency check - if idempotency key provided, check for existing claim
|
||||
if (!string.IsNullOrEmpty(request.IdempotencyKey))
|
||||
{
|
||||
var existingJob = await jobRepository.GetByIdempotencyKeyAsync(
|
||||
tenantId, $"claim:{request.IdempotencyKey}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existingJob is not null && existingJob.Status == JobStatus.Leased &&
|
||||
existingJob.WorkerId == request.WorkerId)
|
||||
{
|
||||
// Return the existing claim
|
||||
return Results.Ok(CreateClaimResponse(existingJob));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate lease duration
|
||||
var leaseSeconds = Math.Min(request.LeaseSeconds ?? DefaultLeaseSeconds, MaxLeaseSeconds);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var leaseUntil = now.AddSeconds(leaseSeconds);
|
||||
var leaseId = Guid.NewGuid();
|
||||
|
||||
// Try to acquire a job
|
||||
var job = await jobRepository.LeaseNextAsync(
|
||||
tenantId,
|
||||
request.JobType,
|
||||
leaseId,
|
||||
request.WorkerId,
|
||||
leaseUntil,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("no_jobs_available", "No jobs available for claim", null, 5),
|
||||
statusCode: StatusCodes.Status204NoContent);
|
||||
}
|
||||
|
||||
// Update task runner ID if provided
|
||||
if (!string.IsNullOrEmpty(request.TaskRunnerId) && job.TaskRunnerId != request.TaskRunnerId)
|
||||
{
|
||||
await jobRepository.UpdateStatusAsync(
|
||||
tenantId,
|
||||
job.JobId,
|
||||
job.Status,
|
||||
job.Attempt,
|
||||
job.LeaseId,
|
||||
job.WorkerId,
|
||||
request.TaskRunnerId,
|
||||
job.LeaseUntil,
|
||||
job.ScheduledAt,
|
||||
job.LeasedAt,
|
||||
job.CompletedAt,
|
||||
job.NotBefore,
|
||||
job.Reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
job = job with { TaskRunnerId = request.TaskRunnerId };
|
||||
}
|
||||
|
||||
JobEngineMetrics.JobLeased(tenantId, job.JobType);
|
||||
|
||||
return Results.Ok(CreateClaimResponse(job));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Heartbeat(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromBody] HeartbeatRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Get current job
|
||||
var job = await jobRepository.GetByIdAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound(new WorkerErrorResponse(
|
||||
"job_not_found",
|
||||
$"Job {jobId} not found",
|
||||
jobId,
|
||||
null));
|
||||
}
|
||||
|
||||
// Verify lease ownership
|
||||
if (job.LeaseId != request.LeaseId)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("invalid_lease", "Lease ID does not match", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
if (job.Status != JobStatus.Leased)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("invalid_status", $"Job is not in leased status: {job.Status}", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
// Calculate extension
|
||||
var extendSeconds = Math.Min(request.ExtendSeconds ?? DefaultExtendSeconds, MaxExtendSeconds);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var newLeaseUntil = now.AddSeconds(extendSeconds);
|
||||
|
||||
// Extend the lease
|
||||
var extended = await jobRepository.ExtendLeaseAsync(
|
||||
tenantId, jobId, request.LeaseId, newLeaseUntil, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!extended)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("lease_expired", "Lease has expired and cannot be extended", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
JobEngineMetrics.LeaseExtended(tenantId, job.JobType);
|
||||
JobEngineMetrics.HeartbeatReceived(tenantId, job.JobType);
|
||||
|
||||
return Results.Ok(new HeartbeatResponse(
|
||||
jobId,
|
||||
request.LeaseId,
|
||||
newLeaseUntil,
|
||||
Acknowledged: true));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReportProgress(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromBody] ProgressRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Get current job
|
||||
var job = await jobRepository.GetByIdAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound(new WorkerErrorResponse(
|
||||
"job_not_found",
|
||||
$"Job {jobId} not found",
|
||||
jobId,
|
||||
null));
|
||||
}
|
||||
|
||||
// Verify lease ownership
|
||||
if (job.LeaseId != request.LeaseId)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("invalid_lease", "Lease ID does not match", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
if (job.Status != JobStatus.Leased)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("invalid_status", $"Job is not in leased status: {job.Status}", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
// Validate progress percentage
|
||||
if (request.ProgressPercent.HasValue && (request.ProgressPercent.Value < 0 || request.ProgressPercent.Value > 100))
|
||||
{
|
||||
return Results.BadRequest(new WorkerErrorResponse(
|
||||
"invalid_progress",
|
||||
"Progress percentage must be between 0 and 100",
|
||||
jobId,
|
||||
null));
|
||||
}
|
||||
|
||||
// Progress is recorded via metrics/events; in a full implementation we'd store it
|
||||
JobEngineMetrics.ProgressReported(tenantId, job.JobType);
|
||||
|
||||
return Results.Ok(new ProgressResponse(
|
||||
jobId,
|
||||
Acknowledged: true,
|
||||
LeaseUntil: job.LeaseUntil ?? timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteJob(
|
||||
HttpContext context,
|
||||
[FromRoute] Guid jobId,
|
||||
[FromBody] CompleteRequest request,
|
||||
[FromServices] TenantResolver tenantResolver,
|
||||
[FromServices] IJobRepository jobRepository,
|
||||
[FromServices] IArtifactRepository artifactRepository,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantResolver.Resolve(context);
|
||||
|
||||
// Get current job
|
||||
var job = await jobRepository.GetByIdAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound(new WorkerErrorResponse(
|
||||
"job_not_found",
|
||||
$"Job {jobId} not found",
|
||||
jobId,
|
||||
null));
|
||||
}
|
||||
|
||||
// Verify lease ownership
|
||||
if (job.LeaseId != request.LeaseId)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("invalid_lease", "Lease ID does not match", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
if (job.Status != JobStatus.Leased)
|
||||
{
|
||||
return Results.Json(
|
||||
new WorkerErrorResponse("invalid_status", $"Job is not in leased status: {job.Status}", jobId, null),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var newStatus = request.Success ? JobStatus.Succeeded : JobStatus.Failed;
|
||||
|
||||
// Create artifacts if provided
|
||||
var artifactIds = new List<Guid>();
|
||||
if (request.Artifacts is { Count: > 0 })
|
||||
{
|
||||
var artifacts = request.Artifacts.Select(a => new Artifact(
|
||||
ArtifactId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
JobId: jobId,
|
||||
RunId: job.RunId,
|
||||
ArtifactType: a.ArtifactType,
|
||||
Uri: a.Uri,
|
||||
Digest: a.Digest,
|
||||
MimeType: a.MimeType,
|
||||
SizeBytes: a.SizeBytes,
|
||||
CreatedAt: now,
|
||||
Metadata: a.Metadata)).ToList();
|
||||
|
||||
await artifactRepository.CreateBatchAsync(artifacts, cancellationToken).ConfigureAwait(false);
|
||||
artifactIds.AddRange(artifacts.Select(a => a.ArtifactId));
|
||||
}
|
||||
|
||||
// Update job status
|
||||
await jobRepository.UpdateStatusAsync(
|
||||
tenantId,
|
||||
jobId,
|
||||
newStatus,
|
||||
job.Attempt,
|
||||
null, // Clear lease
|
||||
null, // Clear worker
|
||||
null, // Clear task runner
|
||||
null, // Clear lease until
|
||||
job.ScheduledAt,
|
||||
job.LeasedAt,
|
||||
now, // Set completed at
|
||||
job.NotBefore,
|
||||
request.Reason,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update run counts if job belongs to a run
|
||||
if (job.RunId.HasValue)
|
||||
{
|
||||
await runRepository.IncrementJobCountsAsync(
|
||||
tenantId, job.RunId.Value, request.Success, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
var duration = job.LeasedAt.HasValue ? (now - job.LeasedAt.Value).TotalSeconds : 0;
|
||||
JobEngineMetrics.JobCompleted(tenantId, job.JobType, newStatus.ToString().ToLowerInvariant());
|
||||
JobEngineMetrics.RecordJobDuration(tenantId, job.JobType, duration);
|
||||
|
||||
if (!request.Success)
|
||||
{
|
||||
JobEngineMetrics.JobFailed(tenantId, job.JobType);
|
||||
}
|
||||
|
||||
return Results.Ok(new CompleteResponse(
|
||||
jobId,
|
||||
newStatus.ToString().ToLowerInvariant(),
|
||||
now,
|
||||
artifactIds,
|
||||
duration));
|
||||
}
|
||||
|
||||
private static ClaimResponse CreateClaimResponse(Job job)
|
||||
{
|
||||
return new ClaimResponse(
|
||||
job.JobId,
|
||||
job.LeaseId!.Value,
|
||||
job.JobType,
|
||||
job.Payload,
|
||||
job.PayloadDigest,
|
||||
job.Attempt,
|
||||
job.MaxAttempts,
|
||||
job.LeaseUntil!.Value,
|
||||
job.IdempotencyKey,
|
||||
job.CorrelationId,
|
||||
job.RunId,
|
||||
job.ProjectId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user