consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
internal interface IPolicyRunService
|
||||
{
|
||||
Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRunStatus?> RequestCancellationAsync(string tenantId, string runId, string? reason, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRunStatus> RetryAsync(string tenantId, string runId, string? requestedBy, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
internal sealed class InMemoryPolicyRunService : IPolicyRunService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyRunStatus> _runs = new(StringComparer.Ordinal);
|
||||
private readonly List<PolicyRunStatus> _orderedRuns = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryPolicyRunService(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var runId = string.IsNullOrWhiteSpace(request.RunId)
|
||||
? GenerateRunId(request.PolicyId, request.QueuedAt ?? now)
|
||||
: request.RunId;
|
||||
|
||||
var queuedAt = request.QueuedAt ?? now;
|
||||
|
||||
var status = new PolicyRunStatus(
|
||||
runId,
|
||||
tenantId,
|
||||
request.PolicyId ?? throw new ValidationException("policyId must be provided."),
|
||||
request.PolicyVersion ?? throw new ValidationException("policyVersion must be provided."),
|
||||
request.Mode,
|
||||
PolicyRunExecutionStatus.Queued,
|
||||
request.Priority,
|
||||
queuedAt,
|
||||
PolicyRunStats.Empty,
|
||||
request.Inputs ?? PolicyRunInputs.Empty,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty,
|
||||
cancellationRequested: false,
|
||||
cancellationRequestedAt: null,
|
||||
cancellationReason: null,
|
||||
SchedulerSchemaVersions.PolicyRunStatus);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_runs.TryGetValue(runId, out var existing))
|
||||
{
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
|
||||
_runs[runId] = status;
|
||||
_orderedRuns.Add(status);
|
||||
}
|
||||
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
List<PolicyRunStatus> snapshot;
|
||||
lock (_gate)
|
||||
{
|
||||
snapshot = _orderedRuns
|
||||
.Where(run => string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (options.PolicyId is { Length: > 0 } policyId)
|
||||
{
|
||||
snapshot = snapshot
|
||||
.Where(run => string.Equals(run.PolicyId, policyId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (options.Mode is { } mode)
|
||||
{
|
||||
snapshot = snapshot
|
||||
.Where(run => run.Mode == mode)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (options.Status is { } status)
|
||||
{
|
||||
snapshot = snapshot
|
||||
.Where(run => run.Status == status)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (options.QueuedAfter is { } since)
|
||||
{
|
||||
snapshot = snapshot
|
||||
.Where(run => run.QueuedAt >= since)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = snapshot
|
||||
.OrderByDescending(run => run.QueuedAt)
|
||||
.ThenBy(run => run.RunId, StringComparer.Ordinal)
|
||||
.Take(options.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyRunStatus>>(result);
|
||||
}
|
||||
|
||||
public Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_runs.TryGetValue(runId, out var run))
|
||||
{
|
||||
return Task.FromResult<PolicyRunStatus?>(null);
|
||||
}
|
||||
|
||||
if (!string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<PolicyRunStatus?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<PolicyRunStatus?>(run);
|
||||
}
|
||||
|
||||
public Task<PolicyRunStatus?> RequestCancellationAsync(string tenantId, string runId, string? reason, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PolicyRunStatus? updated;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_runs.TryGetValue(runId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<PolicyRunStatus?>(null);
|
||||
}
|
||||
|
||||
if (IsTerminal(existing.Status))
|
||||
{
|
||||
return Task.FromResult<PolicyRunStatus?>(existing);
|
||||
}
|
||||
|
||||
var cancellationReason = NormalizeCancellationReason(reason);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
updated = existing with
|
||||
{
|
||||
Status = PolicyRunExecutionStatus.Cancelled,
|
||||
FinishedAt = now,
|
||||
CancellationRequested = true,
|
||||
CancellationRequestedAt = now,
|
||||
CancellationReason = cancellationReason
|
||||
};
|
||||
|
||||
_runs[runId] = updated;
|
||||
var index = _orderedRuns.FindIndex(status => string.Equals(status.RunId, runId, StringComparison.Ordinal));
|
||||
if (index >= 0)
|
||||
{
|
||||
_orderedRuns[index] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<PolicyRunStatus?>(updated);
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus> RetryAsync(string tenantId, string runId, string? requestedBy, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PolicyRunStatus existing;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_runs.TryGetValue(runId, out var status) || !string.Equals(status.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new KeyNotFoundException($"Policy simulation {runId} was not found for tenant {tenantId}.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(status.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Simulation is still in progress and cannot be retried.");
|
||||
}
|
||||
|
||||
existing = status;
|
||||
}
|
||||
|
||||
var metadataBuilder = (existing.Metadata ?? ImmutableSortedDictionary<string, string>.Empty).ToBuilder();
|
||||
metadataBuilder["retry-of"] = runId;
|
||||
var request = new PolicyRunRequest(
|
||||
tenantId,
|
||||
existing.PolicyId,
|
||||
PolicyRunMode.Simulate,
|
||||
existing.Inputs,
|
||||
existing.Priority,
|
||||
runId: null,
|
||||
policyVersion: existing.PolicyVersion,
|
||||
requestedBy: NormalizeActor(requestedBy),
|
||||
queuedAt: _timeProvider.GetUtcNow(),
|
||||
correlationId: null,
|
||||
metadata: metadataBuilder.ToImmutable());
|
||||
|
||||
return await EnqueueAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string GenerateRunId(string policyId, DateTimeOffset timestamp)
|
||||
{
|
||||
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
|
||||
var suffix = _guidProvider.NewGuid().ToString("N")[..8];
|
||||
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
|
||||
}
|
||||
|
||||
private static bool IsTerminal(PolicyRunExecutionStatus status)
|
||||
=> status is PolicyRunExecutionStatus.Succeeded or PolicyRunExecutionStatus.Failed or PolicyRunExecutionStatus.Cancelled;
|
||||
|
||||
private static string? NormalizeCancellationReason(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
const int maxLength = 512;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeActor(string? actor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = actor.Trim();
|
||||
const int maxLength = 256;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
internal static class PolicyRunEndpointExtensions
|
||||
{
|
||||
private const string Scope = StellaOpsScopes.PolicyRun;
|
||||
|
||||
public static void MapPolicyRunEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/api/v1/scheduler/policy/runs")
|
||||
.RequireAuthorization(SchedulerPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListPolicyRunsAsync)
|
||||
.WithName("ListPolicyRuns")
|
||||
.WithDescription("Lists policy run records for the tenant with optional filters by status, mode, and time range. Returns a paginated collection ordered by queue time. Requires policy.run scope.");
|
||||
group.MapGet("/{runId}", GetPolicyRunAsync)
|
||||
.WithName("GetPolicyRun")
|
||||
.WithDescription("Returns the full policy run record for a specific run ID including status, policy reference, inputs, and verdict counts. Returns 404 if the run ID is not found. Requires policy.run scope.");
|
||||
group.MapPost("/", CreatePolicyRunAsync)
|
||||
.WithName("CreatePolicyRun")
|
||||
.WithDescription("Enqueues a new policy evaluation run for the specified policy ID and version. Returns 201 Created with the run ID and initial queued status. Requires policy.run scope.")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
}
|
||||
|
||||
internal static async Task<IResult> ListPolicyRunsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var options = PolicyRunQueryOptions.FromRequest(httpContext.Request);
|
||||
|
||||
var runs = await policyRunService
|
||||
.ListAsync(tenant.TenantId, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PolicyRunCollectionResponse(runs));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<IResult> GetPolicyRunAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var run = await policyRunService
|
||||
.GetAsync(tenant.TenantId, runId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return run is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new PolicyRunResponse(run));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CreatePolicyRunAsync(
|
||||
HttpContext httpContext,
|
||||
PolicyRunCreateRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var actorId = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
|
||||
{
|
||||
throw new ValidationException("policyVersion must be provided and greater than zero.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
throw new ValidationException("policyId must be provided.");
|
||||
}
|
||||
|
||||
var normalizedMetadata = NormalizeMetadata(request.Metadata);
|
||||
|
||||
var policyRunRequest = new PolicyRunRequest(
|
||||
tenant.TenantId,
|
||||
request.PolicyId,
|
||||
request.PolicyVersion,
|
||||
request.Mode,
|
||||
request.Priority,
|
||||
request.RunId,
|
||||
now,
|
||||
actorId,
|
||||
request.CorrelationId,
|
||||
normalizedMetadata,
|
||||
request.Inputs ?? PolicyRunInputs.Empty,
|
||||
request.SchemaVersion);
|
||||
|
||||
var status = await policyRunService
|
||||
.EnqueueAsync(tenant.TenantId, policyRunRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/scheduler/policy/runs/{status.RunId}", new PolicyRunResponse(status));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyRunCollectionResponse(
|
||||
[property: JsonPropertyName("runs")] IReadOnlyList<PolicyRunStatus> Runs);
|
||||
|
||||
internal sealed record PolicyRunResponse(
|
||||
[property: JsonPropertyName("run")] PolicyRunStatus Run);
|
||||
|
||||
internal sealed record PolicyRunCreateRequest(
|
||||
[property: JsonPropertyName("schemaVersion")] string? SchemaVersion,
|
||||
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||
[property: JsonPropertyName("policyVersion")] int? PolicyVersion,
|
||||
[property: JsonPropertyName("mode")] PolicyRunMode Mode = PolicyRunMode.Incremental,
|
||||
[property: JsonPropertyName("priority")] PolicyRunPriority Priority = PolicyRunPriority.Normal,
|
||||
[property: JsonPropertyName("runId")] string? RunId = null,
|
||||
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
[property: JsonPropertyName("inputs")] PolicyRunInputs? Inputs = null);
|
||||
|
||||
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
var key = pair.Key?.Trim();
|
||||
var value = pair.Value?.Trim();
|
||||
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.ToLowerInvariant();
|
||||
if (!builder.ContainsKey(normalizedKey))
|
||||
{
|
||||
builder[normalizedKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
internal sealed class PolicyRunQueryOptions
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
private PolicyRunQueryOptions()
|
||||
{
|
||||
}
|
||||
|
||||
public string? PolicyId { get; private set; }
|
||||
|
||||
public PolicyRunMode? Mode { get; private set; }
|
||||
|
||||
public PolicyRunExecutionStatus? Status { get; private set; }
|
||||
|
||||
public DateTimeOffset? QueuedAfter { get; private set; }
|
||||
|
||||
public int Limit { get; private set; } = DefaultLimit;
|
||||
|
||||
public PolicyRunQueryOptions ForceMode(PolicyRunMode mode)
|
||||
{
|
||||
Mode = mode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public static PolicyRunQueryOptions FromRequest(HttpRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var options = new PolicyRunQueryOptions();
|
||||
var query = request.Query;
|
||||
|
||||
if (query.TryGetValue("policyId", out var policyValues))
|
||||
{
|
||||
var policyId = policyValues.ToString().Trim();
|
||||
if (!string.IsNullOrEmpty(policyId))
|
||||
{
|
||||
options.PolicyId = policyId;
|
||||
}
|
||||
}
|
||||
|
||||
options.Mode = ParseEnum<PolicyRunMode>(query, "mode");
|
||||
options.Status = ParseEnum<PolicyRunExecutionStatus>(query, "status");
|
||||
options.QueuedAfter = ParseTimestamp(query);
|
||||
options.Limit = ParseLimit(query);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static TEnum? ParseEnum<TEnum>(IQueryCollection query, string key)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values) || values == StringValues.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = values.ToString().Trim();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ValidationException($"Value '{value}' is not valid for parameter '{key}'.");
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(IQueryCollection query)
|
||||
{
|
||||
if (!query.TryGetValue("since", out var values) || values == StringValues.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = values.ToString().Trim();
|
||||
if (string.IsNullOrEmpty(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
|
||||
{
|
||||
return timestamp.ToUniversalTime();
|
||||
}
|
||||
|
||||
throw new ValidationException($"Value '{candidate}' is not a valid ISO-8601 timestamp.");
|
||||
}
|
||||
|
||||
private static int ParseLimit(IQueryCollection query)
|
||||
{
|
||||
if (!query.TryGetValue("limit", out var values) || values == StringValues.Empty)
|
||||
{
|
||||
return DefaultLimit;
|
||||
}
|
||||
|
||||
var candidate = values.ToString().Trim();
|
||||
if (string.IsNullOrEmpty(candidate))
|
||||
{
|
||||
return DefaultLimit;
|
||||
}
|
||||
|
||||
if (!int.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
|
||||
{
|
||||
throw new ValidationException("Parameter 'limit' must be a positive integer.");
|
||||
}
|
||||
|
||||
if (parsed > MaxLimit)
|
||||
{
|
||||
throw new ValidationException($"Parameter 'limit' must not exceed {MaxLimit}.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
internal sealed class PolicyRunService : IPolicyRunService
|
||||
{
|
||||
private readonly IPolicyRunJobRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PolicyRunService> _logger;
|
||||
|
||||
public PolicyRunService(
|
||||
IPolicyRunJobRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PolicyRunService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var runId = string.IsNullOrWhiteSpace(request.RunId)
|
||||
? GenerateRunId(request.PolicyId, now)
|
||||
: request.RunId!;
|
||||
|
||||
// Idempotency: return existing job if present when a runId was supplied.
|
||||
if (!string.IsNullOrWhiteSpace(request.RunId))
|
||||
{
|
||||
var existing = await _repository
|
||||
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogDebug("Policy run job already exists for tenant {TenantId} and run {RunId}.", tenantId, runId);
|
||||
return PolicyRunStatusFactory.Create(existing, now);
|
||||
}
|
||||
}
|
||||
|
||||
var jobId = SchedulerEndpointHelpers.GenerateIdentifier("policyjob");
|
||||
var queuedAt = request.QueuedAt ?? now;
|
||||
var metadata = request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
|
||||
var job = new PolicyRunJob(
|
||||
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
|
||||
Id: jobId,
|
||||
TenantId: tenantId,
|
||||
PolicyId: request.PolicyId,
|
||||
PolicyVersion: request.PolicyVersion,
|
||||
Mode: request.Mode,
|
||||
Priority: request.Priority,
|
||||
PriorityRank: -1,
|
||||
RunId: runId,
|
||||
RequestedBy: request.RequestedBy,
|
||||
CorrelationId: request.CorrelationId,
|
||||
Metadata: metadata,
|
||||
Inputs: request.Inputs ?? PolicyRunInputs.Empty,
|
||||
QueuedAt: queuedAt,
|
||||
Status: PolicyRunJobStatus.Pending,
|
||||
AttemptCount: 0,
|
||||
LastAttemptAt: null,
|
||||
LastError: null,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
AvailableAt: now,
|
||||
SubmittedAt: null,
|
||||
CompletedAt: null,
|
||||
LeaseOwner: null,
|
||||
LeaseExpiresAt: null,
|
||||
CancellationRequested: false,
|
||||
CancellationRequestedAt: null,
|
||||
CancellationReason: null,
|
||||
CancelledAt: null);
|
||||
|
||||
await _repository.InsertAsync(job, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Enqueued policy run job {JobId} for tenant {TenantId} policy {PolicyId} (runId={RunId}, mode={Mode}).",
|
||||
job.Id,
|
||||
tenantId,
|
||||
job.PolicyId,
|
||||
job.RunId,
|
||||
job.Mode);
|
||||
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PolicyRunStatus>> ListAsync(
|
||||
string tenantId,
|
||||
PolicyRunQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var statuses = options.Status is null
|
||||
? null
|
||||
: MapExecutionStatus(options.Status.Value);
|
||||
|
||||
var jobs = await _repository
|
||||
.ListAsync(
|
||||
tenantId,
|
||||
options.PolicyId,
|
||||
options.Mode,
|
||||
statuses,
|
||||
options.QueuedAfter,
|
||||
options.Limit,
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return jobs
|
||||
.Select(job => PolicyRunStatusFactory.Create(job, now))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var job = await _repository
|
||||
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus?> RequestCancellationAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var job = await _repository
|
||||
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (IsTerminal(job.Status))
|
||||
{
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
if (job.CancellationRequested && string.Equals(job.CancellationReason, reason, StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
var updated = job with
|
||||
{
|
||||
CancellationRequested = true,
|
||||
CancellationRequestedAt = now,
|
||||
CancellationReason = NormalizeCancellationReason(reason),
|
||||
UpdatedAt = now,
|
||||
AvailableAt = now
|
||||
};
|
||||
|
||||
var replaced = await _repository
|
||||
.ReplaceAsync(updated, expectedLeaseOwner: job.LeaseOwner, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!replaced)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to persist cancellation request for policy run job {JobId} (runId={RunId}).",
|
||||
job.Id,
|
||||
job.RunId ?? "(pending)");
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cancellation requested for policy run job {JobId} (runId={RunId}, reason={Reason}).",
|
||||
updated.Id,
|
||||
updated.RunId ?? "(pending)",
|
||||
updated.CancellationReason ?? "none");
|
||||
|
||||
return PolicyRunStatusFactory.Create(updated, now);
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus> RetryAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? requestedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var job = await _repository
|
||||
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Policy simulation {runId} was not found for tenant {tenantId}.");
|
||||
|
||||
if (job.Mode != PolicyRunMode.Simulate)
|
||||
{
|
||||
throw new InvalidOperationException("Only simulation runs can be retried through this endpoint.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Simulation is still in progress and cannot be retried.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var metadataBuilder = (job.Metadata ?? ImmutableSortedDictionary<string, string>.Empty).ToBuilder();
|
||||
metadataBuilder["retry-of"] = runId;
|
||||
|
||||
var request = new PolicyRunRequest(
|
||||
tenantId,
|
||||
job.PolicyId,
|
||||
PolicyRunMode.Simulate,
|
||||
job.Inputs ?? PolicyRunInputs.Empty,
|
||||
job.Priority,
|
||||
runId: null,
|
||||
policyVersion: job.PolicyVersion,
|
||||
requestedBy: NormalizeActor(requestedBy),
|
||||
queuedAt: now,
|
||||
correlationId: job.CorrelationId,
|
||||
metadata: metadataBuilder.ToImmutable());
|
||||
|
||||
return await EnqueueAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<PolicyRunJobStatus>? MapExecutionStatus(PolicyRunExecutionStatus status)
|
||||
=> status switch
|
||||
{
|
||||
PolicyRunExecutionStatus.Queued => new[] { PolicyRunJobStatus.Pending },
|
||||
PolicyRunExecutionStatus.Running => new[] { PolicyRunJobStatus.Dispatching, PolicyRunJobStatus.Submitted },
|
||||
PolicyRunExecutionStatus.Succeeded => new[] { PolicyRunJobStatus.Completed },
|
||||
PolicyRunExecutionStatus.Failed => new[] { PolicyRunJobStatus.Failed },
|
||||
PolicyRunExecutionStatus.Cancelled => new[] { PolicyRunJobStatus.Cancelled },
|
||||
PolicyRunExecutionStatus.ReplayPending => Array.Empty<PolicyRunJobStatus>(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string GenerateRunId(string policyId, DateTimeOffset timestamp)
|
||||
{
|
||||
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
|
||||
}
|
||||
|
||||
private static bool IsTerminal(PolicyRunJobStatus status)
|
||||
=> status is PolicyRunJobStatus.Completed or PolicyRunJobStatus.Failed or PolicyRunJobStatus.Cancelled;
|
||||
|
||||
private static string? NormalizeCancellationReason(string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = reason.Trim();
|
||||
const int maxLength = 512;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeActor(string? actor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = actor.Trim();
|
||||
const int maxLength = 256;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user