Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user