up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,151 +1,146 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed class InMemoryScheduleRepository : IScheduleRepository
{
private readonly ConcurrentDictionary<string, Schedule> _schedules = new(StringComparer.Ordinal);
public Task UpsertAsync(
Schedule schedule,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
_schedules[schedule.Id] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(
string tenantId,
string scheduleId,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<Schedule?>(schedule);
}
return Task.FromResult<Schedule?>(null);
}
public Task<IReadOnlyList<Schedule>> ListAsync(
string tenantId,
ScheduleQueryOptions? options = null,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
options ??= new ScheduleQueryOptions();
var query = _schedules.Values
.Where(schedule => string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal));
if (!options.IncludeDisabled)
{
query = query.Where(schedule => schedule.Enabled);
}
var result = query
.OrderBy(schedule => schedule.Name, StringComparer.Ordinal)
.Take(options.Limit ?? int.MaxValue)
.ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(result);
}
public Task<bool> SoftDeleteAsync(
string tenantId,
string scheduleId,
string deletedBy,
DateTimeOffset deletedAt,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
_schedules.TryRemove(scheduleId, out _);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
internal sealed class InMemoryRunSummaryService : IRunSummaryService
{
private readonly ConcurrentDictionary<(string TenantId, string ScheduleId), RunSummaryProjection> _summaries = new();
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var scheduleId = run.ScheduleId ?? string.Empty;
var updatedAt = run.FinishedAt ?? run.StartedAt ?? run.CreatedAt;
var counters = new RunSummaryCounters(
Total: 0,
Planning: 0,
Queued: 0,
Running: 0,
Completed: 0,
Error: 0,
Cancelled: 0,
TotalDeltas: 0,
TotalNewCriticals: 0,
TotalNewHigh: 0,
TotalNewMedium: 0,
TotalNewLow: 0);
var projection = new RunSummaryProjection(
run.TenantId,
scheduleId,
updatedAt,
null,
ImmutableArray<RunSummarySnapshot>.Empty,
counters);
_summaries[(run.TenantId, scheduleId)] = projection;
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_summaries.TryGetValue((tenantId, scheduleId), out var projection);
return Task.FromResult<RunSummaryProjection?>(projection);
}
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var projections = _summaries.Values
.Where(summary => string.Equals(summary.TenantId, tenantId, StringComparison.Ordinal))
.ToArray();
return Task.FromResult<IReadOnlyList<RunSummaryProjection>>(projections);
}
}
internal sealed class InMemorySchedulerAuditService : ISchedulerAuditService
{
public Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default)
{
var occurredAt = auditEvent.OccurredAt ?? DateTimeOffset.UtcNow;
var record = new AuditRecord(
auditEvent.AuditId ?? $"audit_{Guid.NewGuid():N}",
auditEvent.TenantId,
auditEvent.Category,
auditEvent.Action,
occurredAt,
auditEvent.Actor,
auditEvent.EntityId,
auditEvent.ScheduleId,
auditEvent.RunId,
auditEvent.CorrelationId,
auditEvent.Metadata?.ToImmutableSortedDictionary(StringComparer.OrdinalIgnoreCase) ?? ImmutableSortedDictionary<string, string>.Empty,
auditEvent.Message);
return Task.FromResult(record);
}
}
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed class InMemoryScheduleRepository : IScheduleRepository
{
private readonly ConcurrentDictionary<string, Schedule> _schedules = new(StringComparer.Ordinal);
public Task UpsertAsync(
Schedule schedule,
CancellationToken cancellationToken = default)
{
_schedules[schedule.Id] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<Schedule?>(schedule);
}
return Task.FromResult<Schedule?>(null);
}
public Task<IReadOnlyList<Schedule>> ListAsync(
string tenantId,
ScheduleQueryOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new ScheduleQueryOptions();
var query = _schedules.Values
.Where(schedule => string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal));
if (!options.IncludeDisabled)
{
query = query.Where(schedule => schedule.Enabled);
}
var result = query
.OrderBy(schedule => schedule.Name, StringComparer.Ordinal)
.Take(options.Limit ?? int.MaxValue)
.ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(result);
}
public Task<bool> SoftDeleteAsync(
string tenantId,
string scheduleId,
string deletedBy,
DateTimeOffset deletedAt,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
_schedules.TryRemove(scheduleId, out _);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
internal sealed class InMemoryRunSummaryService : IRunSummaryService
{
private readonly ConcurrentDictionary<(string TenantId, string ScheduleId), RunSummaryProjection> _summaries = new();
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var scheduleId = run.ScheduleId ?? string.Empty;
var updatedAt = run.FinishedAt ?? run.StartedAt ?? run.CreatedAt;
var counters = new RunSummaryCounters(
Total: 0,
Planning: 0,
Queued: 0,
Running: 0,
Completed: 0,
Error: 0,
Cancelled: 0,
TotalDeltas: 0,
TotalNewCriticals: 0,
TotalNewHigh: 0,
TotalNewMedium: 0,
TotalNewLow: 0);
var projection = new RunSummaryProjection(
run.TenantId,
scheduleId,
updatedAt,
null,
ImmutableArray<RunSummarySnapshot>.Empty,
counters);
_summaries[(run.TenantId, scheduleId)] = projection;
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_summaries.TryGetValue((tenantId, scheduleId), out var projection);
return Task.FromResult<RunSummaryProjection?>(projection);
}
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var projections = _summaries.Values
.Where(summary => string.Equals(summary.TenantId, tenantId, StringComparison.Ordinal))
.ToArray();
return Task.FromResult<IReadOnlyList<RunSummaryProjection>>(projections);
}
}
internal sealed class InMemorySchedulerAuditService : ISchedulerAuditService
{
public Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default)
{
var occurredAt = auditEvent.OccurredAt ?? DateTimeOffset.UtcNow;
var record = new AuditRecord(
auditEvent.AuditId ?? $"audit_{Guid.NewGuid():N}",
auditEvent.TenantId,
auditEvent.Category,
auditEvent.Action,
occurredAt,
auditEvent.Actor,
auditEvent.EntityId,
auditEvent.ScheduleId,
auditEvent.RunId,
auditEvent.CorrelationId,
auditEvent.Metadata?.ToImmutableSortedDictionary(StringComparer.OrdinalIgnoreCase) ?? ImmutableSortedDictionary<string, string>.Empty,
auditEvent.Message);
return Task.FromResult(record);
}
}

View File

@@ -1,34 +1,34 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed record ScheduleCreateRequest(
[property: JsonPropertyName("name"), Required] string Name,
[property: JsonPropertyName("cronExpression"), Required] string CronExpression,
[property: JsonPropertyName("timezone"), Required] string Timezone,
[property: JsonPropertyName("mode")] ScheduleMode Mode = ScheduleMode.AnalysisOnly,
[property: JsonPropertyName("selection"), Required] Selector Selection = null!,
[property: JsonPropertyName("onlyIf")] ScheduleOnlyIf? OnlyIf = null,
[property: JsonPropertyName("notify")] ScheduleNotify? Notify = null,
[property: JsonPropertyName("limits")] ScheduleLimits? Limits = null,
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? Subscribers = null,
[property: JsonPropertyName("enabled")] bool Enabled = true);
internal sealed record ScheduleUpdateRequest(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("cronExpression")] string? CronExpression,
[property: JsonPropertyName("timezone")] string? Timezone,
[property: JsonPropertyName("mode")] ScheduleMode? Mode,
[property: JsonPropertyName("selection")] Selector? Selection,
[property: JsonPropertyName("onlyIf")] ScheduleOnlyIf? OnlyIf,
[property: JsonPropertyName("notify")] ScheduleNotify? Notify,
[property: JsonPropertyName("limits")] ScheduleLimits? Limits,
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? Subscribers);
internal sealed record ScheduleCollectionResponse(IReadOnlyList<ScheduleResponse> Schedules);
internal sealed record ScheduleResponse(Schedule Schedule, RunSummaryProjection? Summary);
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed record ScheduleCreateRequest(
[property: JsonPropertyName("name"), Required] string Name,
[property: JsonPropertyName("cronExpression"), Required] string CronExpression,
[property: JsonPropertyName("timezone"), Required] string Timezone,
[property: JsonPropertyName("mode")] ScheduleMode Mode = ScheduleMode.AnalysisOnly,
[property: JsonPropertyName("selection"), Required] Selector Selection = null!,
[property: JsonPropertyName("onlyIf")] ScheduleOnlyIf? OnlyIf = null,
[property: JsonPropertyName("notify")] ScheduleNotify? Notify = null,
[property: JsonPropertyName("limits")] ScheduleLimits? Limits = null,
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? Subscribers = null,
[property: JsonPropertyName("enabled")] bool Enabled = true);
internal sealed record ScheduleUpdateRequest(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("cronExpression")] string? CronExpression,
[property: JsonPropertyName("timezone")] string? Timezone,
[property: JsonPropertyName("mode")] ScheduleMode? Mode,
[property: JsonPropertyName("selection")] Selector? Selection,
[property: JsonPropertyName("onlyIf")] ScheduleOnlyIf? OnlyIf,
[property: JsonPropertyName("notify")] ScheduleNotify? Notify,
[property: JsonPropertyName("limits")] ScheduleLimits? Limits,
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? Subscribers);
internal sealed record ScheduleCollectionResponse(IReadOnlyList<ScheduleResponse> Schedules);
internal sealed record ScheduleResponse(Schedule Schedule, RunSummaryProjection? Summary);

View File

@@ -1,396 +1,396 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Schedules;
internal static class ScheduleEndpoints
{
private const string ReadScope = "scheduler.schedules.read";
private const string WriteScope = "scheduler.schedules.write";
public static IEndpointRouteBuilder MapScheduleEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/schedules");
group.MapGet("/", ListSchedulesAsync);
group.MapGet("/{scheduleId}", GetScheduleAsync);
group.MapPost("/", CreateScheduleAsync);
group.MapPatch("/{scheduleId}", UpdateScheduleAsync);
group.MapPost("/{scheduleId}/pause", PauseScheduleAsync);
group.MapPost("/{scheduleId}/resume", ResumeScheduleAsync);
return routes;
}
private static async Task<IResult> ListSchedulesAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] IRunSummaryService runSummaryService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var includeDisabled = SchedulerEndpointHelpers.TryParseBoolean(httpContext.Request.Query.TryGetValue("includeDisabled", out var includeDisabledValues) ? includeDisabledValues.ToString() : null);
var includeDeleted = SchedulerEndpointHelpers.TryParseBoolean(httpContext.Request.Query.TryGetValue("includeDeleted", out var includeDeletedValues) ? includeDeletedValues.ToString() : null);
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
var options = new ScheduleQueryOptions
{
IncludeDisabled = includeDisabled,
IncludeDeleted = includeDeleted,
Limit = limit
};
var schedules = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
var summaries = await runSummaryService.ListAsync(tenant.TenantId, cancellationToken).ConfigureAwait(false);
var summaryLookup = summaries.ToDictionary(summary => summary.ScheduleId, summary => summary, StringComparer.Ordinal);
var response = new ScheduleCollectionResponse(
schedules.Select(schedule => new ScheduleResponse(schedule, summaryLookup.GetValueOrDefault(schedule.Id))).ToArray());
return Results.Ok(response);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> GetScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] IRunSummaryService runSummaryService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var schedule = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.NotFound();
}
var summary = await runSummaryService.GetAsync(tenant.TenantId, scheduleId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(schedule, summary));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> CreateScheduleAsync(
HttpContext httpContext,
ScheduleCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
[FromServices] ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
ValidateRequest(request);
var tenant = tenantAccessor.GetTenant(httpContext);
var now = timeProvider.GetUtcNow();
var selection = SchedulerEndpointHelpers.NormalizeSelector(request.Selection, tenant.TenantId);
var scheduleId = SchedulerEndpointHelpers.GenerateIdentifier("sch");
var subscribers = request.Subscribers ?? ImmutableArray<string>.Empty;
var schedule = new Schedule(
scheduleId,
tenant.TenantId,
request.Name.Trim(),
request.Enabled,
request.CronExpression.Trim(),
request.Timezone.Trim(),
request.Mode,
selection,
request.OnlyIf ?? ScheduleOnlyIf.Default,
request.Notify ?? ScheduleNotify.Default,
request.Limits ?? ScheduleLimits.Default,
subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
SchedulerSchemaVersions.Schedule);
await repository.UpsertAsync(schedule, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"create",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: schedule.Id,
Metadata: new Dictionary<string, string>
{
["cronExpression"] = schedule.CronExpression,
["timezone"] = schedule.Timezone
}),
cancellationToken).ConfigureAwait(false);
var response = new ScheduleResponse(schedule, null);
return Results.Created($"/api/v1/scheduler/schedules/{schedule.Id}", response);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> UpdateScheduleAsync(
HttpContext httpContext,
string scheduleId,
ScheduleUpdateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
var updated = UpdateSchedule(existing, request, tenant.TenantId, timeProvider.GetUtcNow(), SchedulerEndpointHelpers.ResolveActorId(httpContext));
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"update",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: updated.Id,
Metadata: new Dictionary<string, string>
{
["updatedAt"] = updated.UpdatedAt.ToString("O")
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> PauseScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (!existing.Enabled)
{
return Results.Ok(new ScheduleResponse(existing, null));
}
var now = timeProvider.GetUtcNow();
var updated = new Schedule(
existing.Id,
existing.TenantId,
existing.Name,
enabled: false,
existing.CronExpression,
existing.Timezone,
existing.Mode,
existing.Selection,
existing.OnlyIf,
existing.Notify,
existing.Limits,
existing.Subscribers,
existing.CreatedAt,
existing.CreatedBy,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
existing.SchemaVersion);
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"pause",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: scheduleId,
Metadata: new Dictionary<string, string>
{
["enabled"] = "false"
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> ResumeScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (existing.Enabled)
{
return Results.Ok(new ScheduleResponse(existing, null));
}
var now = timeProvider.GetUtcNow();
var updated = new Schedule(
existing.Id,
existing.TenantId,
existing.Name,
enabled: true,
existing.CronExpression,
existing.Timezone,
existing.Mode,
existing.Selection,
existing.OnlyIf,
existing.Notify,
existing.Limits,
existing.Subscribers,
existing.CreatedAt,
existing.CreatedBy,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
existing.SchemaVersion);
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"resume",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: scheduleId,
Metadata: new Dictionary<string, string>
{
["enabled"] = "true"
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static void ValidateRequest(ScheduleCreateRequest request)
{
if (request.Selection is null)
{
throw new ValidationException("Selection is required.");
}
}
private static Schedule UpdateSchedule(
Schedule existing,
ScheduleUpdateRequest request,
string tenantId,
DateTimeOffset updatedAt,
string actor)
{
var name = request.Name?.Trim() ?? existing.Name;
var cronExpression = request.CronExpression?.Trim() ?? existing.CronExpression;
var timezone = request.Timezone?.Trim() ?? existing.Timezone;
var mode = request.Mode ?? existing.Mode;
var selection = request.Selection is null
? existing.Selection
: SchedulerEndpointHelpers.NormalizeSelector(request.Selection, tenantId);
var onlyIf = request.OnlyIf ?? existing.OnlyIf;
var notify = request.Notify ?? existing.Notify;
var limits = request.Limits ?? existing.Limits;
var subscribers = request.Subscribers ?? existing.Subscribers;
return new Schedule(
existing.Id,
existing.TenantId,
name,
existing.Enabled,
cronExpression,
timezone,
mode,
selection,
onlyIf,
notify,
limits,
subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers,
existing.CreatedAt,
existing.CreatedBy,
updatedAt,
actor,
existing.SchemaVersion);
}
}
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Schedules;
internal static class ScheduleEndpoints
{
private const string ReadScope = "scheduler.schedules.read";
private const string WriteScope = "scheduler.schedules.write";
public static IEndpointRouteBuilder MapScheduleEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/schedules");
group.MapGet("/", ListSchedulesAsync);
group.MapGet("/{scheduleId}", GetScheduleAsync);
group.MapPost("/", CreateScheduleAsync);
group.MapPatch("/{scheduleId}", UpdateScheduleAsync);
group.MapPost("/{scheduleId}/pause", PauseScheduleAsync);
group.MapPost("/{scheduleId}/resume", ResumeScheduleAsync);
return routes;
}
private static async Task<IResult> ListSchedulesAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] IRunSummaryService runSummaryService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var includeDisabled = SchedulerEndpointHelpers.TryParseBoolean(httpContext.Request.Query.TryGetValue("includeDisabled", out var includeDisabledValues) ? includeDisabledValues.ToString() : null);
var includeDeleted = SchedulerEndpointHelpers.TryParseBoolean(httpContext.Request.Query.TryGetValue("includeDeleted", out var includeDeletedValues) ? includeDeletedValues.ToString() : null);
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
var options = new ScheduleQueryOptions
{
IncludeDisabled = includeDisabled,
IncludeDeleted = includeDeleted,
Limit = limit
};
var schedules = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
var summaries = await runSummaryService.ListAsync(tenant.TenantId, cancellationToken).ConfigureAwait(false);
var summaryLookup = summaries.ToDictionary(summary => summary.ScheduleId, summary => summary, StringComparer.Ordinal);
var response = new ScheduleCollectionResponse(
schedules.Select(schedule => new ScheduleResponse(schedule, summaryLookup.GetValueOrDefault(schedule.Id))).ToArray());
return Results.Ok(response);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> GetScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] IRunSummaryService runSummaryService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var schedule = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.NotFound();
}
var summary = await runSummaryService.GetAsync(tenant.TenantId, scheduleId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(schedule, summary));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> CreateScheduleAsync(
HttpContext httpContext,
ScheduleCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
[FromServices] ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
ValidateRequest(request);
var tenant = tenantAccessor.GetTenant(httpContext);
var now = timeProvider.GetUtcNow();
var selection = SchedulerEndpointHelpers.NormalizeSelector(request.Selection, tenant.TenantId);
var scheduleId = SchedulerEndpointHelpers.GenerateIdentifier("sch");
var subscribers = request.Subscribers ?? ImmutableArray<string>.Empty;
var schedule = new Schedule(
scheduleId,
tenant.TenantId,
request.Name.Trim(),
request.Enabled,
request.CronExpression.Trim(),
request.Timezone.Trim(),
request.Mode,
selection,
request.OnlyIf ?? ScheduleOnlyIf.Default,
request.Notify ?? ScheduleNotify.Default,
request.Limits ?? ScheduleLimits.Default,
subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
SchedulerSchemaVersions.Schedule);
await repository.UpsertAsync(schedule, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"create",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: schedule.Id,
Metadata: new Dictionary<string, string>
{
["cronExpression"] = schedule.CronExpression,
["timezone"] = schedule.Timezone
}),
cancellationToken).ConfigureAwait(false);
var response = new ScheduleResponse(schedule, null);
return Results.Created($"/api/v1/scheduler/schedules/{schedule.Id}", response);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> UpdateScheduleAsync(
HttpContext httpContext,
string scheduleId,
ScheduleUpdateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
var updated = UpdateSchedule(existing, request, tenant.TenantId, timeProvider.GetUtcNow(), SchedulerEndpointHelpers.ResolveActorId(httpContext));
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"update",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: updated.Id,
Metadata: new Dictionary<string, string>
{
["updatedAt"] = updated.UpdatedAt.ToString("O")
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> PauseScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (!existing.Enabled)
{
return Results.Ok(new ScheduleResponse(existing, null));
}
var now = timeProvider.GetUtcNow();
var updated = new Schedule(
existing.Id,
existing.TenantId,
existing.Name,
enabled: false,
existing.CronExpression,
existing.Timezone,
existing.Mode,
existing.Selection,
existing.OnlyIf,
existing.Notify,
existing.Limits,
existing.Subscribers,
existing.CreatedAt,
existing.CreatedBy,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
existing.SchemaVersion);
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"pause",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: scheduleId,
Metadata: new Dictionary<string, string>
{
["enabled"] = "false"
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> ResumeScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (existing.Enabled)
{
return Results.Ok(new ScheduleResponse(existing, null));
}
var now = timeProvider.GetUtcNow();
var updated = new Schedule(
existing.Id,
existing.TenantId,
existing.Name,
enabled: true,
existing.CronExpression,
existing.Timezone,
existing.Mode,
existing.Selection,
existing.OnlyIf,
existing.Notify,
existing.Limits,
existing.Subscribers,
existing.CreatedAt,
existing.CreatedBy,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
existing.SchemaVersion);
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"resume",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: scheduleId,
Metadata: new Dictionary<string, string>
{
["enabled"] = "true"
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static void ValidateRequest(ScheduleCreateRequest request)
{
if (request.Selection is null)
{
throw new ValidationException("Selection is required.");
}
}
private static Schedule UpdateSchedule(
Schedule existing,
ScheduleUpdateRequest request,
string tenantId,
DateTimeOffset updatedAt,
string actor)
{
var name = request.Name?.Trim() ?? existing.Name;
var cronExpression = request.CronExpression?.Trim() ?? existing.CronExpression;
var timezone = request.Timezone?.Trim() ?? existing.Timezone;
var mode = request.Mode ?? existing.Mode;
var selection = request.Selection is null
? existing.Selection
: SchedulerEndpointHelpers.NormalizeSelector(request.Selection, tenantId);
var onlyIf = request.OnlyIf ?? existing.OnlyIf;
var notify = request.Notify ?? existing.Notify;
var limits = request.Limits ?? existing.Limits;
var subscribers = request.Subscribers ?? existing.Subscribers;
return new Schedule(
existing.Id,
existing.TenantId,
name,
existing.Enabled,
cronExpression,
timezone,
mode,
selection,
onlyIf,
notify,
limits,
subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers,
existing.CreatedAt,
existing.CreatedBy,
updatedAt,
actor,
existing.SchemaVersion);
}
}