Refactor code structure for improved readability and maintainability
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-06 21:48:12 +02:00
parent f6c22854a4
commit dd0067ea0b
105 changed files with 12662 additions and 427 deletions

View File

@@ -0,0 +1,76 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.TaskRunner.Client.Extensions;
/// <summary>
/// Service collection extensions for registering the TaskRunner client.
/// </summary>
public static class TaskRunnerClientServiceCollectionExtensions
{
/// <summary>
/// Adds the TaskRunner client to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <returns>HTTP client builder for further configuration.</returns>
public static IHttpClientBuilder AddTaskRunnerClient(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<TaskRunnerClientOptions>(
configuration.GetSection(TaskRunnerClientOptions.SectionName));
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
{
var options = configuration
.GetSection(TaskRunnerClientOptions.SectionName)
.Get<TaskRunnerClientOptions>();
if (options is not null && !string.IsNullOrWhiteSpace(options.BaseUrl))
{
client.BaseAddress = new Uri(options.BaseUrl);
}
if (!string.IsNullOrWhiteSpace(options?.UserAgent))
{
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
}
});
}
/// <summary>
/// Adds the TaskRunner client to the service collection with custom options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>HTTP client builder for further configuration.</returns>
public static IHttpClientBuilder AddTaskRunnerClient(
this IServiceCollection services,
Action<TaskRunnerClientOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
{
var options = new TaskRunnerClientOptions();
configureOptions(options);
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
{
client.BaseAddress = new Uri(options.BaseUrl);
}
if (!string.IsNullOrWhiteSpace(options.UserAgent))
{
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
}
});
}
}

View File

@@ -0,0 +1,124 @@
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client;
/// <summary>
/// Client interface for the TaskRunner WebService API.
/// </summary>
public interface ITaskRunnerClient
{
#region Pack Runs
/// <summary>
/// Creates a new pack run.
/// </summary>
/// <param name="request">Run creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Created run response.</returns>
Task<CreatePackRunResponse> CreateRunAsync(
CreatePackRunRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current state of a pack run.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Pack run state or null if not found.</returns>
Task<PackRunState?> GetRunAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a running pack run.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cancel response.</returns>
Task<CancelRunResponse> CancelRunAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Approvals
/// <summary>
/// Applies an approval decision to a pending approval gate.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="approvalId">Approval gate identifier.</param>
/// <param name="request">Decision request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Approval decision response.</returns>
Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
string runId,
string approvalId,
ApprovalDecisionRequest request,
CancellationToken cancellationToken = default);
#endregion
#region Logs
/// <summary>
/// Streams log entries for a pack run as NDJSON.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of log entries.</returns>
IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Artifacts
/// <summary>
/// Lists artifacts produced by a pack run.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Artifact list response.</returns>
Task<ArtifactListResponse> ListArtifactsAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Simulation
/// <summary>
/// Simulates a task pack execution without running it.
/// </summary>
/// <param name="request">Simulation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Simulation result.</returns>
Task<SimulatePackResponse> SimulateAsync(
SimulatePackRequest request,
CancellationToken cancellationToken = default);
#endregion
#region Metadata
/// <summary>
/// Gets OpenAPI metadata including spec URL, version, and signature.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpenAPI metadata.</returns>
Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default);
#endregion
}
/// <summary>
/// OpenAPI metadata from /.well-known/openapi endpoint.
/// </summary>
public sealed record OpenApiMetadata(
[property: System.Text.Json.Serialization.JsonPropertyName("specUrl")] string SpecUrl,
[property: System.Text.Json.Serialization.JsonPropertyName("version")] string Version,
[property: System.Text.Json.Serialization.JsonPropertyName("buildVersion")] string BuildVersion,
[property: System.Text.Json.Serialization.JsonPropertyName("eTag")] string ETag,
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] string Signature);

View File

@@ -0,0 +1,230 @@
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client.Lifecycle;
/// <summary>
/// Helper methods for pack run lifecycle operations.
/// </summary>
public static class PackRunLifecycleHelper
{
/// <summary>
/// Terminal statuses for pack runs.
/// </summary>
public static readonly IReadOnlySet<string> TerminalStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"completed",
"failed",
"cancelled",
"rejected"
};
/// <summary>
/// Creates a run and waits for it to reach a terminal state.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="request">Run creation request.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final pack run state.</returns>
public static async Task<PackRunState> CreateAndWaitAsync(
ITaskRunnerClient client,
CreatePackRunRequest request,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
return await WaitForCompletionAsync(client, createResponse.RunId, interval, maxWait, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Waits for a pack run to reach a terminal state.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final pack run state.</returns>
public static async Task<PackRunState> WaitForCompletionAsync(
ITaskRunnerClient client,
string runId,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
if (state is null)
{
throw new InvalidOperationException($"Run '{runId}' not found.");
}
if (TerminalStatuses.Contains(state.Status))
{
return state;
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Waits for a pack run to reach a pending approval state.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 10 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Pack run state with pending approvals, or null if run completed without approvals.</returns>
public static async Task<PackRunState?> WaitForApprovalAsync(
ITaskRunnerClient client,
string runId,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(10);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
if (state is null)
{
throw new InvalidOperationException($"Run '{runId}' not found.");
}
if (TerminalStatuses.Contains(state.Status))
{
return null; // Completed without needing approval
}
if (state.PendingApprovals is { Count: > 0 })
{
return state;
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Approves all pending approvals for a run.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="planHash">Expected plan hash.</param>
/// <param name="actorId">Actor applying the approval.</param>
/// <param name="summary">Approval summary.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of approvals applied.</returns>
public static async Task<int> ApproveAllAsync(
ITaskRunnerClient client,
string runId,
string planHash,
string? actorId = null,
string? summary = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentException.ThrowIfNullOrWhiteSpace(planHash);
var state = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
if (state?.PendingApprovals is null or { Count: 0 })
{
return 0;
}
var count = 0;
foreach (var approval in state.PendingApprovals)
{
var request = new ApprovalDecisionRequest("approved", planHash, actorId, summary);
await client.ApplyApprovalDecisionAsync(runId, approval.ApprovalId, request, cancellationToken)
.ConfigureAwait(false);
count++;
}
return count;
}
/// <summary>
/// Creates a run, auto-approves when needed, and waits for completion.
/// </summary>
/// <param name="client">TaskRunner client.</param>
/// <param name="request">Run creation request.</param>
/// <param name="actorId">Actor for auto-approval.</param>
/// <param name="pollInterval">Interval between status checks.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final pack run state.</returns>
public static async Task<PackRunState> CreateRunAndAutoApproveAsync(
ITaskRunnerClient client,
CreatePackRunRequest request,
string? actorId = null,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
var runId = createResponse.RunId;
var planHash = createResponse.PlanHash;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
if (state is null)
{
throw new InvalidOperationException($"Run '{runId}' not found.");
}
if (TerminalStatuses.Contains(state.Status))
{
return state;
}
if (state.PendingApprovals is { Count: > 0 })
{
await ApproveAllAsync(client, runId, planHash, actorId, "Auto-approved by SDK", cts.Token)
.ConfigureAwait(false);
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,174 @@
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Client.Models;
/// <summary>
/// Request to create a new pack run.
/// </summary>
public sealed record CreatePackRunRequest(
[property: JsonPropertyName("packId")] string PackId,
[property: JsonPropertyName("packVersion")] string? PackVersion = null,
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null,
[property: JsonPropertyName("tenantId")] string? TenantId = null,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
/// <summary>
/// Response from creating a pack run.
/// </summary>
public sealed record CreatePackRunResponse(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("planHash")] string PlanHash,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
/// <summary>
/// Pack run state.
/// </summary>
public sealed record PackRunState(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("packId")] string PackId,
[property: JsonPropertyName("packVersion")] string PackVersion,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("planHash")] string PlanHash,
[property: JsonPropertyName("currentStepId")] string? CurrentStepId,
[property: JsonPropertyName("steps")] IReadOnlyList<PackRunStepState> Steps,
[property: JsonPropertyName("pendingApprovals")] IReadOnlyList<PendingApproval>? PendingApprovals,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("error")] PackRunError? Error);
/// <summary>
/// State of a single step in a pack run.
/// </summary>
public sealed record PackRunStepState(
[property: JsonPropertyName("stepId")] string StepId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("retryCount")] int RetryCount,
[property: JsonPropertyName("outputs")] IReadOnlyDictionary<string, object>? Outputs);
/// <summary>
/// Pending approval gate.
/// </summary>
public sealed record PendingApproval(
[property: JsonPropertyName("approvalId")] string ApprovalId,
[property: JsonPropertyName("stepId")] string StepId,
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("requiredGrants")] IReadOnlyList<string> RequiredGrants,
[property: JsonPropertyName("requestedAt")] DateTimeOffset RequestedAt);
/// <summary>
/// Pack run error information.
/// </summary>
public sealed record PackRunError(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("stepId")] string? StepId);
/// <summary>
/// Request to apply an approval decision.
/// </summary>
public sealed record ApprovalDecisionRequest(
[property: JsonPropertyName("decision")] string Decision,
[property: JsonPropertyName("planHash")] string PlanHash,
[property: JsonPropertyName("actorId")] string? ActorId = null,
[property: JsonPropertyName("summary")] string? Summary = null);
/// <summary>
/// Response from applying an approval decision.
/// </summary>
public sealed record ApprovalDecisionResponse(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("resumed")] bool Resumed);
/// <summary>
/// Request to simulate a task pack.
/// </summary>
public sealed record SimulatePackRequest(
[property: JsonPropertyName("manifest")] string Manifest,
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null);
/// <summary>
/// Simulation result for a task pack.
/// </summary>
public sealed record SimulatePackResponse(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("planHash")] string? PlanHash,
[property: JsonPropertyName("steps")] IReadOnlyList<SimulatedStep> Steps,
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
/// <summary>
/// Simulated step in a pack run.
/// </summary>
public sealed record SimulatedStep(
[property: JsonPropertyName("stepId")] string StepId,
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("loopInfo")] LoopInfo? LoopInfo,
[property: JsonPropertyName("conditionalInfo")] ConditionalInfo? ConditionalInfo,
[property: JsonPropertyName("policyInfo")] PolicyInfo? PolicyInfo);
/// <summary>
/// Loop step simulation info.
/// </summary>
public sealed record LoopInfo(
[property: JsonPropertyName("itemsExpression")] string? ItemsExpression,
[property: JsonPropertyName("iterator")] string Iterator,
[property: JsonPropertyName("maxIterations")] int MaxIterations);
/// <summary>
/// Conditional step simulation info.
/// </summary>
public sealed record ConditionalInfo(
[property: JsonPropertyName("branches")] IReadOnlyList<BranchInfo> Branches,
[property: JsonPropertyName("hasElse")] bool HasElse);
/// <summary>
/// Conditional branch info.
/// </summary>
public sealed record BranchInfo(
[property: JsonPropertyName("condition")] string Condition,
[property: JsonPropertyName("stepCount")] int StepCount);
/// <summary>
/// Policy gate simulation info.
/// </summary>
public sealed record PolicyInfo(
[property: JsonPropertyName("policyId")] string PolicyId,
[property: JsonPropertyName("failureAction")] string FailureAction);
/// <summary>
/// Artifact metadata.
/// </summary>
public sealed record ArtifactInfo(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("size")] long Size,
[property: JsonPropertyName("sha256")] string Sha256,
[property: JsonPropertyName("contentType")] string? ContentType,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
/// <summary>
/// List of artifacts.
/// </summary>
public sealed record ArtifactListResponse(
[property: JsonPropertyName("artifacts")] IReadOnlyList<ArtifactInfo> Artifacts);
/// <summary>
/// Run log entry.
/// </summary>
public sealed record RunLogEntry(
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("level")] string Level,
[property: JsonPropertyName("stepId")] string? StepId,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("traceId")] string? TraceId);
/// <summary>
/// Cancel run response.
/// </summary>
public sealed record CancelRunResponse(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("message")] string? Message);

View File

@@ -0,0 +1,171 @@
using System.Runtime.CompilerServices;
namespace StellaOps.TaskRunner.Client.Pagination;
/// <summary>
/// Generic paginator for API responses.
/// </summary>
/// <typeparam name="T">Type of items being paginated.</typeparam>
public sealed class Paginator<T>
{
private readonly Func<int, int, CancellationToken, Task<PagedResponse<T>>> _fetchPage;
private readonly int _pageSize;
/// <summary>
/// Initializes a new paginator.
/// </summary>
/// <param name="fetchPage">Function to fetch a page (offset, limit, cancellationToken) -> page.</param>
/// <param name="pageSize">Number of items per page (default: 50).</param>
public Paginator(
Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
int pageSize = 50)
{
_fetchPage = fetchPage ?? throw new ArgumentNullException(nameof(fetchPage));
_pageSize = pageSize > 0 ? pageSize : throw new ArgumentOutOfRangeException(nameof(pageSize));
}
/// <summary>
/// Iterates through all pages asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of items.</returns>
public async IAsyncEnumerable<T> GetAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var offset = 0;
while (true)
{
var page = await _fetchPage(offset, _pageSize, cancellationToken).ConfigureAwait(false);
foreach (var item in page.Items)
{
yield return item;
}
if (!page.HasMore || page.Items.Count == 0)
{
break;
}
offset += page.Items.Count;
}
}
/// <summary>
/// Collects all items into a list.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of all items.</returns>
public async Task<IReadOnlyList<T>> CollectAsync(CancellationToken cancellationToken = default)
{
var items = new List<T>();
await foreach (var item in GetAllAsync(cancellationToken).ConfigureAwait(false))
{
items.Add(item);
}
return items;
}
/// <summary>
/// Gets a single page.
/// </summary>
/// <param name="pageNumber">Page number (1-based).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Single page response.</returns>
public Task<PagedResponse<T>> GetPageAsync(int pageNumber, CancellationToken cancellationToken = default)
{
if (pageNumber < 1)
{
throw new ArgumentOutOfRangeException(nameof(pageNumber), "Page number must be >= 1.");
}
var offset = (pageNumber - 1) * _pageSize;
return _fetchPage(offset, _pageSize, cancellationToken);
}
}
/// <summary>
/// Paginated response wrapper.
/// </summary>
/// <typeparam name="T">Type of items.</typeparam>
public sealed record PagedResponse<T>(
IReadOnlyList<T> Items,
int TotalCount,
bool HasMore)
{
/// <summary>
/// Creates an empty page.
/// </summary>
public static PagedResponse<T> Empty { get; } = new([], 0, false);
/// <summary>
/// Current page number (1-based) based on offset and page size.
/// </summary>
public int PageNumber(int offset, int pageSize)
=> pageSize > 0 ? (offset / pageSize) + 1 : 1;
}
/// <summary>
/// Extension methods for creating paginators.
/// </summary>
public static class PaginatorExtensions
{
/// <summary>
/// Creates a paginator from a fetch function.
/// </summary>
public static Paginator<T> Paginate<T>(
this Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
int pageSize = 50)
=> new(fetchPage, pageSize);
/// <summary>
/// Takes the first N items from an async enumerable.
/// </summary>
public static async IAsyncEnumerable<T> TakeAsync<T>(
this IAsyncEnumerable<T> source,
int count,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
if (count <= 0)
{
yield break;
}
var taken = 0;
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
yield return item;
taken++;
if (taken >= count)
{
break;
}
}
}
/// <summary>
/// Skips the first N items from an async enumerable.
/// </summary>
public static async IAsyncEnumerable<T> SkipAsync<T>(
this IAsyncEnumerable<T> source,
int count,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
var skipped = 0;
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (skipped < count)
{
skipped++;
continue;
}
yield return item;
}
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Description>SDK client for StellaOps TaskRunner WebService API</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,153 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client.Streaming;
/// <summary>
/// Helper for reading NDJSON streaming logs.
/// </summary>
public static class StreamingLogReader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Reads log entries from an NDJSON stream.
/// </summary>
/// <param name="stream">The input stream containing NDJSON log entries.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of log entries.</returns>
public static async IAsyncEnumerable<RunLogEntry> ReadAsync(
Stream stream,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
RunLogEntry? entry;
try
{
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
}
catch (JsonException)
{
continue;
}
if (entry is not null)
{
yield return entry;
}
}
}
/// <summary>
/// Collects all log entries from a stream into a list.
/// </summary>
/// <param name="stream">The input stream containing NDJSON log entries.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of all log entries.</returns>
public static async Task<IReadOnlyList<RunLogEntry>> CollectAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
var entries = new List<RunLogEntry>();
await foreach (var entry in ReadAsync(stream, cancellationToken).ConfigureAwait(false))
{
entries.Add(entry);
}
return entries;
}
/// <summary>
/// Filters log entries by level.
/// </summary>
/// <param name="entries">Source log entries.</param>
/// <param name="levels">Log levels to include (e.g., "error", "warning").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Filtered log entries.</returns>
public static async IAsyncEnumerable<RunLogEntry> FilterByLevelAsync(
IAsyncEnumerable<RunLogEntry> entries,
IReadOnlySet<string> levels,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentNullException.ThrowIfNull(levels);
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (levels.Contains(entry.Level, StringComparer.OrdinalIgnoreCase))
{
yield return entry;
}
}
}
/// <summary>
/// Filters log entries by step ID.
/// </summary>
/// <param name="entries">Source log entries.</param>
/// <param name="stepId">Step ID to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Filtered log entries.</returns>
public static async IAsyncEnumerable<RunLogEntry> FilterByStepAsync(
IAsyncEnumerable<RunLogEntry> entries,
string stepId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ArgumentException.ThrowIfNullOrWhiteSpace(stepId);
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(entry.StepId, stepId, StringComparison.Ordinal))
{
yield return entry;
}
}
}
/// <summary>
/// Groups log entries by step ID.
/// </summary>
/// <param name="entries">Source log entries.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary of step ID to log entries.</returns>
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<RunLogEntry>>> GroupByStepAsync(
IAsyncEnumerable<RunLogEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
var groups = new Dictionary<string, List<RunLogEntry>>(StringComparer.Ordinal);
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
{
var key = entry.StepId ?? "(global)";
if (!groups.TryGetValue(key, out var list))
{
list = [];
groups[key] = list;
}
list.Add(entry);
}
return groups.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<RunLogEntry>)kvp.Value,
StringComparer.Ordinal);
}
}

View File

@@ -0,0 +1,292 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Client.Models;
namespace StellaOps.TaskRunner.Client;
/// <summary>
/// HTTP implementation of <see cref="ITaskRunnerClient"/>.
/// </summary>
public sealed class TaskRunnerClient : ITaskRunnerClient
{
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly IOptionsMonitor<TaskRunnerClientOptions> _options;
private readonly ILogger<TaskRunnerClient>? _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TaskRunnerClient"/> class.
/// </summary>
public TaskRunnerClient(
HttpClient httpClient,
IOptionsMonitor<TaskRunnerClientOptions> options,
ILogger<TaskRunnerClient>? logger = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
}
#region Pack Runs
/// <inheritdoc />
public async Task<CreatePackRunResponse> CreateRunAsync(
CreatePackRunRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl("/runs");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
};
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CreatePackRunResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
/// <inheritdoc />
public async Task<PackRunState?> GetRunAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PackRunState>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<CancelRunResponse> CancelRunAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/cancel");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CancelRunResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Approvals
/// <inheritdoc />
public async Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
string runId,
string approvalId,
ApprovalDecisionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/approvals/{Uri.EscapeDataString(approvalId)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
};
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApprovalDecisionResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Logs
/// <inheritdoc />
public async IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/logs");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-ndjson"));
// Use longer timeout for streaming
var streamingTimeout = TimeSpan.FromSeconds(_options.CurrentValue.StreamingTimeoutSeconds);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(streamingTimeout);
using var response = await _httpClient.SendAsync(
httpRequest,
HttpCompletionOption.ResponseHeadersRead,
cts.Token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false)) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
RunLogEntry? entry;
try
{
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
}
catch (JsonException ex)
{
_logger?.LogWarning(ex, "Failed to parse log entry: {Line}", line);
continue;
}
if (entry is not null)
{
yield return entry;
}
}
}
#endregion
#region Artifacts
/// <inheritdoc />
public async Task<ArtifactListResponse> ListArtifactsAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/artifacts");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ArtifactListResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ArtifactListResponse([]);
}
#endregion
#region Simulation
/// <inheritdoc />
public async Task<SimulatePackResponse> SimulateAsync(
SimulatePackRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl("/simulations");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
};
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SimulatePackResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Metadata
/// <inheritdoc />
public async Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default)
{
var options = _options.CurrentValue;
var url = new Uri(new Uri(options.BaseUrl), "/.well-known/openapi");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<OpenApiMetadata>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
}
#endregion
#region Helpers
private Uri BuildUrl(string path)
{
var options = _options.CurrentValue;
var baseUrl = options.BaseUrl.TrimEnd('/');
var apiPath = options.ApiPath.TrimEnd('/');
return new Uri($"{baseUrl}{apiPath}{path}");
}
private async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!string.IsNullOrWhiteSpace(options.UserAgent))
{
request.Headers.UserAgent.TryParseAdd(options.UserAgent);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
return await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
}
#endregion
}

View File

@@ -0,0 +1,42 @@
namespace StellaOps.TaskRunner.Client;
/// <summary>
/// Configuration options for the TaskRunner client.
/// </summary>
public sealed class TaskRunnerClientOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "TaskRunner:Client";
/// <summary>
/// Base URL for the TaskRunner API (e.g., "https://taskrunner.example.com").
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// API version path prefix (default: "/v1/task-runner").
/// </summary>
public string ApiPath { get; set; } = "/v1/task-runner";
/// <summary>
/// Timeout for HTTP requests in seconds (default: 30).
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>
/// Timeout for streaming log requests in seconds (default: 300).
/// </summary>
public int StreamingTimeoutSeconds { get; set; } = 300;
/// <summary>
/// Maximum number of retry attempts for transient failures (default: 3).
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// User-Agent header value for requests.
/// </summary>
public string? UserAgent { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Provider for retrieving air-gap sealed mode status.
/// </summary>
public interface IAirGapStatusProvider
{
/// <summary>
/// Gets the current sealed mode status of the environment.
/// </summary>
/// <param name="tenantId">Optional tenant ID for multi-tenant environments.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The sealed mode status.</returns>
Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,125 @@
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Audit logger for sealed install enforcement decisions.
/// </summary>
public interface ISealedInstallAuditLogger
{
/// <summary>
/// Logs an enforcement decision.
/// </summary>
Task LogEnforcementAsync(
TaskPackManifest manifest,
SealedInstallEnforcementResult result,
string? tenantId = null,
string? runId = null,
string? actor = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementation of sealed install audit logger using timeline events.
/// </summary>
public sealed class SealedInstallAuditLogger : ISealedInstallAuditLogger
{
private readonly IPackRunTimelineEventEmitter _eventEmitter;
public SealedInstallAuditLogger(IPackRunTimelineEventEmitter eventEmitter)
{
_eventEmitter = eventEmitter ?? throw new ArgumentNullException(nameof(eventEmitter));
}
/// <inheritdoc />
public async Task LogEnforcementAsync(
TaskPackManifest manifest,
SealedInstallEnforcementResult result,
string? tenantId = null,
string? runId = null,
string? actor = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(result);
var effectiveTenantId = tenantId ?? "default";
var effectiveRunId = runId ?? Guid.NewGuid().ToString("n");
var now = DateTimeOffset.UtcNow;
var eventType = result.Allowed
? PackRunEventTypes.SealedInstallAllowed
: PackRunEventTypes.SealedInstallDenied;
var severity = result.Allowed
? PackRunEventSeverity.Info
: PackRunEventSeverity.Warning;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["pack_name"] = manifest.Metadata.Name,
["pack_version"] = manifest.Metadata.Version,
["decision"] = result.Allowed ? "allowed" : "denied",
["sealed_install_required"] = manifest.Spec.SealedInstall.ToString().ToLowerInvariant()
};
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
{
attributes["error_code"] = result.ErrorCode;
}
object payload;
if (result.Allowed)
{
payload = new
{
event_type = "sealed_install_enforcement",
pack_id = manifest.Metadata.Name,
pack_version = manifest.Metadata.Version,
decision = "allowed",
reason = result.Message
};
}
else
{
payload = new
{
event_type = "sealed_install_enforcement",
pack_id = manifest.Metadata.Name,
pack_version = manifest.Metadata.Version,
decision = "denied",
reason = result.ErrorCode,
message = result.Message,
violation = result.Violation is not null
? new
{
required_sealed = result.Violation.RequiredSealed,
actual_sealed = result.Violation.ActualSealed,
recommendation = result.Violation.Recommendation
}
: null,
requirement_violations = result.RequirementViolations?.Select(v => new
{
requirement = v.Requirement,
expected = v.Expected,
actual = v.Actual,
message = v.Message
}).ToList()
};
}
var timelineEvent = PackRunTimelineEvent.Create(
tenantId: effectiveTenantId,
eventType: eventType,
source: "StellaOps.TaskRunner.SealedInstallEnforcer",
occurredAt: now,
runId: effectiveRunId,
actor: actor,
severity: severity,
attributes: attributes,
payload: payload);
await _eventEmitter.EmitAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,22 @@
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Enforces sealed install requirements for task packs.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public interface ISealedInstallEnforcer
{
/// <summary>
/// Enforces sealed install requirements for a task pack.
/// </summary>
/// <param name="manifest">The task pack manifest.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Enforcement result indicating whether execution is allowed.</returns>
Task<SealedInstallEnforcementResult> EnforceAsync(
TaskPackManifest manifest,
string? tenantId = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,118 @@
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Result of sealed install enforcement check.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed record SealedInstallEnforcementResult(
/// <summary>Whether execution is allowed.</summary>
bool Allowed,
/// <summary>Error code if denied.</summary>
string? ErrorCode,
/// <summary>Human-readable message.</summary>
string Message,
/// <summary>Detailed violation information.</summary>
SealedInstallViolation? Violation,
/// <summary>Requirement violations if any.</summary>
IReadOnlyList<RequirementViolation>? RequirementViolations)
{
/// <summary>
/// Creates an allowed result.
/// </summary>
public static SealedInstallEnforcementResult CreateAllowed(string message)
=> new(true, null, message, null, null);
/// <summary>
/// Creates a denied result.
/// </summary>
public static SealedInstallEnforcementResult CreateDenied(
string errorCode,
string message,
SealedInstallViolation? violation = null,
IReadOnlyList<RequirementViolation>? requirementViolations = null)
=> new(false, errorCode, message, violation, requirementViolations);
}
/// <summary>
/// Details about a sealed install violation.
/// </summary>
public sealed record SealedInstallViolation(
/// <summary>Pack ID that requires sealed install.</summary>
string PackId,
/// <summary>Pack version.</summary>
string? PackVersion,
/// <summary>Whether pack requires sealed install.</summary>
bool RequiredSealed,
/// <summary>Actual sealed status of environment.</summary>
bool ActualSealed,
/// <summary>Recommendation for resolving the violation.</summary>
string Recommendation);
/// <summary>
/// Details about a requirement violation.
/// </summary>
public sealed record RequirementViolation(
/// <summary>Name of the requirement that was violated.</summary>
string Requirement,
/// <summary>Expected value.</summary>
string Expected,
/// <summary>Actual value.</summary>
string Actual,
/// <summary>Human-readable message describing the violation.</summary>
string Message);
/// <summary>
/// Error codes for sealed install enforcement.
/// </summary>
public static class SealedInstallErrorCodes
{
/// <summary>Pack requires sealed but environment is not sealed.</summary>
public const string SealedInstallViolation = "SEALED_INSTALL_VIOLATION";
/// <summary>Sealed requirements not met.</summary>
public const string SealedRequirementsViolation = "SEALED_REQUIREMENTS_VIOLATION";
/// <summary>Bundle version below minimum required.</summary>
public const string BundleVersionViolation = "BUNDLE_VERSION_VIOLATION";
/// <summary>Advisory data too stale.</summary>
public const string AdvisoryStalenessViolation = "ADVISORY_STALENESS_VIOLATION";
/// <summary>Time anchor missing or invalid.</summary>
public const string TimeAnchorViolation = "TIME_ANCHOR_VIOLATION";
/// <summary>Bundle signature verification failed.</summary>
public const string SignatureVerificationViolation = "SIGNATURE_VERIFICATION_VIOLATION";
}
/// <summary>
/// CLI exit codes for sealed install enforcement.
/// </summary>
public static class SealedInstallExitCodes
{
/// <summary>Pack requires sealed but environment is not.</summary>
public const int SealedInstallViolation = 40;
/// <summary>Bundle version below minimum.</summary>
public const int BundleVersionViolation = 41;
/// <summary>Advisory data too stale.</summary>
public const int AdvisoryStalenessViolation = 42;
/// <summary>Time anchor missing or invalid.</summary>
public const int TimeAnchorViolation = 43;
/// <summary>Bundle signature verification failed.</summary>
public const int SignatureVerificationViolation = 44;
}

View File

@@ -0,0 +1,297 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Enforces sealed install requirements for task packs.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed class SealedInstallEnforcer : ISealedInstallEnforcer
{
private readonly IAirGapStatusProvider _statusProvider;
private readonly IOptions<SealedInstallEnforcementOptions> _options;
private readonly ILogger<SealedInstallEnforcer> _logger;
public SealedInstallEnforcer(
IAirGapStatusProvider statusProvider,
IOptions<SealedInstallEnforcementOptions> options,
ILogger<SealedInstallEnforcer> logger)
{
_statusProvider = statusProvider ?? throw new ArgumentNullException(nameof(statusProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SealedInstallEnforcementResult> EnforceAsync(
TaskPackManifest manifest,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
var options = _options.Value;
// Check if enforcement is enabled
if (!options.Enabled)
{
_logger.LogDebug("Sealed install enforcement is disabled.");
return SealedInstallEnforcementResult.CreateAllowed("Enforcement disabled");
}
// Check for development bypass
if (options.BypassForDevelopment && IsDevelopmentEnvironment())
{
_logger.LogWarning("Sealed install enforcement bypassed for development environment.");
return SealedInstallEnforcementResult.CreateAllowed("Development bypass active");
}
// If pack doesn't require sealed install, allow
if (!manifest.Spec.SealedInstall)
{
_logger.LogDebug(
"Pack {PackName} v{PackVersion} does not require sealed install.",
manifest.Metadata.Name,
manifest.Metadata.Version);
return SealedInstallEnforcementResult.CreateAllowed("Pack does not require sealed install");
}
// Get environment sealed status
SealedModeStatus status;
try
{
status = await _statusProvider.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get air-gap status. Denying sealed install pack.");
return SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedInstallViolation,
"Failed to verify sealed mode status",
new SealedInstallViolation(
manifest.Metadata.Name,
manifest.Metadata.Version,
RequiredSealed: true,
ActualSealed: false,
Recommendation: "Ensure the AirGap controller is accessible: stella airgap status"));
}
// Core check: environment must be sealed
if (!status.Sealed)
{
_logger.LogWarning(
"Sealed install violation: Pack {PackName} v{PackVersion} requires sealed environment but environment is {Mode}.",
manifest.Metadata.Name,
manifest.Metadata.Version,
status.Mode);
return SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedInstallViolation,
"Pack requires sealed environment but environment is not sealed",
new SealedInstallViolation(
manifest.Metadata.Name,
manifest.Metadata.Version,
RequiredSealed: true,
ActualSealed: false,
Recommendation: "Activate sealed mode with: stella airgap seal"));
}
// Check sealed requirements if specified
var requirements = manifest.Spec.SealedRequirements ?? SealedRequirements.Default;
var violations = ValidateRequirements(requirements, status, options);
if (violations.Count > 0)
{
_logger.LogWarning(
"Sealed requirements violation for pack {PackName} v{PackVersion}: {ViolationCount} requirement(s) not met.",
manifest.Metadata.Name,
manifest.Metadata.Version,
violations.Count);
return SealedInstallEnforcementResult.CreateDenied(
SealedInstallErrorCodes.SealedRequirementsViolation,
"Sealed requirements not met",
violation: null,
requirementViolations: violations);
}
_logger.LogInformation(
"Sealed install requirements satisfied for pack {PackName} v{PackVersion}.",
manifest.Metadata.Name,
manifest.Metadata.Version);
return SealedInstallEnforcementResult.CreateAllowed("Sealed install requirements satisfied");
}
private List<RequirementViolation> ValidateRequirements(
SealedRequirements requirements,
SealedModeStatus status,
SealedInstallEnforcementOptions options)
{
var violations = new List<RequirementViolation>();
// Bundle version check
if (!string.IsNullOrWhiteSpace(requirements.MinBundleVersion) &&
!string.IsNullOrWhiteSpace(status.BundleVersion))
{
if (!IsVersionSatisfied(status.BundleVersion, requirements.MinBundleVersion))
{
violations.Add(new RequirementViolation(
Requirement: "min_bundle_version",
Expected: requirements.MinBundleVersion,
Actual: status.BundleVersion,
Message: $"Bundle version {status.BundleVersion} < required {requirements.MinBundleVersion}"));
}
}
// Advisory staleness check
var effectiveStaleness = status.AdvisoryStalenessHours;
var maxStaleness = requirements.MaxAdvisoryStalenessHours;
// Apply grace period if configured
if (options.StalenessGracePeriodHours > 0)
{
maxStaleness += options.StalenessGracePeriodHours;
}
if (effectiveStaleness > maxStaleness)
{
if (options.DenyOnStaleness)
{
violations.Add(new RequirementViolation(
Requirement: "max_advisory_staleness_hours",
Expected: requirements.MaxAdvisoryStalenessHours.ToString(),
Actual: effectiveStaleness.ToString(),
Message: $"Advisory data is {effectiveStaleness}h old, max allowed is {requirements.MaxAdvisoryStalenessHours}h"));
}
else if (effectiveStaleness > options.StalenessWarningThresholdHours)
{
_logger.LogWarning(
"Advisory data is {Staleness}h old, approaching max allowed {MaxStaleness}h.",
effectiveStaleness,
requirements.MaxAdvisoryStalenessHours);
}
}
// Time anchor check
if (requirements.RequireTimeAnchor)
{
if (status.TimeAnchor is null)
{
violations.Add(new RequirementViolation(
Requirement: "require_time_anchor",
Expected: "valid time anchor",
Actual: "missing",
Message: "Valid time anchor required but not present"));
}
else if (!status.TimeAnchor.Valid)
{
violations.Add(new RequirementViolation(
Requirement: "require_time_anchor",
Expected: "valid time anchor",
Actual: "invalid",
Message: "Time anchor present but invalid or expired"));
}
else if (status.TimeAnchor.ExpiresAt.HasValue &&
status.TimeAnchor.ExpiresAt.Value < DateTimeOffset.UtcNow)
{
violations.Add(new RequirementViolation(
Requirement: "require_time_anchor",
Expected: "non-expired time anchor",
Actual: $"expired at {status.TimeAnchor.ExpiresAt.Value:O}",
Message: "Time anchor has expired"));
}
}
return violations;
}
private static bool IsVersionSatisfied(string actual, string required)
{
// Try semantic version comparison
if (Version.TryParse(NormalizeVersion(actual), out var actualVersion) &&
Version.TryParse(NormalizeVersion(required), out var requiredVersion))
{
return actualVersion >= requiredVersion;
}
// Fall back to string comparison
return string.Compare(actual, required, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string NormalizeVersion(string version)
{
// Strip common prefixes like 'v' and suffixes like '-beta'
var normalized = version.TrimStart('v', 'V');
var dashIndex = normalized.IndexOf('-', StringComparison.Ordinal);
if (dashIndex > 0)
{
normalized = normalized[..dashIndex];
}
return normalized;
}
private static bool IsDevelopmentEnvironment()
{
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ??
Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
return string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// Configuration options for sealed install enforcement.
/// </summary>
public sealed class SealedInstallEnforcementOptions
{
/// <summary>
/// Whether enforcement is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Grace period for advisory staleness in hours.
/// </summary>
public int StalenessGracePeriodHours { get; set; } = 24;
/// <summary>
/// Warning threshold for staleness in hours.
/// </summary>
public int StalenessWarningThresholdHours { get; set; } = 120;
/// <summary>
/// Whether to deny on staleness violation (false = warn only).
/// </summary>
public bool DenyOnStaleness { get; set; } = true;
/// <summary>
/// Whether to use heuristic detection when AirGap controller is unavailable.
/// </summary>
public bool UseHeuristicDetection { get; set; } = true;
/// <summary>
/// Heuristic score threshold to consider environment sealed.
/// </summary>
public double HeuristicThreshold { get; set; } = 0.7;
/// <summary>
/// Bypass enforcement in development environments (DANGEROUS).
/// </summary>
public bool BypassForDevelopment { get; set; }
/// <summary>
/// Log all enforcement decisions.
/// </summary>
public bool LogAllDecisions { get; set; } = true;
/// <summary>
/// Audit retention in days.
/// </summary>
public int AuditRetentionDays { get; set; } = 365;
}

View File

@@ -0,0 +1,88 @@
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Represents the sealed mode status of the air-gap environment.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed record SealedModeStatus(
/// <summary>Whether the environment is currently sealed.</summary>
bool Sealed,
/// <summary>Current mode (sealed, unsealed, transitioning).</summary>
string Mode,
/// <summary>When the environment was sealed.</summary>
DateTimeOffset? SealedAt,
/// <summary>Identity that sealed the environment.</summary>
string? SealedBy,
/// <summary>Air-gap bundle version currently installed.</summary>
string? BundleVersion,
/// <summary>Digest of the bundle.</summary>
string? BundleDigest,
/// <summary>When advisories were last updated.</summary>
DateTimeOffset? LastAdvisoryUpdate,
/// <summary>Hours since last advisory update.</summary>
int AdvisoryStalenessHours,
/// <summary>Time anchor information.</summary>
TimeAnchorInfo? TimeAnchor,
/// <summary>Whether egress is blocked.</summary>
bool EgressBlocked,
/// <summary>Network policy in effect.</summary>
string? NetworkPolicy)
{
/// <summary>
/// Creates an unsealed status (environment not in air-gap mode).
/// </summary>
public static SealedModeStatus Unsealed() => new(
Sealed: false,
Mode: "unsealed",
SealedAt: null,
SealedBy: null,
BundleVersion: null,
BundleDigest: null,
LastAdvisoryUpdate: null,
AdvisoryStalenessHours: 0,
TimeAnchor: null,
EgressBlocked: false,
NetworkPolicy: null);
/// <summary>
/// Creates a status indicating the provider is unavailable.
/// </summary>
public static SealedModeStatus Unavailable() => new(
Sealed: false,
Mode: "unavailable",
SealedAt: null,
SealedBy: null,
BundleVersion: null,
BundleDigest: null,
LastAdvisoryUpdate: null,
AdvisoryStalenessHours: 0,
TimeAnchor: null,
EgressBlocked: false,
NetworkPolicy: null);
}
/// <summary>
/// Time anchor information for sealed environments.
/// </summary>
public sealed record TimeAnchorInfo(
/// <summary>The anchor timestamp.</summary>
DateTimeOffset Timestamp,
/// <summary>Signature of the time anchor.</summary>
string? Signature,
/// <summary>Whether the time anchor is valid.</summary>
bool Valid,
/// <summary>When the time anchor expires.</summary>
DateTimeOffset? ExpiresAt);

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.AirGap;
/// <summary>
/// Sealed install requirements specified in a task pack manifest.
/// Per sealed-install-enforcement.md contract.
/// </summary>
public sealed record SealedRequirements(
/// <summary>Minimum air-gap bundle version required.</summary>
[property: JsonPropertyName("min_bundle_version")]
string? MinBundleVersion,
/// <summary>Maximum age of advisory data in hours (default: 168).</summary>
[property: JsonPropertyName("max_advisory_staleness_hours")]
int MaxAdvisoryStalenessHours,
/// <summary>Whether a valid time anchor is required (default: true).</summary>
[property: JsonPropertyName("require_time_anchor")]
bool RequireTimeAnchor,
/// <summary>Maximum allowed offline duration in hours (default: 720).</summary>
[property: JsonPropertyName("allowed_offline_duration_hours")]
int AllowedOfflineDurationHours,
/// <summary>Whether bundle signature verification is required (default: true).</summary>
[property: JsonPropertyName("require_signature_verification")]
bool RequireSignatureVerification)
{
/// <summary>
/// Default sealed requirements.
/// </summary>
public static SealedRequirements Default => new(
MinBundleVersion: null,
MaxAdvisoryStalenessHours: 168,
RequireTimeAnchor: true,
AllowedOfflineDurationHours: 720,
RequireSignatureVerification: true);
}

View File

@@ -301,6 +301,18 @@ public static class PackRunEventTypes
/// <summary>Policy gate evaluated.</summary>
public const string PolicyEvaluated = "pack.policy.evaluated";
/// <summary>Sealed install enforcement performed.</summary>
public const string SealedInstallEnforcement = "pack.sealed_install.enforcement";
/// <summary>Sealed install enforcement denied execution.</summary>
public const string SealedInstallDenied = "pack.sealed_install.denied";
/// <summary>Sealed install enforcement allowed execution.</summary>
public const string SealedInstallAllowed = "pack.sealed_install.allowed";
/// <summary>Sealed install requirements warning.</summary>
public const string SealedInstallWarning = "pack.sealed_install.warning";
/// <summary>Checks if the event type is a pack run event.</summary>
public static bool IsPackRunEvent(string eventType) =>
eventType.StartsWith(Prefix, StringComparison.Ordinal);

View File

@@ -2,9 +2,9 @@ using System.Diagnostics.Metrics;
namespace StellaOps.TaskRunner.Core.Execution;
internal static class TaskRunnerTelemetry
public static class TaskRunnerTelemetry
{
internal const string MeterName = "stellaops.taskrunner";
public const string MeterName = "stellaops.taskrunner";
internal static readonly Meter Meter = new(MeterName);
internal static readonly Histogram<double> StepDurationMs =

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using StellaOps.TaskRunner.Core.AirGap;
namespace StellaOps.TaskRunner.Core.TaskPacks;
@@ -82,6 +83,18 @@ public sealed class TaskPackSpec
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
/// <summary>
/// Whether this pack requires a sealed (air-gapped) environment.
/// </summary>
[JsonPropertyName("sealedInstall")]
public bool SealedInstall { get; init; }
/// <summary>
/// Specific requirements for sealed install mode.
/// </summary>
[JsonPropertyName("sealedRequirements")]
public SealedRequirements? SealedRequirements { get; init; }
}
public sealed class TaskPackInput

View File

@@ -0,0 +1,237 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.AirGap;
namespace StellaOps.TaskRunner.Infrastructure.AirGap;
/// <summary>
/// HTTP client implementation for retrieving air-gap status from the AirGap controller.
/// </summary>
public sealed class HttpAirGapStatusProvider : IAirGapStatusProvider
{
private readonly HttpClient _httpClient;
private readonly IOptions<AirGapStatusProviderOptions> _options;
private readonly ILogger<HttpAirGapStatusProvider> _logger;
public HttpAirGapStatusProvider(
HttpClient httpClient,
IOptions<AirGapStatusProviderOptions> options,
ILogger<HttpAirGapStatusProvider> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SealedModeStatus> GetStatusAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
var options = _options.Value;
var url = string.IsNullOrWhiteSpace(tenantId)
? options.StatusEndpoint
: $"{options.StatusEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}";
try
{
var response = await _httpClient.GetFromJsonAsync<AirGapStatusDto>(
url,
cancellationToken).ConfigureAwait(false);
if (response is null)
{
_logger.LogWarning("AirGap controller returned null response.");
return SealedModeStatus.Unavailable();
}
return MapToSealedModeStatus(response);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to connect to AirGap controller at {Url}.", url);
if (options.UseHeuristicFallback)
{
return await GetStatusFromHeuristicsAsync(cancellationToken).ConfigureAwait(false);
}
return SealedModeStatus.Unavailable();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error getting air-gap status.");
return SealedModeStatus.Unavailable();
}
}
private static SealedModeStatus MapToSealedModeStatus(AirGapStatusDto dto)
{
TimeAnchorInfo? timeAnchor = null;
if (dto.TimeAnchor is not null)
{
timeAnchor = new TimeAnchorInfo(
dto.TimeAnchor.Timestamp,
dto.TimeAnchor.Signature,
dto.TimeAnchor.Valid,
dto.TimeAnchor.ExpiresAt);
}
return new SealedModeStatus(
Sealed: dto.Sealed,
Mode: dto.Sealed ? "sealed" : "unsealed",
SealedAt: dto.SealedAt,
SealedBy: dto.SealedBy,
BundleVersion: dto.BundleVersion,
BundleDigest: dto.BundleDigest,
LastAdvisoryUpdate: dto.LastAdvisoryUpdate,
AdvisoryStalenessHours: dto.AdvisoryStalenessHours,
TimeAnchor: timeAnchor,
EgressBlocked: dto.EgressBlocked,
NetworkPolicy: dto.NetworkPolicy);
}
private async Task<SealedModeStatus> GetStatusFromHeuristicsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Using heuristic detection for sealed mode status.");
var score = 0.0;
var weights = 0.0;
// Check AIRGAP_MODE environment variable (high weight)
var airgapMode = Environment.GetEnvironmentVariable("AIRGAP_MODE");
if (string.Equals(airgapMode, "sealed", StringComparison.OrdinalIgnoreCase))
{
score += 0.3;
}
weights += 0.3;
// Check for sealed file marker (medium weight)
var sealedMarkerPath = _options.Value.SealedMarkerPath;
if (!string.IsNullOrWhiteSpace(sealedMarkerPath) && File.Exists(sealedMarkerPath))
{
score += 0.2;
}
weights += 0.2;
// Check network connectivity (high weight)
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(2));
var testResponse = await _httpClient.GetAsync(
_options.Value.ConnectivityTestUrl,
cts.Token).ConfigureAwait(false);
// If we can reach external network, likely not sealed
}
catch (Exception)
{
// Network blocked, likely sealed
score += 0.3;
}
weights += 0.3;
// Check for local registry configuration (low weight)
var registryEnv = Environment.GetEnvironmentVariable("CONTAINER_REGISTRY");
if (!string.IsNullOrWhiteSpace(registryEnv) &&
(registryEnv.Contains("localhost", StringComparison.OrdinalIgnoreCase) ||
registryEnv.Contains("127.0.0.1", StringComparison.Ordinal)))
{
score += 0.1;
}
weights += 0.1;
// Check proxy settings (low weight)
var httpProxy = Environment.GetEnvironmentVariable("HTTP_PROXY") ??
Environment.GetEnvironmentVariable("http_proxy");
var noProxy = Environment.GetEnvironmentVariable("NO_PROXY") ??
Environment.GetEnvironmentVariable("no_proxy");
if (string.IsNullOrWhiteSpace(httpProxy) && !string.IsNullOrWhiteSpace(noProxy))
{
score += 0.1;
}
weights += 0.1;
var normalizedScore = weights > 0 ? score / weights : 0;
var threshold = _options.Value.HeuristicThreshold;
var isSealed = normalizedScore >= threshold;
_logger.LogInformation(
"Heuristic detection result: score={Score:F2}, threshold={Threshold:F2}, sealed={IsSealed}",
normalizedScore,
threshold,
isSealed);
return new SealedModeStatus(
Sealed: isSealed,
Mode: isSealed ? "sealed-heuristic" : "unsealed-heuristic",
SealedAt: null,
SealedBy: null,
BundleVersion: null,
BundleDigest: null,
LastAdvisoryUpdate: null,
AdvisoryStalenessHours: 0,
TimeAnchor: null,
EgressBlocked: isSealed,
NetworkPolicy: isSealed ? "heuristic-detected" : null);
}
private sealed record AirGapStatusDto(
[property: JsonPropertyName("sealed")] bool Sealed,
[property: JsonPropertyName("sealed_at")] DateTimeOffset? SealedAt,
[property: JsonPropertyName("sealed_by")] string? SealedBy,
[property: JsonPropertyName("bundle_version")] string? BundleVersion,
[property: JsonPropertyName("bundle_digest")] string? BundleDigest,
[property: JsonPropertyName("last_advisory_update")] DateTimeOffset? LastAdvisoryUpdate,
[property: JsonPropertyName("advisory_staleness_hours")] int AdvisoryStalenessHours,
[property: JsonPropertyName("time_anchor")] TimeAnchorDto? TimeAnchor,
[property: JsonPropertyName("egress_blocked")] bool EgressBlocked,
[property: JsonPropertyName("network_policy")] string? NetworkPolicy);
private sealed record TimeAnchorDto(
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("signature")] string? Signature,
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);
}
/// <summary>
/// Configuration options for the HTTP air-gap status provider.
/// </summary>
public sealed class AirGapStatusProviderOptions
{
/// <summary>
/// Base URL of the AirGap controller.
/// </summary>
public string BaseUrl { get; set; } = "http://localhost:8080";
/// <summary>
/// Status endpoint path.
/// </summary>
public string StatusEndpoint { get; set; } = "/api/v1/airgap/status";
/// <summary>
/// Whether to use heuristic fallback when controller is unavailable.
/// </summary>
public bool UseHeuristicFallback { get; set; } = true;
/// <summary>
/// Heuristic score threshold (0.0-1.0) to consider environment sealed.
/// </summary>
public double HeuristicThreshold { get; set; } = 0.7;
/// <summary>
/// Path to the sealed mode marker file.
/// </summary>
public string? SealedMarkerPath { get; set; } = "/etc/stellaops/sealed";
/// <summary>
/// URL to test external connectivity.
/// </summary>
public string ConnectivityTestUrl { get; set; } = "https://api.stellaops.org/health";
}

View File

@@ -0,0 +1,174 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.WebService.Deprecation;
namespace StellaOps.TaskRunner.Tests;
public sealed class ApiDeprecationTests
{
[Fact]
public void DeprecatedEndpoint_PathPattern_MatchesExpected()
{
var endpoint = new DeprecatedEndpoint
{
PathPattern = "/v1/legacy/*",
DeprecatedAt = DateTimeOffset.UtcNow.AddDays(-30),
SunsetAt = DateTimeOffset.UtcNow.AddDays(60),
ReplacementPath = "/v2/new",
Message = "Use the v2 API"
};
Assert.Equal("/v1/legacy/*", endpoint.PathPattern);
Assert.NotNull(endpoint.DeprecatedAt);
Assert.NotNull(endpoint.SunsetAt);
}
[Fact]
public void ApiDeprecationOptions_DefaultValues_AreCorrect()
{
var options = new ApiDeprecationOptions();
Assert.True(options.EmitDeprecationHeaders);
Assert.True(options.EmitSunsetHeaders);
Assert.NotNull(options.DeprecationPolicyUrl);
Assert.Empty(options.DeprecatedEndpoints);
}
[Fact]
public async Task LoggingDeprecationNotificationService_GetUpcoming_FiltersCorrectly()
{
var now = DateTimeOffset.UtcNow;
var options = new ApiDeprecationOptions
{
DeprecatedEndpoints =
[
new DeprecatedEndpoint
{
PathPattern = "/v1/soon/*",
SunsetAt = now.AddDays(30) // Within 90 days
},
new DeprecatedEndpoint
{
PathPattern = "/v1/later/*",
SunsetAt = now.AddDays(180) // Beyond 90 days
},
new DeprecatedEndpoint
{
PathPattern = "/v1/past/*",
SunsetAt = now.AddDays(-10) // Already passed
}
]
};
var optionsMonitor = new OptionsMonitor(options);
var service = new LoggingDeprecationNotificationService(
NullLogger<LoggingDeprecationNotificationService>.Instance,
optionsMonitor);
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
Assert.Single(upcoming);
Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath);
}
[Fact]
public async Task LoggingDeprecationNotificationService_GetUpcoming_OrdersBySunsetDate()
{
var now = DateTimeOffset.UtcNow;
var options = new ApiDeprecationOptions
{
DeprecatedEndpoints =
[
new DeprecatedEndpoint { PathPattern = "/v1/third/*", SunsetAt = now.AddDays(60) },
new DeprecatedEndpoint { PathPattern = "/v1/first/*", SunsetAt = now.AddDays(10) },
new DeprecatedEndpoint { PathPattern = "/v1/second/*", SunsetAt = now.AddDays(30) }
]
};
var optionsMonitor = new OptionsMonitor(options);
var service = new LoggingDeprecationNotificationService(
NullLogger<LoggingDeprecationNotificationService>.Instance,
optionsMonitor);
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
Assert.Equal(3, upcoming.Count);
Assert.Equal("/v1/first/*", upcoming[0].EndpointPath);
Assert.Equal("/v1/second/*", upcoming[1].EndpointPath);
Assert.Equal("/v1/third/*", upcoming[2].EndpointPath);
}
[Fact]
public void DeprecationInfo_DaysUntilSunset_CalculatesCorrectly()
{
var now = DateTimeOffset.UtcNow;
var sunsetDate = now.AddDays(45);
var info = new DeprecationInfo(
"/v1/test/*",
now.AddDays(-30),
sunsetDate,
"/v2/test/*",
"https://docs.example.com/migration",
45);
Assert.Equal(45, info.DaysUntilSunset);
Assert.Equal("/v2/test/*", info.ReplacementPath);
}
[Fact]
public void DeprecationNotification_RecordProperties_AreAccessible()
{
var notification = new DeprecationNotification(
"/v1/legacy/endpoint",
"/v2/new/endpoint",
DateTimeOffset.UtcNow.AddDays(90),
"This endpoint is deprecated",
"https://docs.example.com/deprecation",
["consumer-1", "consumer-2"]);
Assert.Equal("/v1/legacy/endpoint", notification.EndpointPath);
Assert.Equal("/v2/new/endpoint", notification.ReplacementPath);
Assert.NotNull(notification.SunsetDate);
Assert.Equal(2, notification.AffectedConsumerIds?.Count);
}
[Fact]
public void PathPattern_WildcardToRegex_MatchesSingleSegment()
{
var pattern = "^" + Regex.Escape("/v1/packs/*")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/packs/foo");
Assert.Matches(pattern, "/v1/packs/bar");
Assert.DoesNotMatch(pattern, "/v1/packs/foo/bar"); // Single * shouldn't match /
Assert.DoesNotMatch(pattern, "/v2/packs/foo");
}
[Fact]
public void PathPattern_DoubleWildcard_MatchesMultipleSegments()
{
var pattern = "^" + Regex.Escape("/v1/legacy/**")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/legacy/foo");
Assert.Matches(pattern, "/v1/legacy/foo/bar");
Assert.Matches(pattern, "/v1/legacy/foo/bar/baz");
Assert.DoesNotMatch(pattern, "/v2/legacy/foo");
}
private sealed class OptionsMonitor : IOptionsMonitor<ApiDeprecationOptions>
{
public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value;
public ApiDeprecationOptions CurrentValue { get; }
public ApiDeprecationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<ApiDeprecationOptions, string?> listener) => null;
}
}

View File

@@ -9,12 +9,15 @@ public sealed class OpenApiMetadataFactoryTests
{
var metadata = OpenApiMetadataFactory.Create();
Assert.Equal("/openapi", metadata.Url);
Assert.False(string.IsNullOrWhiteSpace(metadata.Build));
Assert.Equal("/openapi", metadata.SpecUrl);
Assert.Equal(OpenApiMetadataFactory.ApiVersion, metadata.Version);
Assert.False(string.IsNullOrWhiteSpace(metadata.BuildVersion));
Assert.StartsWith("W/\"", metadata.ETag);
Assert.EndsWith("\"", metadata.ETag);
Assert.Equal(64, metadata.Signature.Length);
Assert.True(metadata.Signature.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
Assert.StartsWith("sha256:", metadata.Signature);
var hashPart = metadata.Signature["sha256:".Length..];
Assert.Equal(64, hashPart.Length);
Assert.True(hashPart.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
}
[Fact]
@@ -22,6 +25,26 @@ public sealed class OpenApiMetadataFactoryTests
{
var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json");
Assert.Equal("/docs/openapi.json", metadata.Url);
Assert.Equal("/docs/openapi.json", metadata.SpecUrl);
}
[Fact]
public void Create_SignatureIncludesAllComponents()
{
var metadata1 = OpenApiMetadataFactory.Create("/path1");
var metadata2 = OpenApiMetadataFactory.Create("/path2");
// Different URLs should produce different signatures
Assert.NotEqual(metadata1.Signature, metadata2.Signature);
}
[Fact]
public void Create_ETagIsDeterministic()
{
var metadata1 = OpenApiMetadataFactory.Create();
var metadata2 = OpenApiMetadataFactory.Create();
// Same inputs should produce same ETag
Assert.Equal(metadata1.ETag, metadata2.ETag);
}
}

View File

@@ -21,11 +21,14 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj" />
<ProjectReference Include="..\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj" />
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" />
<!-- OpenApiMetadataFactory is now accessible via WebService project reference -->
<!-- <Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" /> -->
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,242 @@
using System.Text;
using StellaOps.TaskRunner.Client.Models;
using StellaOps.TaskRunner.Client.Streaming;
using StellaOps.TaskRunner.Client.Pagination;
using StellaOps.TaskRunner.Client.Lifecycle;
namespace StellaOps.TaskRunner.Tests;
public sealed class TaskRunnerClientTests
{
[Fact]
public async Task StreamingLogReader_ParsesNdjsonLines()
{
var ct = TestContext.Current.CancellationToken;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Starting","traceId":"abc123"}
{"timestamp":"2025-01-01T00:00:01Z","level":"error","stepId":"step-1","message":"Failed","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
Assert.Equal("info", entries[0].Level);
Assert.Equal("error", entries[1].Level);
Assert.Equal("step-1", entries[0].StepId);
Assert.Equal("Starting", entries[0].Message);
}
[Fact]
public async Task StreamingLogReader_SkipsEmptyLines()
{
var ct = TestContext.Current.CancellationToken;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Test","traceId":"abc123"}
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"Test2","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
}
[Fact]
public async Task StreamingLogReader_SkipsMalformedLines()
{
var ct = TestContext.Current.CancellationToken;
var ndjson = """
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Valid","traceId":"abc123"}
not valid json
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"AlsoValid","traceId":"abc123"}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
var entries = await StreamingLogReader.CollectAsync(stream, ct);
Assert.Equal(2, entries.Count);
Assert.Equal("Valid", entries[0].Message);
Assert.Equal("AlsoValid", entries[1].Message);
}
[Fact]
public async Task StreamingLogReader_FilterByLevel_FiltersCorrectly()
{
var ct = TestContext.Current.CancellationToken;
var entries = new List<RunLogEntry>
{
new(DateTimeOffset.UtcNow, "info", "step-1", "Info message", "trace1"),
new(DateTimeOffset.UtcNow, "error", "step-1", "Error message", "trace1"),
new(DateTimeOffset.UtcNow, "warning", "step-1", "Warning message", "trace1"),
};
var levels = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "error", "warning" };
var filtered = new List<RunLogEntry>();
await foreach (var entry in StreamingLogReader.FilterByLevelAsync(entries.ToAsyncEnumerable(), levels, ct))
{
filtered.Add(entry);
}
Assert.Equal(2, filtered.Count);
Assert.DoesNotContain(filtered, e => e.Level == "info");
}
[Fact]
public async Task StreamingLogReader_GroupByStep_GroupsCorrectly()
{
var ct = TestContext.Current.CancellationToken;
var entries = new List<RunLogEntry>
{
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 1", "trace1"),
new(DateTimeOffset.UtcNow, "info", "step-2", "Message 2", "trace1"),
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 3", "trace1"),
new(DateTimeOffset.UtcNow, "info", null, "Global message", "trace1"),
};
var groups = await StreamingLogReader.GroupByStepAsync(entries.ToAsyncEnumerable(), ct);
Assert.Equal(3, groups.Count);
Assert.Equal(2, groups["step-1"].Count);
Assert.Single(groups["step-2"]);
Assert.Single(groups["(global)"]);
}
[Fact]
public async Task Paginator_IteratesAllPages()
{
var ct = TestContext.Current.CancellationToken;
var allItems = Enumerable.Range(1, 25).ToList();
var pageSize = 10;
var fetchCalls = 0;
var paginator = new Paginator<int>(
async (offset, limit, token) =>
{
fetchCalls++;
var items = allItems.Skip(offset).Take(limit).ToList();
var hasMore = offset + items.Count < allItems.Count;
return new PagedResponse<int>(items, allItems.Count, hasMore);
},
pageSize);
var collected = await paginator.CollectAsync(ct);
Assert.Equal(25, collected.Count);
Assert.Equal(3, fetchCalls); // 10, 10, 5 items
Assert.Equal(allItems, collected);
}
[Fact]
public async Task Paginator_GetPage_ReturnsCorrectPage()
{
var ct = TestContext.Current.CancellationToken;
var allItems = Enumerable.Range(1, 25).ToList();
var pageSize = 10;
var paginator = new Paginator<int>(
async (offset, limit, token) =>
{
var items = allItems.Skip(offset).Take(limit).ToList();
var hasMore = offset + items.Count < allItems.Count;
return new PagedResponse<int>(items, allItems.Count, hasMore);
},
pageSize);
var page2 = await paginator.GetPageAsync(2, ct);
Assert.Equal(10, page2.Items.Count);
Assert.Equal(11, page2.Items[0]); // Items 11-20
}
[Fact]
public async Task PaginatorExtensions_TakeAsync_TakesCorrectNumber()
{
var ct = TestContext.Current.CancellationToken;
var items = Enumerable.Range(1, 100).ToAsyncEnumerable();
var taken = new List<int>();
await foreach (var item in items.TakeAsync(5, ct))
{
taken.Add(item);
}
Assert.Equal(5, taken.Count);
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, taken);
}
[Fact]
public async Task PaginatorExtensions_SkipAsync_SkipsCorrectNumber()
{
var ct = TestContext.Current.CancellationToken;
var items = Enumerable.Range(1, 10).ToAsyncEnumerable();
var skipped = new List<int>();
await foreach (var item in items.SkipAsync(5, ct))
{
skipped.Add(item);
}
Assert.Equal(5, skipped.Count);
Assert.Equal(new[] { 6, 7, 8, 9, 10 }, skipped);
}
[Fact]
public void PackRunLifecycleHelper_TerminalStatuses_IncludesExpectedStatuses()
{
Assert.Contains("completed", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("failed", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("cancelled", PackRunLifecycleHelper.TerminalStatuses);
Assert.Contains("rejected", PackRunLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("running", PackRunLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("pending", PackRunLifecycleHelper.TerminalStatuses);
}
[Fact]
public void PackRunModels_CreatePackRunRequest_SerializesCorrectly()
{
var request = new CreatePackRunRequest(
"my-pack",
"1.0.0",
new Dictionary<string, object> { ["key"] = "value" },
"tenant-1",
"corr-123");
Assert.Equal("my-pack", request.PackId);
Assert.Equal("1.0.0", request.PackVersion);
Assert.NotNull(request.Inputs);
Assert.Equal("value", request.Inputs["key"]);
}
[Fact]
public void PackRunModels_SimulatedStep_HasCorrectProperties()
{
var loopInfo = new LoopInfo("{{ inputs.items }}", "item", 100);
var step = new SimulatedStep(
"step-1",
"loop",
"WillIterate",
loopInfo,
null,
null);
Assert.Equal("step-1", step.StepId);
Assert.Equal("loop", step.Kind);
Assert.NotNull(step.LoopInfo);
Assert.Equal("{{ inputs.items }}", step.LoopInfo.ItemsExpression);
}
}
internal static class AsyncEnumerableExtensions
{
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,196 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Middleware that adds deprecation and sunset headers per RFC 8594.
/// </summary>
public sealed class ApiDeprecationMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
private readonly ILogger<ApiDeprecationMiddleware> _logger;
private readonly List<CompiledEndpointPattern> _patterns;
/// <summary>
/// HTTP header for deprecation status per draft-ietf-httpapi-deprecation-header.
/// </summary>
public const string DeprecationHeader = "Deprecation";
/// <summary>
/// HTTP header for sunset date per RFC 8594.
/// </summary>
public const string SunsetHeader = "Sunset";
/// <summary>
/// HTTP Link header for deprecation documentation.
/// </summary>
public const string LinkHeader = "Link";
public ApiDeprecationMiddleware(
RequestDelegate next,
IOptionsMonitor<ApiDeprecationOptions> options,
ILogger<ApiDeprecationMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_patterns = CompilePatterns(options.CurrentValue.DeprecatedEndpoints);
options.OnChange(newOptions =>
{
_patterns.Clear();
_patterns.AddRange(CompilePatterns(newOptions.DeprecatedEndpoints));
});
}
public async Task InvokeAsync(HttpContext context)
{
var options = _options.CurrentValue;
var path = context.Request.Path.Value ?? string.Empty;
var deprecatedEndpoint = FindMatchingEndpoint(path);
if (deprecatedEndpoint is not null)
{
AddDeprecationHeaders(context.Response, deprecatedEndpoint, options);
_logger.LogInformation(
"Deprecated endpoint accessed: {Path} (sunset: {Sunset})",
path,
deprecatedEndpoint.Config.SunsetAt?.ToString("o", CultureInfo.InvariantCulture) ?? "not set");
}
await _next(context).ConfigureAwait(false);
}
private CompiledEndpointPattern? FindMatchingEndpoint(string path)
{
foreach (var pattern in _patterns)
{
if (pattern.Regex.IsMatch(path))
{
return pattern;
}
}
return null;
}
private static void AddDeprecationHeaders(
HttpResponse response,
CompiledEndpointPattern endpoint,
ApiDeprecationOptions options)
{
var config = endpoint.Config;
// Add Deprecation header per draft-ietf-httpapi-deprecation-header
if (options.EmitDeprecationHeaders && config.DeprecatedAt.HasValue)
{
// RFC 7231 date format: Sun, 06 Nov 1994 08:49:37 GMT
var deprecationDate = config.DeprecatedAt.Value.ToString("R", CultureInfo.InvariantCulture);
response.Headers.Append(DeprecationHeader, deprecationDate);
}
else if (options.EmitDeprecationHeaders)
{
// If no specific date, use "true" to indicate deprecated
response.Headers.Append(DeprecationHeader, "true");
}
// Add Sunset header per RFC 8594
if (options.EmitSunsetHeaders && config.SunsetAt.HasValue)
{
var sunsetDate = config.SunsetAt.Value.ToString("R", CultureInfo.InvariantCulture);
response.Headers.Append(SunsetHeader, sunsetDate);
}
// Add Link headers for documentation
var links = new List<string>();
if (!string.IsNullOrWhiteSpace(config.DeprecationLink))
{
links.Add($"<{config.DeprecationLink}>; rel=\"deprecation\"; type=\"text/html\"");
}
if (!string.IsNullOrWhiteSpace(options.DeprecationPolicyUrl))
{
links.Add($"<{options.DeprecationPolicyUrl}>; rel=\"sunset\"; type=\"text/html\"");
}
if (!string.IsNullOrWhiteSpace(config.ReplacementPath))
{
links.Add($"<{config.ReplacementPath}>; rel=\"successor-version\"");
}
if (links.Count > 0)
{
response.Headers.Append(LinkHeader, string.Join(", ", links));
}
// Add custom deprecation message header
if (!string.IsNullOrWhiteSpace(config.Message))
{
response.Headers.Append("X-Deprecation-Notice", config.Message);
}
}
private static List<CompiledEndpointPattern> CompilePatterns(List<DeprecatedEndpoint> endpoints)
{
var patterns = new List<CompiledEndpointPattern>(endpoints.Count);
foreach (var endpoint in endpoints)
{
if (string.IsNullOrWhiteSpace(endpoint.PathPattern))
{
continue;
}
// Convert wildcard pattern to regex
var pattern = "^" + Regex.Escape(endpoint.PathPattern)
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
try
{
var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
patterns.Add(new CompiledEndpointPattern(regex, endpoint));
}
catch (ArgumentException)
{
// Invalid regex pattern, skip
}
}
return patterns;
}
private sealed record CompiledEndpointPattern(Regex Regex, DeprecatedEndpoint Config);
}
/// <summary>
/// Extension methods for adding API deprecation middleware.
/// </summary>
public static class ApiDeprecationMiddlewareExtensions
{
/// <summary>
/// Adds the API deprecation middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseApiDeprecation(this IApplicationBuilder app)
{
return app.UseMiddleware<ApiDeprecationMiddleware>();
}
/// <summary>
/// Adds API deprecation services to the service collection.
/// </summary>
public static IServiceCollection AddApiDeprecation(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<ApiDeprecationOptions>(
configuration.GetSection(ApiDeprecationOptions.SectionName));
return services;
}
}

View File

@@ -0,0 +1,68 @@
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Configuration options for API deprecation and sunset headers.
/// </summary>
public sealed class ApiDeprecationOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "TaskRunner:ApiDeprecation";
/// <summary>
/// Whether to emit deprecation headers for deprecated endpoints.
/// </summary>
public bool EmitDeprecationHeaders { get; set; } = true;
/// <summary>
/// Whether to emit sunset headers per RFC 8594.
/// </summary>
public bool EmitSunsetHeaders { get; set; } = true;
/// <summary>
/// URL to deprecation policy documentation.
/// </summary>
public string? DeprecationPolicyUrl { get; set; } = "https://docs.stellaops.io/api/deprecation-policy";
/// <summary>
/// List of deprecated endpoints with their sunset dates.
/// </summary>
public List<DeprecatedEndpoint> DeprecatedEndpoints { get; set; } = [];
}
/// <summary>
/// Configuration for a deprecated endpoint.
/// </summary>
public sealed class DeprecatedEndpoint
{
/// <summary>
/// Path pattern to match (supports wildcards like /v1/packs/*).
/// </summary>
public string PathPattern { get; set; } = string.Empty;
/// <summary>
/// Date when the endpoint was deprecated.
/// </summary>
public DateTimeOffset? DeprecatedAt { get; set; }
/// <summary>
/// Date when the endpoint will be removed (sunset date per RFC 8594).
/// </summary>
public DateTimeOffset? SunsetAt { get; set; }
/// <summary>
/// URL to documentation about the deprecation and migration path.
/// </summary>
public string? DeprecationLink { get; set; }
/// <summary>
/// Suggested replacement endpoint path.
/// </summary>
public string? ReplacementPath { get; set; }
/// <summary>
/// Human-readable deprecation message.
/// </summary>
public string? Message { get; set; }
}

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Options;
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Service for sending deprecation notifications to API consumers.
/// </summary>
public interface IDeprecationNotificationService
{
/// <summary>
/// Sends a notification about an upcoming deprecation.
/// </summary>
/// <param name="notification">Deprecation notification details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default);
/// <summary>
/// Gets upcoming deprecations within a specified number of days.
/// </summary>
/// <param name="withinDays">Number of days to look ahead.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of upcoming deprecations.</returns>
Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
int withinDays = 90,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Deprecation notification details.
/// </summary>
public sealed record DeprecationNotification(
string EndpointPath,
string? ReplacementPath,
DateTimeOffset? SunsetDate,
string? Message,
string? DocumentationUrl,
IReadOnlyList<string>? AffectedConsumerIds);
/// <summary>
/// Information about a deprecation.
/// </summary>
public sealed record DeprecationInfo(
string EndpointPath,
DateTimeOffset? DeprecatedAt,
DateTimeOffset? SunsetAt,
string? ReplacementPath,
string? DocumentationUrl,
int DaysUntilSunset);
/// <summary>
/// Default implementation that logs deprecation notifications.
/// </summary>
public sealed class LoggingDeprecationNotificationService : IDeprecationNotificationService
{
private readonly ILogger<LoggingDeprecationNotificationService> _logger;
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
public LoggingDeprecationNotificationService(
ILogger<LoggingDeprecationNotificationService> logger,
IOptionsMonitor<ApiDeprecationOptions> options)
{
_logger = logger;
_options = options;
}
public Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default)
{
_logger.LogWarning(
"Deprecation notification: Endpoint {Endpoint} will be sunset on {SunsetDate}. " +
"Replacement: {Replacement}. Message: {Message}",
notification.EndpointPath,
notification.SunsetDate?.ToString("o"),
notification.ReplacementPath ?? "(none)",
notification.Message ?? "(none)");
return Task.CompletedTask;
}
public Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
int withinDays = 90,
CancellationToken cancellationToken = default)
{
var options = _options.CurrentValue;
var now = DateTimeOffset.UtcNow;
var cutoff = now.AddDays(withinDays);
var upcoming = options.DeprecatedEndpoints
.Where(e => e.SunsetAt.HasValue && e.SunsetAt.Value <= cutoff && e.SunsetAt.Value > now)
.OrderBy(e => e.SunsetAt)
.Select(e => new DeprecationInfo(
e.PathPattern,
e.DeprecatedAt,
e.SunsetAt,
e.ReplacementPath,
e.DeprecationLink,
e.SunsetAt.HasValue ? (int)(e.SunsetAt.Value - now).TotalDays : int.MaxValue))
.ToList();
return Task.FromResult<IReadOnlyList<DeprecationInfo>>(upcoming);
}
}

View File

@@ -7,7 +7,7 @@ namespace StellaOps.TaskRunner.WebService;
/// <summary>
/// Factory for creating OpenAPI metadata including version, build info, and spec signature.
/// </summary>
internal static class OpenApiMetadataFactory
public static class OpenApiMetadataFactory
{
/// <summary>API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml).</summary>
public const string ApiVersion = "0.1.0-draft";
@@ -73,7 +73,7 @@ internal static class OpenApiMetadataFactory
/// <param name="BuildVersion">Build/assembly version with optional git info.</param>
/// <param name="ETag">ETag for HTTP caching.</param>
/// <param name="Signature">SHA-256 signature for verification.</param>
internal sealed record OpenApiMetadata(
public sealed record OpenApiMetadata(
string SpecUrl,
string Version,
string BuildVersion,

View File

@@ -5,7 +5,10 @@ using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using MongoDB.Driver;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -17,6 +20,7 @@ using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TaskRunner.WebService;
using StellaOps.TaskRunner.WebService.Deprecation;
using StellaOps.Telemetry.Core;
var builder = WebApplication.CreateBuilder(args);
@@ -95,12 +99,42 @@ builder.Services.AddSingleton(sp =>
});
builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<FilesystemPackRunDispatcher>());
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
builder.Services.AddApiDeprecation(builder.Configuration);
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
builder.Services.AddOpenApi();
var app = builder.Build();
// Add deprecation middleware for sunset headers (RFC 8594)
app.UseApiDeprecation();
app.MapOpenApi("/openapi");
// Deprecation status endpoint
app.MapGet("/v1/task-runner/deprecations", async (
IDeprecationNotificationService deprecationService,
[FromQuery] int? withinDays,
CancellationToken cancellationToken) =>
{
var days = withinDays ?? 90;
var deprecations = await deprecationService.GetUpcomingDeprecationsAsync(days, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new
{
withinDays = days,
deprecations = deprecations.Select(d => new
{
endpoint = d.EndpointPath,
deprecatedAt = d.DeprecatedAt?.ToString("o"),
sunsetAt = d.SunsetAt?.ToString("o"),
daysUntilSunset = d.DaysUntilSunset,
replacement = d.ReplacementPath,
documentation = d.DocumentationUrl
})
});
}).WithName("GetDeprecations").WithTags("API Governance");
app.MapPost("/v1/task-runner/simulations", async (
[FromBody] SimulationRequest request,
TaskPackManifestLoader loader,
@@ -290,11 +324,11 @@ async Task<IResult> HandleStreamRunLogs(
return Results.NotFound();
}
return Results.Stream(async (stream, ct) =>
return Results.Stream(async stream =>
{
await foreach (var entry in logStore.ReadAsync(runId, ct).ConfigureAwait(false))
await foreach (var entry in logStore.ReadAsync(runId, cancellationToken).ConfigureAwait(false))
{
await RunLogMapper.WriteAsync(stream, entry, ct).ConfigureAwait(false);
await RunLogMapper.WriteAsync(stream, entry, cancellationToken).ConfigureAwait(false);
}
}, "application/x-ndjson");
}

View File

@@ -16,11 +16,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
</ItemGroup>

View File

@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Worker
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{552E7C8A-74F6-4E33-B956-46DF96E2BE11}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Client", "StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj", "{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +85,18 @@ Global
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x64.Build.0 = Release|Any CPU
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.ActiveCfg = Release|Any CPU
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.Build.0 = Release|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x64.ActiveCfg = Debug|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x64.Build.0 = Debug|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x86.ActiveCfg = Debug|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x86.Build.0 = Debug|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|Any CPU.Build.0 = Release|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x64.ActiveCfg = Release|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x64.Build.0 = Release|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x86.ActiveCfg = Release|Any CPU
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE