consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,32 @@
# Task Runner Service ??? Agent Charter
## Mission
Execute Task Packs safely and deterministically. Provide remote pack execution, approvals, logging, artifact capture, and policy gates in support of Epic???12, honoring the imposed rule to propagate similar work where needed.
## Responsibilities
- Validate Task Packs, enforce RBAC/approvals, orchestrate steps, manage artifacts/logs, stream status.
- Integrate with JobEngine, Authority, Policy Engine, Export Center, Notifications, and CLI.
- Guarantee reproducible runs, provenance manifests, and secure handling of secrets and networks.
## Module Layout
- `StellaOps.TaskRunner.Core/` ??? execution engine, step DSL, policy gates.
- `StellaOps.TaskRunner.Infrastructure/` ??? storage adapters, artifact handling, external clients.
- `StellaOps.TaskRunner.WebService/` ??? run management APIs and simulation endpoints.
- `StellaOps.TaskRunner.Worker/` ??? background executors, approvals, and telemetry loops.
- `StellaOps.TaskRunner.Tests/` ??? unit tests for core/infrastructure code paths.
- `StellaOps.TaskRunner.sln` ??? module solution.
## Required Reading
- `docs/modules/platform/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/taskrunner/architecture.md`
- `docs-archived/product/advisories/27-Nov-2025-superseded/28-Nov-2025 - Task Pack Orchestration and Automation.md`
- `docs/modules/packs-registry/guides/spec.md`, `docs/modules/packs-registry/guides/authoring-guide.md`, `docs/modules/packs-registry/guides/runbook.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations; enforce plan-hash binding for every run.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change; sync sprint Decisions/Risks when advisory-driven changes land.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
# StellaOps.TaskRunner.Client Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/StellaOps.TaskRunner.Client.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,576 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using System.Text;
using System.Text.Json;
namespace StellaOps.TaskRunner.Core.Attestation;
/// <summary>
/// Service for generating and verifying pack run attestations.
/// Per TASKRUN-OBS-54-001.
/// </summary>
public interface IPackRunAttestationService
{
/// <summary>
/// Generates an attestation for a pack run.
/// </summary>
Task<PackRunAttestationResult> GenerateAsync(
PackRunAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a pack run attestation.
/// </summary>
Task<PackRunAttestationVerificationResult> VerifyAsync(
PackRunAttestationVerificationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an attestation by ID.
/// </summary>
Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestations for a run.
/// </summary>
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the DSSE envelope for an attestation.
/// </summary>
Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for pack run attestations.
/// </summary>
public interface IPackRunAttestationStore
{
/// <summary>
/// Stores an attestation.
/// </summary>
Task StoreAsync(
PackRunAttestation attestation,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an attestation by ID.
/// </summary>
Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestations for a run.
/// </summary>
Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates attestation status.
/// </summary>
Task UpdateStatusAsync(
Guid attestationId,
PackRunAttestationStatus status,
string? error = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Signing provider for pack run attestations.
/// </summary>
public interface IPackRunAttestationSigner
{
/// <summary>
/// Signs an in-toto statement.
/// </summary>
Task<PackRunDsseEnvelope> SignAsync(
byte[] statementBytes,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE envelope signature.
/// </summary>
Task<bool> VerifyAsync(
PackRunDsseEnvelope envelope,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current signing key ID.
/// </summary>
string GetKeyId();
}
/// <summary>
/// Default implementation of pack run attestation service.
/// </summary>
public sealed class PackRunAttestationService : IPackRunAttestationService
{
private readonly IPackRunAttestationStore _store;
private readonly IPackRunAttestationSigner? _signer;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunAttestationService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PackRunAttestationService(
IPackRunAttestationStore store,
ILogger<PackRunAttestationService> logger,
IPackRunAttestationSigner? signer = null,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_signer = signer;
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<PackRunAttestationResult> GenerateAsync(
PackRunAttestationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
// Build provenance predicate
var buildDefinition = new PackRunBuildDefinition(
BuildType: "https://stellaops.io/pack-run/v1",
ExternalParameters: request.ExternalParameters,
InternalParameters: new Dictionary<string, object>
{
["planHash"] = request.PlanHash
},
ResolvedDependencies: request.ResolvedDependencies);
var runDetails = new PackRunDetails(
Builder: new PackRunBuilder(
Id: request.BuilderId ?? "https://stellaops.io/task-runner",
Version: new Dictionary<string, string>
{
["stellaops.task-runner"] = GetVersion()
},
BuilderDependencies: null),
Metadata: new PackRunProvMetadata(
InvocationId: request.RunId,
StartedOn: request.StartedAt,
FinishedOn: request.CompletedAt),
Byproducts: null);
var predicate = new PackRunProvenancePredicate(
BuildDefinition: buildDefinition,
RunDetails: runDetails);
var predicateJson = JsonSerializer.Serialize(predicate, JsonOptions);
// Build in-toto statement
var statement = new PackRunInTotoStatement(
Type: InTotoStatementTypes.V1,
Subject: request.Subjects,
PredicateType: PredicateTypes.PackRunProvenance,
Predicate: predicate);
var statementJson = JsonSerializer.Serialize(statement, JsonOptions);
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
// Sign if signer is available
PackRunDsseEnvelope? envelope = null;
PackRunAttestationStatus status = PackRunAttestationStatus.Pending;
string? error = null;
if (_signer is not null)
{
try
{
envelope = await _signer.SignAsync(statementBytes, cancellationToken);
status = PackRunAttestationStatus.Signed;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sign attestation for run {RunId}", request.RunId);
error = ex.Message;
status = PackRunAttestationStatus.Failed;
}
}
// Create attestation record
var attestation = new PackRunAttestation(
AttestationId: Guid.NewGuid(),
TenantId: request.TenantId,
RunId: request.RunId,
PlanHash: request.PlanHash,
CreatedAt: DateTimeOffset.UtcNow,
Subjects: request.Subjects,
PredicateType: PredicateTypes.PackRunProvenance,
PredicateJson: predicateJson,
Envelope: envelope,
Status: status,
Error: error,
EvidenceSnapshotId: request.EvidenceSnapshotId,
Metadata: request.Metadata);
// Store attestation
await _store.StoreAsync(attestation, cancellationToken);
// Emit timeline event
if (_timelineEmitter is not null)
{
var eventType = status == PackRunAttestationStatus.Signed
? PackRunAttestationEventTypes.AttestationCreated
: PackRunAttestationEventTypes.AttestationFailed;
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: request.TenantId,
eventType: eventType,
source: "taskrunner-attestation",
occurredAt: DateTimeOffset.UtcNow,
runId: request.RunId,
planHash: request.PlanHash,
attributes: new Dictionary<string, string>
{
["attestationId"] = attestation.AttestationId.ToString(),
["predicateType"] = attestation.PredicateType,
["subjectCount"] = request.Subjects.Count.ToString(),
["status"] = status.ToString()
},
evidencePointer: envelope is not null
? PackRunEvidencePointer.Attestation(
request.RunId,
envelope.ComputeDigest())
: null),
cancellationToken);
}
_logger.LogInformation(
"Generated attestation {AttestationId} for run {RunId} with {SubjectCount} subjects, status {Status}",
attestation.AttestationId,
request.RunId,
request.Subjects.Count,
status);
return new PackRunAttestationResult(
Success: status != PackRunAttestationStatus.Failed,
Attestation: attestation,
Error: error);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate attestation for run {RunId}", request.RunId);
return new PackRunAttestationResult(
Success: false,
Attestation: null,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PackRunAttestationVerificationResult> VerifyAsync(
PackRunAttestationVerificationRequest request,
CancellationToken cancellationToken = default)
{
var errors = new List<string>();
var signatureStatus = PackRunSignatureVerificationStatus.NotVerified;
var subjectStatus = PackRunSubjectVerificationStatus.NotVerified;
var revocationStatus = PackRunRevocationStatus.NotChecked;
var attestation = await _store.GetAsync(request.AttestationId, cancellationToken);
if (attestation is null)
{
return new PackRunAttestationVerificationResult(
Valid: false,
AttestationId: request.AttestationId,
SignatureStatus: PackRunSignatureVerificationStatus.NotVerified,
SubjectStatus: PackRunSubjectVerificationStatus.NotVerified,
RevocationStatus: PackRunRevocationStatus.NotChecked,
Errors: ["Attestation not found"],
VerifiedAt: DateTimeOffset.UtcNow);
}
// Verify signature
if (request.VerifySignature && attestation.Envelope is not null && _signer is not null)
{
try
{
var signatureValid = await _signer.VerifyAsync(attestation.Envelope, cancellationToken);
signatureStatus = signatureValid
? PackRunSignatureVerificationStatus.Valid
: PackRunSignatureVerificationStatus.Invalid;
if (!signatureValid)
{
errors.Add("Signature verification failed");
}
}
catch (Exception ex)
{
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
errors.Add($"Signature verification error: {ex.Message}");
}
}
else if (request.VerifySignature && attestation.Envelope is null)
{
signatureStatus = PackRunSignatureVerificationStatus.Invalid;
errors.Add("No envelope available for signature verification");
}
// Verify subjects
if (request.VerifySubjects && request.ExpectedSubjects is not null)
{
var expectedSet = request.ExpectedSubjects
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
.ToHashSet();
var actualSet = attestation.Subjects
.Select(s => $"{s.Name}:{string.Join(",", s.Digest.OrderBy(d => d.Key).Select(d => $"{d.Key}={d.Value}"))}")
.ToHashSet();
if (expectedSet.SetEquals(actualSet))
{
subjectStatus = PackRunSubjectVerificationStatus.Match;
}
else if (expectedSet.IsSubsetOf(actualSet))
{
subjectStatus = PackRunSubjectVerificationStatus.Match;
}
else
{
var missing = expectedSet.Except(actualSet).ToList();
if (missing.Count > 0)
{
subjectStatus = PackRunSubjectVerificationStatus.Missing;
errors.Add($"Missing subjects: {string.Join(", ", missing)}");
}
else
{
subjectStatus = PackRunSubjectVerificationStatus.Mismatch;
errors.Add("Subject digest mismatch");
}
}
}
// Check revocation
if (request.CheckRevocation)
{
revocationStatus = attestation.Status == PackRunAttestationStatus.Revoked
? PackRunRevocationStatus.Revoked
: PackRunRevocationStatus.NotRevoked;
if (attestation.Status == PackRunAttestationStatus.Revoked)
{
errors.Add("Attestation has been revoked");
}
}
var valid = errors.Count == 0 &&
(signatureStatus is PackRunSignatureVerificationStatus.Valid or PackRunSignatureVerificationStatus.NotVerified) &&
(subjectStatus is PackRunSubjectVerificationStatus.Match or PackRunSubjectVerificationStatus.NotVerified) &&
(revocationStatus is PackRunRevocationStatus.NotRevoked or PackRunRevocationStatus.NotChecked);
return new PackRunAttestationVerificationResult(
Valid: valid,
AttestationId: request.AttestationId,
SignatureStatus: signatureStatus,
SubjectStatus: subjectStatus,
RevocationStatus: revocationStatus,
Errors: errors.Count > 0 ? errors : null,
VerifiedAt: DateTimeOffset.UtcNow);
}
/// <inheritdoc />
public Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
=> _store.GetAsync(attestationId, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
=> _store.ListByRunAsync(tenantId, runId, cancellationToken);
/// <inheritdoc />
public async Task<PackRunDsseEnvelope?> GetEnvelopeAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
{
var attestation = await _store.GetAsync(attestationId, cancellationToken);
return attestation?.Envelope;
}
private static string GetVersion()
{
var assembly = typeof(PackRunAttestationService).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "0.0.0";
}
}
/// <summary>
/// Attestation event types for timeline.
/// </summary>
public static class PackRunAttestationEventTypes
{
/// <summary>Attestation created successfully.</summary>
public const string AttestationCreated = "pack.attestation.created";
/// <summary>Attestation creation failed.</summary>
public const string AttestationFailed = "pack.attestation.failed";
/// <summary>Attestation verified.</summary>
public const string AttestationVerified = "pack.attestation.verified";
/// <summary>Attestation verification failed.</summary>
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
/// <summary>Attestation revoked.</summary>
public const string AttestationRevoked = "pack.attestation.revoked";
}
/// <summary>
/// In-memory attestation store for testing.
/// </summary>
public sealed class InMemoryPackRunAttestationStore : IPackRunAttestationStore
{
private readonly Dictionary<Guid, PackRunAttestation> _attestations = new();
private readonly object _lock = new();
/// <inheritdoc />
public Task StoreAsync(
PackRunAttestation attestation,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_attestations[attestation.AttestationId] = attestation;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PackRunAttestation?> GetAsync(
Guid attestationId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_attestations.TryGetValue(attestationId, out var attestation);
return Task.FromResult(attestation);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<PackRunAttestation>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _attestations.Values
.Where(a => a.TenantId == tenantId && a.RunId == runId)
.OrderBy(a => a.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunAttestation>>(results);
}
}
/// <inheritdoc />
public Task UpdateStatusAsync(
Guid attestationId,
PackRunAttestationStatus status,
string? error = null,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_attestations.TryGetValue(attestationId, out var attestation))
{
_attestations[attestationId] = attestation with
{
Status = status,
Error = error
};
}
}
return Task.CompletedTask;
}
/// <summary>Gets all attestations (for testing).</summary>
public IReadOnlyList<PackRunAttestation> GetAll()
{
lock (_lock) { return _attestations.Values.ToList(); }
}
/// <summary>Clears all attestations (for testing).</summary>
public void Clear()
{
lock (_lock) { _attestations.Clear(); }
}
/// <summary>Gets attestation count.</summary>
public int Count
{
get { lock (_lock) { return _attestations.Count; } }
}
}
/// <summary>
/// Stub signer for testing (does not perform real cryptographic signing).
/// </summary>
public sealed class StubPackRunAttestationSigner : IPackRunAttestationSigner
{
private readonly string _keyId;
public StubPackRunAttestationSigner(string keyId = "test-key-001")
{
_keyId = keyId;
}
/// <inheritdoc />
public Task<PackRunDsseEnvelope> SignAsync(
byte[] statementBytes,
CancellationToken cancellationToken = default)
{
var payload = Convert.ToBase64String(statementBytes);
// Create stub signature (not cryptographically valid)
var sigBytes = System.Security.Cryptography.SHA256.HashData(statementBytes);
var sig = Convert.ToBase64String(sigBytes);
var envelope = new PackRunDsseEnvelope(
PayloadType: PackRunDsseEnvelope.InTotoPayloadType,
Payload: payload,
Signatures: [new PackRunDsseSignature(_keyId, sig)]);
return Task.FromResult(envelope);
}
/// <inheritdoc />
public Task<bool> VerifyAsync(
PackRunDsseEnvelope envelope,
CancellationToken cancellationToken = default)
{
// Stub always returns true for testing
return Task.FromResult(true);
}
/// <inheritdoc />
public string GetKeyId() => _keyId;
}

View File

@@ -0,0 +1,526 @@
using StellaOps.TaskRunner.Core.Evidence;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Attestation;
/// <summary>
/// DSSE attestation for pack run execution.
/// Per TASKRUN-OBS-54-001.
/// </summary>
public sealed record PackRunAttestation(
/// <summary>Unique attestation identifier.</summary>
Guid AttestationId,
/// <summary>Tenant scope.</summary>
string TenantId,
/// <summary>Run ID this attestation covers.</summary>
string RunId,
/// <summary>Plan hash that was executed.</summary>
string PlanHash,
/// <summary>When the attestation was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Subjects covered by this attestation (produced artifacts).</summary>
IReadOnlyList<PackRunAttestationSubject> Subjects,
/// <summary>Predicate type URI.</summary>
string PredicateType,
/// <summary>Predicate content as JSON.</summary>
string PredicateJson,
/// <summary>DSSE envelope containing signature.</summary>
PackRunDsseEnvelope? Envelope,
/// <summary>Attestation status.</summary>
PackRunAttestationStatus Status,
/// <summary>Error message if signing failed.</summary>
string? Error,
/// <summary>Reference to evidence snapshot.</summary>
Guid? EvidenceSnapshotId,
/// <summary>Attestation metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Computes the canonical statement digest.
/// </summary>
public string ComputeStatementDigest()
{
var statement = new PackRunInTotoStatement(
Type: InTotoStatementTypes.V01,
Subject: Subjects,
PredicateType: PredicateType,
Predicate: JsonSerializer.Deserialize<JsonElement>(PredicateJson, JsonOptions));
var json = JsonSerializer.Serialize(statement, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Deserializes from JSON.
/// </summary>
public static PackRunAttestation? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunAttestation>(json, JsonOptions);
}
/// <summary>
/// Attestation status.
/// </summary>
public enum PackRunAttestationStatus
{
/// <summary>Attestation is pending signing.</summary>
Pending,
/// <summary>Attestation is signed and valid.</summary>
Signed,
/// <summary>Attestation signing failed.</summary>
Failed,
/// <summary>Attestation signature was revoked.</summary>
Revoked
}
/// <summary>
/// Subject covered by attestation (an artifact).
/// </summary>
public sealed record PackRunAttestationSubject(
/// <summary>Subject name (artifact path or identifier).</summary>
[property: JsonPropertyName("name")]
string Name,
/// <summary>Subject digest (sha256 -> hash).</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string> Digest)
{
/// <summary>
/// Creates a subject from an artifact reference.
/// </summary>
public static PackRunAttestationSubject FromArtifact(PackRunArtifactReference artifact)
{
var digest = new Dictionary<string, string>();
// Parse sha256:abcdef format and extract just the hash
if (artifact.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
digest["sha256"] = artifact.Sha256[7..];
}
else
{
digest["sha256"] = artifact.Sha256;
}
return new PackRunAttestationSubject(artifact.Name, digest);
}
/// <summary>
/// Creates a subject from a material.
/// </summary>
public static PackRunAttestationSubject FromMaterial(PackRunEvidenceMaterial material)
{
var digest = new Dictionary<string, string>();
if (material.Sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
digest["sha256"] = material.Sha256[7..];
}
else
{
digest["sha256"] = material.Sha256;
}
return new PackRunAttestationSubject(material.CanonicalPath, digest);
}
}
/// <summary>
/// In-toto statement wrapper for pack runs.
/// </summary>
public sealed record PackRunInTotoStatement(
/// <summary>Statement type (always _type).</summary>
[property: JsonPropertyName("_type")]
string Type,
/// <summary>Subjects covered.</summary>
[property: JsonPropertyName("subject")]
IReadOnlyList<PackRunAttestationSubject> Subject,
/// <summary>Predicate type URI.</summary>
[property: JsonPropertyName("predicateType")]
string PredicateType,
/// <summary>Predicate content.</summary>
[property: JsonPropertyName("predicate")]
object Predicate);
/// <summary>
/// Standard in-toto statement type URIs.
/// </summary>
public static class InTotoStatementTypes
{
/// <summary>In-toto statement v0.1.</summary>
public const string V01 = "https://in-toto.io/Statement/v0.1";
/// <summary>In-toto statement v1.0.</summary>
public const string V1 = "https://in-toto.io/Statement/v1";
}
/// <summary>
/// Standard predicate type URIs.
/// </summary>
public static class PredicateTypes
{
/// <summary>SLSA Provenance v0.2.</summary>
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
/// <summary>SLSA Provenance v1.0.</summary>
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
/// <summary>StellaOps Pack Run provenance.</summary>
public const string PackRunProvenance = "https://stellaops.io/attestation/pack-run/v1";
/// <summary>StellaOps Pack Run completion.</summary>
public const string PackRunCompletion = "https://stellaops.io/attestation/pack-run-completion/v1";
}
/// <summary>
/// DSSE envelope for pack run attestation.
/// </summary>
public sealed record PackRunDsseEnvelope(
/// <summary>Payload type (usually application/vnd.in-toto+json).</summary>
[property: JsonPropertyName("payloadType")]
string PayloadType,
/// <summary>Base64-encoded payload.</summary>
[property: JsonPropertyName("payload")]
string Payload,
/// <summary>Signatures on the envelope.</summary>
[property: JsonPropertyName("signatures")]
IReadOnlyList<PackRunDsseSignature> Signatures)
{
/// <summary>Standard payload type for in-toto attestations.</summary>
public const string InTotoPayloadType = "application/vnd.in-toto+json";
/// <summary>
/// Computes the envelope digest.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Signature in a DSSE envelope.
/// </summary>
public sealed record PackRunDsseSignature(
/// <summary>Key identifier.</summary>
[property: JsonPropertyName("keyid")]
string? KeyId,
/// <summary>Base64-encoded signature.</summary>
[property: JsonPropertyName("sig")]
string Sig);
/// <summary>
/// Pack run provenance predicate per SLSA Provenance v1.
/// </summary>
public sealed record PackRunProvenancePredicate(
/// <summary>Build definition describing what was run.</summary>
[property: JsonPropertyName("buildDefinition")]
PackRunBuildDefinition BuildDefinition,
/// <summary>Run details describing the actual execution.</summary>
[property: JsonPropertyName("runDetails")]
PackRunDetails RunDetails);
/// <summary>
/// Build definition for pack run provenance.
/// </summary>
public sealed record PackRunBuildDefinition(
/// <summary>Build type identifier.</summary>
[property: JsonPropertyName("buildType")]
string BuildType,
/// <summary>External parameters (e.g., pack manifest URL).</summary>
[property: JsonPropertyName("externalParameters")]
IReadOnlyDictionary<string, object>? ExternalParameters,
/// <summary>Internal parameters resolved during build.</summary>
[property: JsonPropertyName("internalParameters")]
IReadOnlyDictionary<string, object>? InternalParameters,
/// <summary>Dependencies resolved during build.</summary>
[property: JsonPropertyName("resolvedDependencies")]
IReadOnlyList<PackRunDependency>? ResolvedDependencies);
/// <summary>
/// Resolved dependency in provenance.
/// </summary>
public sealed record PackRunDependency(
/// <summary>Dependency URI.</summary>
[property: JsonPropertyName("uri")]
string Uri,
/// <summary>Dependency digest.</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string>? Digest,
/// <summary>Dependency name.</summary>
[property: JsonPropertyName("name")]
string? Name,
/// <summary>Media type.</summary>
[property: JsonPropertyName("mediaType")]
string? MediaType);
/// <summary>
/// Run details for pack run provenance.
/// </summary>
public sealed record PackRunDetails(
/// <summary>Builder information.</summary>
[property: JsonPropertyName("builder")]
PackRunBuilder Builder,
/// <summary>Run metadata.</summary>
[property: JsonPropertyName("metadata")]
PackRunProvMetadata Metadata,
/// <summary>By-products of the run.</summary>
[property: JsonPropertyName("byproducts")]
IReadOnlyList<PackRunByproduct>? Byproducts);
/// <summary>
/// Builder information.
/// </summary>
public sealed record PackRunBuilder(
/// <summary>Builder ID (URI).</summary>
[property: JsonPropertyName("id")]
string Id,
/// <summary>Builder version.</summary>
[property: JsonPropertyName("version")]
IReadOnlyDictionary<string, string>? Version,
/// <summary>Builder dependencies.</summary>
[property: JsonPropertyName("builderDependencies")]
IReadOnlyList<PackRunDependency>? BuilderDependencies);
/// <summary>
/// Provenance metadata.
/// </summary>
public sealed record PackRunProvMetadata(
/// <summary>Invocation ID.</summary>
[property: JsonPropertyName("invocationId")]
string? InvocationId,
/// <summary>When the build started.</summary>
[property: JsonPropertyName("startedOn")]
DateTimeOffset? StartedOn,
/// <summary>When the build finished.</summary>
[property: JsonPropertyName("finishedOn")]
DateTimeOffset? FinishedOn);
/// <summary>
/// By-product of the build.
/// </summary>
public sealed record PackRunByproduct(
/// <summary>By-product URI.</summary>
[property: JsonPropertyName("uri")]
string? Uri,
/// <summary>By-product digest.</summary>
[property: JsonPropertyName("digest")]
IReadOnlyDictionary<string, string>? Digest,
/// <summary>By-product name.</summary>
[property: JsonPropertyName("name")]
string? Name,
/// <summary>By-product media type.</summary>
[property: JsonPropertyName("mediaType")]
string? MediaType);
/// <summary>
/// Request to generate an attestation for a pack run.
/// </summary>
public sealed record PackRunAttestationRequest(
/// <summary>Run ID to attest.</summary>
string RunId,
/// <summary>Tenant ID.</summary>
string TenantId,
/// <summary>Plan hash.</summary>
string PlanHash,
/// <summary>Subjects (artifacts) to attest.</summary>
IReadOnlyList<PackRunAttestationSubject> Subjects,
/// <summary>Evidence snapshot ID to link.</summary>
Guid? EvidenceSnapshotId,
/// <summary>Run started at.</summary>
DateTimeOffset StartedAt,
/// <summary>Run completed at.</summary>
DateTimeOffset? CompletedAt,
/// <summary>Builder ID.</summary>
string? BuilderId,
/// <summary>External parameters.</summary>
IReadOnlyDictionary<string, object>? ExternalParameters,
/// <summary>Resolved dependencies.</summary>
IReadOnlyList<PackRunDependency>? ResolvedDependencies,
/// <summary>Additional metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Result of attestation generation.
/// </summary>
public sealed record PackRunAttestationResult(
/// <summary>Whether attestation generation succeeded.</summary>
bool Success,
/// <summary>Generated attestation.</summary>
PackRunAttestation? Attestation,
/// <summary>Error message if failed.</summary>
string? Error);
/// <summary>
/// Request to verify a pack run attestation.
/// </summary>
public sealed record PackRunAttestationVerificationRequest(
/// <summary>Attestation ID to verify.</summary>
Guid AttestationId,
/// <summary>Expected subjects to verify against.</summary>
IReadOnlyList<PackRunAttestationSubject>? ExpectedSubjects,
/// <summary>Whether to verify signature.</summary>
bool VerifySignature,
/// <summary>Whether to verify subjects match.</summary>
bool VerifySubjects,
/// <summary>Whether to check revocation status.</summary>
bool CheckRevocation);
/// <summary>
/// Result of attestation verification.
/// </summary>
public sealed record PackRunAttestationVerificationResult(
/// <summary>Whether verification passed.</summary>
bool Valid,
/// <summary>Attestation that was verified.</summary>
Guid AttestationId,
/// <summary>Signature verification status.</summary>
PackRunSignatureVerificationStatus SignatureStatus,
/// <summary>Subject verification status.</summary>
PackRunSubjectVerificationStatus SubjectStatus,
/// <summary>Revocation status.</summary>
PackRunRevocationStatus RevocationStatus,
/// <summary>Verification errors.</summary>
IReadOnlyList<string>? Errors,
/// <summary>When verification was performed.</summary>
DateTimeOffset VerifiedAt);
/// <summary>
/// Signature verification status.
/// </summary>
public enum PackRunSignatureVerificationStatus
{
/// <summary>Not verified.</summary>
NotVerified,
/// <summary>Signature is valid.</summary>
Valid,
/// <summary>Signature is invalid.</summary>
Invalid,
/// <summary>Key not found.</summary>
KeyNotFound,
/// <summary>Key expired.</summary>
KeyExpired
}
/// <summary>
/// Subject verification status.
/// </summary>
public enum PackRunSubjectVerificationStatus
{
/// <summary>Not verified.</summary>
NotVerified,
/// <summary>All subjects match.</summary>
Match,
/// <summary>Subjects do not match.</summary>
Mismatch,
/// <summary>Missing expected subjects.</summary>
Missing
}
/// <summary>
/// Revocation status.
/// </summary>
public enum PackRunRevocationStatus
{
/// <summary>Not checked.</summary>
NotChecked,
/// <summary>Not revoked.</summary>
NotRevoked,
/// <summary>Revoked.</summary>
Revoked,
/// <summary>Revocation check failed.</summary>
CheckFailed
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.TaskRunner.Core.Configuration;
/// <summary>
/// Worker configuration for queue paths, artifacts, and execution persistence.
/// Kept in Core so infrastructure helpers can share deterministic paths without
/// referencing the worker assembly.
/// </summary>
public sealed class PackRunWorkerOptions
{
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromSeconds(1);
public string QueuePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue");
public string ArchivePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue", "archive");
public string ApprovalStorePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "approvals");
public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs");
public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts");
public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs");
}

View File

@@ -0,0 +1,196 @@
namespace StellaOps.TaskRunner.Core.Events;
/// <summary>
/// Sink for pack run timeline events (Kafka, NATS, file, etc.).
/// Per TASKRUN-OBS-52-001.
/// </summary>
public interface IPackRunTimelineEventSink
{
/// <summary>
/// Writes a timeline event to the sink.
/// </summary>
Task<PackRunTimelineSinkWriteResult> WriteAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default);
/// <summary>
/// Writes multiple timeline events to the sink.
/// </summary>
Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of writing to pack run timeline sink.
/// </summary>
public sealed record PackRunTimelineSinkWriteResult(
/// <summary>Whether the event was written successfully.</summary>
bool Success,
/// <summary>Assigned sequence number if applicable.</summary>
long? Sequence,
/// <summary>Whether the event was deduplicated.</summary>
bool Deduplicated,
/// <summary>Error message if write failed.</summary>
string? Error);
/// <summary>
/// Result of batch writing to pack run timeline sink.
/// </summary>
public sealed record PackRunTimelineSinkBatchWriteResult(
/// <summary>Number of events written successfully.</summary>
int Written,
/// <summary>Number of events deduplicated.</summary>
int Deduplicated,
/// <summary>Number of events that failed.</summary>
int Failed);
/// <summary>
/// In-memory pack run timeline event sink for testing.
/// </summary>
public sealed class InMemoryPackRunTimelineEventSink : IPackRunTimelineEventSink
{
private readonly List<PackRunTimelineEvent> _events = new();
private readonly HashSet<Guid> _seenIds = new();
private readonly object _lock = new();
private long _sequence;
public Task<PackRunTimelineSinkWriteResult> WriteAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_seenIds.Add(evt.EventId))
{
return Task.FromResult(new PackRunTimelineSinkWriteResult(
Success: true,
Sequence: null,
Deduplicated: true,
Error: null));
}
var seq = ++_sequence;
var eventWithSeq = evt.WithSequence(seq);
_events.Add(eventWithSeq);
return Task.FromResult(new PackRunTimelineSinkWriteResult(
Success: true,
Sequence: seq,
Deduplicated: false,
Error: null));
}
}
public Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default)
{
var written = 0;
var deduplicated = 0;
lock (_lock)
{
foreach (var evt in events)
{
if (!_seenIds.Add(evt.EventId))
{
deduplicated++;
continue;
}
var seq = ++_sequence;
_events.Add(evt.WithSequence(seq));
written++;
}
}
return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(written, deduplicated, 0));
}
/// <summary>Gets all events (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEvents()
{
lock (_lock) { return _events.ToList(); }
}
/// <summary>Gets events for a tenant (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEvents(string tenantId)
{
lock (_lock) { return _events.Where(e => e.TenantId == tenantId).ToList(); }
}
/// <summary>Gets events for a run (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEventsForRun(string runId)
{
lock (_lock) { return _events.Where(e => e.RunId == runId).ToList(); }
}
/// <summary>Gets events by type (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetEventsByType(string eventType)
{
lock (_lock) { return _events.Where(e => e.EventType == eventType).ToList(); }
}
/// <summary>Gets step events for a run (for testing).</summary>
public IReadOnlyList<PackRunTimelineEvent> GetStepEvents(string runId, string stepId)
{
lock (_lock)
{
return _events
.Where(e => e.RunId == runId && e.StepId == stepId)
.ToList();
}
}
/// <summary>Clears all events (for testing).</summary>
public void Clear()
{
lock (_lock)
{
_events.Clear();
_seenIds.Clear();
_sequence = 0;
}
}
/// <summary>Gets the current event count.</summary>
public int Count
{
get { lock (_lock) { return _events.Count; } }
}
}
/// <summary>
/// Null sink that discards all events.
/// </summary>
public sealed class NullPackRunTimelineEventSink : IPackRunTimelineEventSink
{
public static NullPackRunTimelineEventSink Instance { get; } = new();
private NullPackRunTimelineEventSink() { }
public Task<PackRunTimelineSinkWriteResult> WriteAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new PackRunTimelineSinkWriteResult(
Success: true,
Sequence: null,
Deduplicated: false,
Error: null));
}
public Task<PackRunTimelineSinkBatchWriteResult> WriteBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default)
{
var count = events.Count();
return Task.FromResult(new PackRunTimelineSinkBatchWriteResult(count, 0, 0));
}
}

View File

@@ -0,0 +1,347 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Events;
/// <summary>
/// Timeline event for pack run audit trail, observability, and evidence chain tracking.
/// Per TASKRUN-OBS-52-001 and timeline-event.schema.json.
/// </summary>
public sealed record PackRunTimelineEvent(
/// <summary>Monotonically increasing sequence number for ordering.</summary>
long? EventSeq,
/// <summary>Globally unique event identifier.</summary>
Guid EventId,
/// <summary>Tenant scope for multi-tenant isolation.</summary>
string TenantId,
/// <summary>Event type identifier following namespace convention.</summary>
string EventType,
/// <summary>Service or component that emitted this event.</summary>
string Source,
/// <summary>When the event actually occurred.</summary>
DateTimeOffset OccurredAt,
/// <summary>When the event was received by timeline indexer.</summary>
DateTimeOffset? ReceivedAt,
/// <summary>Correlation ID linking related events across services.</summary>
string? CorrelationId,
/// <summary>OpenTelemetry trace ID for distributed tracing.</summary>
string? TraceId,
/// <summary>OpenTelemetry span ID within the trace.</summary>
string? SpanId,
/// <summary>User, service account, or system that triggered the event.</summary>
string? Actor,
/// <summary>Event severity level.</summary>
PackRunEventSeverity Severity,
/// <summary>Key-value attributes for filtering and querying.</summary>
IReadOnlyDictionary<string, string>? Attributes,
/// <summary>SHA-256 hash of the raw payload for integrity.</summary>
string? PayloadHash,
/// <summary>Original event payload as JSON string.</summary>
string? RawPayloadJson,
/// <summary>Canonicalized JSON for deterministic hashing.</summary>
string? NormalizedPayloadJson,
/// <summary>Reference to associated evidence bundle or attestation.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Run ID for this pack run.</summary>
string RunId,
/// <summary>Plan hash for the pack run.</summary>
string? PlanHash,
/// <summary>Step ID if this event is associated with a step.</summary>
string? StepId,
/// <summary>Project ID scope within tenant.</summary>
string? ProjectId)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new timeline event with generated ID.
/// </summary>
public static PackRunTimelineEvent Create(
string tenantId,
string eventType,
string source,
DateTimeOffset occurredAt,
string runId,
string? planHash = null,
string? stepId = null,
string? actor = null,
PackRunEventSeverity severity = PackRunEventSeverity.Info,
IReadOnlyDictionary<string, string>? attributes = null,
string? correlationId = null,
string? traceId = null,
string? spanId = null,
string? projectId = null,
object? payload = null,
PackRunEvidencePointer? evidencePointer = null)
{
string? rawPayload = null;
string? normalizedPayload = null;
string? payloadHash = null;
if (payload is not null)
{
rawPayload = JsonSerializer.Serialize(payload, JsonOptions);
normalizedPayload = NormalizeJson(rawPayload);
payloadHash = ComputeHash(normalizedPayload);
}
return new PackRunTimelineEvent(
EventSeq: null,
EventId: Guid.NewGuid(),
TenantId: tenantId,
EventType: eventType,
Source: source,
OccurredAt: occurredAt,
ReceivedAt: null,
CorrelationId: correlationId,
TraceId: traceId,
SpanId: spanId,
Actor: actor,
Severity: severity,
Attributes: attributes,
PayloadHash: payloadHash,
RawPayloadJson: rawPayload,
NormalizedPayloadJson: normalizedPayload,
EvidencePointer: evidencePointer,
RunId: runId,
PlanHash: planHash,
StepId: stepId,
ProjectId: projectId);
}
/// <summary>
/// Serializes the event to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Parses a timeline event from JSON.
/// </summary>
public static PackRunTimelineEvent? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunTimelineEvent>(json, JsonOptions);
/// <summary>
/// Creates a copy with received timestamp set.
/// </summary>
public PackRunTimelineEvent WithReceivedAt(DateTimeOffset receivedAt)
=> this with { ReceivedAt = receivedAt };
/// <summary>
/// Creates a copy with sequence number set.
/// </summary>
public PackRunTimelineEvent WithSequence(long seq)
=> this with { EventSeq = seq };
/// <summary>
/// Generates an idempotency key for this event.
/// </summary>
public string GenerateIdempotencyKey()
=> $"timeline:pack:{TenantId}:{EventType}:{EventId}";
private static string NormalizeJson(string json)
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, CanonicalJsonOptions);
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Event severity level for pack run timeline events.
/// </summary>
public enum PackRunEventSeverity
{
Debug,
Info,
Warning,
Error,
Critical
}
/// <summary>
/// Reference to associated evidence bundle or attestation.
/// </summary>
public sealed record PackRunEvidencePointer(
/// <summary>Type of evidence being referenced.</summary>
PackRunEvidencePointerType Type,
/// <summary>Evidence bundle identifier.</summary>
Guid? BundleId,
/// <summary>Content digest of the evidence bundle.</summary>
string? BundleDigest,
/// <summary>Subject URI for the attestation.</summary>
string? AttestationSubject,
/// <summary>Digest of the attestation envelope.</summary>
string? AttestationDigest,
/// <summary>URI to the evidence manifest.</summary>
string? ManifestUri,
/// <summary>Path within evidence locker storage.</summary>
string? LockerPath)
{
/// <summary>
/// Creates a bundle evidence pointer.
/// </summary>
public static PackRunEvidencePointer Bundle(Guid bundleId, string? bundleDigest = null)
=> new(PackRunEvidencePointerType.Bundle, bundleId, bundleDigest, null, null, null, null);
/// <summary>
/// Creates an attestation evidence pointer.
/// </summary>
public static PackRunEvidencePointer Attestation(string subject, string? digest = null)
=> new(PackRunEvidencePointerType.Attestation, null, null, subject, digest, null, null);
/// <summary>
/// Creates a manifest evidence pointer.
/// </summary>
public static PackRunEvidencePointer Manifest(string uri, string? lockerPath = null)
=> new(PackRunEvidencePointerType.Manifest, null, null, null, null, uri, lockerPath);
/// <summary>
/// Creates an artifact evidence pointer.
/// </summary>
public static PackRunEvidencePointer Artifact(string lockerPath, string? digest = null)
=> new(PackRunEvidencePointerType.Artifact, null, digest, null, null, null, lockerPath);
}
/// <summary>
/// Type of evidence being referenced.
/// </summary>
public enum PackRunEvidencePointerType
{
Bundle,
Attestation,
Manifest,
Artifact
}
/// <summary>
/// Pack run timeline event types.
/// </summary>
public static class PackRunEventTypes
{
/// <summary>Prefix for all pack run events.</summary>
public const string Prefix = "pack.";
/// <summary>Pack run started.</summary>
public const string PackStarted = "pack.started";
/// <summary>Pack run completed successfully.</summary>
public const string PackCompleted = "pack.completed";
/// <summary>Pack run failed.</summary>
public const string PackFailed = "pack.failed";
/// <summary>Pack run paused (awaiting approvals/gates).</summary>
public const string PackPaused = "pack.paused";
/// <summary>Step started execution.</summary>
public const string StepStarted = "pack.step.started";
/// <summary>Step completed successfully.</summary>
public const string StepCompleted = "pack.step.completed";
/// <summary>Step failed.</summary>
public const string StepFailed = "pack.step.failed";
/// <summary>Step scheduled for retry.</summary>
public const string StepRetryScheduled = "pack.step.retry_scheduled";
/// <summary>Step skipped.</summary>
public const string StepSkipped = "pack.step.skipped";
/// <summary>Approval gate satisfied.</summary>
public const string ApprovalSatisfied = "pack.approval.satisfied";
/// <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>Attestation created successfully (per TASKRUN-OBS-54-001).</summary>
public const string AttestationCreated = "pack.attestation.created";
/// <summary>Attestation creation failed.</summary>
public const string AttestationFailed = "pack.attestation.failed";
/// <summary>Attestation verified successfully.</summary>
public const string AttestationVerified = "pack.attestation.verified";
/// <summary>Attestation verification failed.</summary>
public const string AttestationVerificationFailed = "pack.attestation.verification_failed";
/// <summary>Attestation was revoked.</summary>
public const string AttestationRevoked = "pack.attestation.revoked";
/// <summary>Incident mode activated (per TASKRUN-OBS-55-001).</summary>
public const string IncidentModeActivated = "pack.incident.activated";
/// <summary>Incident mode deactivated.</summary>
public const string IncidentModeDeactivated = "pack.incident.deactivated";
/// <summary>Incident mode escalated to higher level.</summary>
public const string IncidentModeEscalated = "pack.incident.escalated";
/// <summary>SLO breach detected triggering incident mode.</summary>
public const string SloBreachDetected = "pack.incident.slo_breach";
/// <summary>Checks if the event type is a pack run event.</summary>
public static bool IsPackRunEvent(string eventType) =>
eventType.StartsWith(Prefix, StringComparison.Ordinal);
}

View File

@@ -0,0 +1,603 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Events;
/// <summary>
/// Service for emitting pack run timeline events with trace IDs, deduplication, and retries.
/// Per TASKRUN-OBS-52-001.
/// </summary>
public interface IPackRunTimelineEventEmitter
{
/// <summary>
/// Emits a timeline event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits multiple timeline events in batch.
/// </summary>
Task<PackRunTimelineBatchEmitResult> EmitBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.started event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitPackStartedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.completed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitPackCompletedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.failed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitPackFailedAsync(
string tenantId,
string runId,
string planHash,
string? failureReason = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.step.started event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitStepStartedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.step.completed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitStepCompletedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
double? durationMs = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits a pack.step.failed event.
/// </summary>
Task<PackRunTimelineEmitResult> EmitStepFailedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? error = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of timeline event emission.
/// </summary>
public sealed record PackRunTimelineEmitResult(
/// <summary>Whether the event was emitted successfully.</summary>
bool Success,
/// <summary>The emitted event (with sequence if assigned).</summary>
PackRunTimelineEvent Event,
/// <summary>Whether the event was deduplicated.</summary>
bool Deduplicated,
/// <summary>Error message if emission failed.</summary>
string? Error);
/// <summary>
/// Result of batch timeline event emission.
/// </summary>
public sealed record PackRunTimelineBatchEmitResult(
/// <summary>Number of events emitted successfully.</summary>
int Emitted,
/// <summary>Number of events deduplicated.</summary>
int Deduplicated,
/// <summary>Number of events that failed.</summary>
int Failed,
/// <summary>Errors encountered.</summary>
IReadOnlyList<string> Errors)
{
/// <summary>Total events processed.</summary>
public int Total => Emitted + Deduplicated + Failed;
/// <summary>Whether any events were emitted.</summary>
public bool HasEmitted => Emitted > 0;
/// <summary>Whether any errors occurred.</summary>
public bool HasErrors => Failed > 0 || Errors.Count > 0;
/// <summary>Creates an empty result.</summary>
public static PackRunTimelineBatchEmitResult Empty => new(0, 0, 0, []);
}
/// <summary>
/// Default implementation of pack run timeline event emitter.
/// </summary>
public sealed class PackRunTimelineEventEmitter : IPackRunTimelineEventEmitter
{
private const string Source = "taskrunner-worker";
private readonly IPackRunTimelineEventSink _sink;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PackRunTimelineEventEmitter> _logger;
private readonly PackRunTimelineEmitterOptions _options;
public PackRunTimelineEventEmitter(
IPackRunTimelineEventSink sink,
TimeProvider timeProvider,
ILogger<PackRunTimelineEventEmitter> logger,
PackRunTimelineEmitterOptions? options = null)
{
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? PackRunTimelineEmitterOptions.Default;
}
public async Task<PackRunTimelineEmitResult> EmitAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
var eventWithReceived = evt.WithReceivedAt(_timeProvider.GetUtcNow());
try
{
var result = await EmitWithRetryAsync(eventWithReceived, cancellationToken);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to emit timeline event {EventId} type {EventType} for tenant {TenantId} run {RunId}",
evt.EventId, evt.EventType, evt.TenantId, evt.RunId);
return new PackRunTimelineEmitResult(
Success: false,
Event: eventWithReceived,
Deduplicated: false,
Error: ex.Message);
}
}
public async Task<PackRunTimelineBatchEmitResult> EmitBatchAsync(
IEnumerable<PackRunTimelineEvent> events,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
var emitted = 0;
var deduplicated = 0;
var failed = 0;
var errors = new List<string>();
// Order by occurredAt then eventId for deterministic fan-out
var ordered = events
.OrderBy(e => e.OccurredAt)
.ThenBy(e => e.EventId)
.ToList();
foreach (var evt in ordered)
{
var result = await EmitAsync(evt, cancellationToken);
if (result.Success)
{
if (result.Deduplicated)
deduplicated++;
else
emitted++;
}
else
{
failed++;
if (result.Error is not null)
errors.Add($"{evt.EventId}: {result.Error}");
}
}
return new PackRunTimelineBatchEmitResult(emitted, deduplicated, failed, errors);
}
public Task<PackRunTimelineEmitResult> EmitPackStartedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash
});
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.PackStarted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitPackCompletedAsync(
string tenantId,
string runId,
string planHash,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash
});
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.PackCompleted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitPackFailedAsync(
string tenantId,
string runId,
string planHash,
string? failureReason = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrDict = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash
};
if (!string.IsNullOrWhiteSpace(failureReason))
{
attrDict["failureReason"] = failureReason;
}
var attrs = MergeAttributes(attributes, attrDict);
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.PackFailed,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
actor: actor,
severity: PackRunEventSeverity.Error,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: failureReason != null ? new { reason = failureReason } : null,
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitStepStartedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = stepId,
["attempt"] = attempt.ToString()
});
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.StepStarted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
stepId: stepId,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: new { stepId, attempt });
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitStepCompletedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
double? durationMs = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
PackRunEvidencePointer? evidencePointer = null,
CancellationToken cancellationToken = default)
{
var attrDict = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = stepId,
["attempt"] = attempt.ToString()
};
if (durationMs.HasValue)
{
attrDict["durationMs"] = durationMs.Value.ToString("F2");
}
var attrs = MergeAttributes(attributes, attrDict);
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.StepCompleted,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
stepId: stepId,
actor: actor,
severity: PackRunEventSeverity.Info,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: new { stepId, attempt, durationMs },
evidencePointer: evidencePointer);
return EmitAsync(evt, cancellationToken);
}
public Task<PackRunTimelineEmitResult> EmitStepFailedAsync(
string tenantId,
string runId,
string planHash,
string stepId,
int attempt,
string? error = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var attrDict = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = stepId,
["attempt"] = attempt.ToString()
};
if (!string.IsNullOrWhiteSpace(error))
{
attrDict["error"] = error;
}
var attrs = MergeAttributes(attributes, attrDict);
var evt = PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: PackRunEventTypes.StepFailed,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
planHash: planHash,
stepId: stepId,
actor: actor,
severity: PackRunEventSeverity.Error,
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
projectId: projectId,
payload: new { stepId, attempt, error });
return EmitAsync(evt, cancellationToken);
}
private async Task<PackRunTimelineEmitResult> EmitWithRetryAsync(
PackRunTimelineEvent evt,
CancellationToken cancellationToken)
{
var attempt = 0;
var delay = _options.RetryDelay;
while (true)
{
try
{
var sinkResult = await _sink.WriteAsync(evt, cancellationToken);
if (sinkResult.Deduplicated)
{
_logger.LogDebug(
"Timeline event {EventId} deduplicated",
evt.EventId);
return new PackRunTimelineEmitResult(
Success: true,
Event: evt,
Deduplicated: true,
Error: null);
}
_logger.LogInformation(
"Emitted timeline event {EventId} type {EventType} tenant {TenantId} run {RunId} seq {Seq}",
evt.EventId, evt.EventType, evt.TenantId, evt.RunId, sinkResult.Sequence);
return new PackRunTimelineEmitResult(
Success: true,
Event: sinkResult.Sequence.HasValue ? evt.WithSequence(sinkResult.Sequence.Value) : evt,
Deduplicated: false,
Error: null);
}
catch (Exception ex) when (attempt < _options.MaxRetries && IsTransient(ex))
{
attempt++;
_logger.LogWarning(ex,
"Transient failure emitting timeline event {EventId}, attempt {Attempt}/{MaxRetries}",
evt.EventId, attempt, _options.MaxRetries);
await Task.Delay(delay, cancellationToken);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
}
}
private static IReadOnlyDictionary<string, string> MergeAttributes(
IReadOnlyDictionary<string, string>? existing,
Dictionary<string, string> additional)
{
if (existing is null || existing.Count == 0)
return additional;
var merged = new Dictionary<string, string>(existing);
foreach (var (key, value) in additional)
{
merged.TryAdd(key, value);
}
return merged;
}
private static bool IsTransient(Exception ex)
{
return ex is TimeoutException or
TaskCanceledException or
System.Net.Http.HttpRequestException or
System.IO.IOException;
}
}
/// <summary>
/// Options for pack run timeline event emitter.
/// </summary>
public sealed record PackRunTimelineEmitterOptions(
/// <summary>Maximum retry attempts for transient failures.</summary>
int MaxRetries,
/// <summary>Base delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Whether to include evidence pointers.</summary>
bool IncludeEvidencePointers)
{
/// <summary>Default emitter options.</summary>
public static PackRunTimelineEmitterOptions Default => new(
MaxRetries: 3,
RetryDelay: TimeSpan.FromSeconds(1),
IncludeEvidencePointers: true);
}

View File

@@ -0,0 +1,243 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Evidence for bundle import operations.
/// Per TASKRUN-AIRGAP-58-001.
/// </summary>
public sealed record BundleImportEvidence(
/// <summary>Unique import job identifier.</summary>
string JobId,
/// <summary>Tenant that initiated the import.</summary>
string TenantId,
/// <summary>Bundle source path or URL.</summary>
string SourcePath,
/// <summary>When the import started.</summary>
DateTimeOffset StartedAt,
/// <summary>When the import completed.</summary>
DateTimeOffset? CompletedAt,
/// <summary>Final status of the import.</summary>
BundleImportStatus Status,
/// <summary>Error message if failed.</summary>
string? ErrorMessage,
/// <summary>Actor who initiated the import.</summary>
string? InitiatedBy,
/// <summary>Input bundle manifest.</summary>
BundleImportInputManifest? InputManifest,
/// <summary>Output files with hashes.</summary>
IReadOnlyList<BundleImportOutputFile> OutputFiles,
/// <summary>Import transcript log entries.</summary>
IReadOnlyList<BundleImportTranscriptEntry> Transcript,
/// <summary>Validation results.</summary>
BundleImportValidationResult? ValidationResult,
/// <summary>Computed hashes for evidence chain.</summary>
BundleImportHashChain HashChain);
/// <summary>
/// Bundle import status.
/// </summary>
public enum BundleImportStatus
{
/// <summary>Import is pending.</summary>
Pending,
/// <summary>Import is in progress.</summary>
InProgress,
/// <summary>Import completed successfully.</summary>
Completed,
/// <summary>Import failed.</summary>
Failed,
/// <summary>Import was cancelled.</summary>
Cancelled,
/// <summary>Import is partially complete.</summary>
PartiallyComplete
}
/// <summary>
/// Input bundle manifest from the import source.
/// </summary>
public sealed record BundleImportInputManifest(
/// <summary>Bundle format version.</summary>
string FormatVersion,
/// <summary>Bundle identifier.</summary>
string BundleId,
/// <summary>Bundle version.</summary>
string BundleVersion,
/// <summary>When the bundle was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Who created the bundle.</summary>
string? CreatedBy,
/// <summary>Total size in bytes.</summary>
long TotalSizeBytes,
/// <summary>Number of items in the bundle.</summary>
int ItemCount,
/// <summary>SHA-256 of the manifest.</summary>
string ManifestSha256,
/// <summary>Bundle signature if present.</summary>
string? Signature,
/// <summary>Signature verification status.</summary>
bool? SignatureValid);
/// <summary>
/// Output file from bundle import.
/// </summary>
public sealed record BundleImportOutputFile(
/// <summary>Relative path within staging directory.</summary>
string RelativePath,
/// <summary>SHA-256 hash of the file.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType,
/// <summary>When the file was staged.</summary>
DateTimeOffset StagedAt,
/// <summary>Source item identifier in the bundle.</summary>
string? SourceItemId);
/// <summary>
/// Transcript entry for bundle import.
/// </summary>
public sealed record BundleImportTranscriptEntry(
/// <summary>When the entry was recorded.</summary>
DateTimeOffset Timestamp,
/// <summary>Log level.</summary>
string Level,
/// <summary>Event type.</summary>
string EventType,
/// <summary>Message.</summary>
string Message,
/// <summary>Additional data.</summary>
IReadOnlyDictionary<string, string>? Data);
/// <summary>
/// Bundle import validation result.
/// </summary>
public sealed record BundleImportValidationResult(
/// <summary>Whether validation passed.</summary>
bool Valid,
/// <summary>Checksum verification passed.</summary>
bool ChecksumValid,
/// <summary>Signature verification passed.</summary>
bool? SignatureValid,
/// <summary>Format validation passed.</summary>
bool FormatValid,
/// <summary>Validation errors.</summary>
IReadOnlyList<string>? Errors,
/// <summary>Validation warnings.</summary>
IReadOnlyList<string>? Warnings);
/// <summary>
/// Hash chain for bundle import evidence.
/// </summary>
public sealed record BundleImportHashChain(
/// <summary>Hash of all input files.</summary>
string InputsHash,
/// <summary>Hash of all output files.</summary>
string OutputsHash,
/// <summary>Hash of the transcript.</summary>
string TranscriptHash,
/// <summary>Combined root hash.</summary>
string RootHash,
/// <summary>Algorithm used.</summary>
string Algorithm)
{
/// <summary>
/// Computes hash chain from import evidence data.
/// </summary>
public static BundleImportHashChain Compute(
BundleImportInputManifest? input,
IReadOnlyList<BundleImportOutputFile> outputs,
IReadOnlyList<BundleImportTranscriptEntry> transcript)
{
// Compute input hash
var inputJson = input is not null
? JsonSerializer.Serialize(input, JsonOptions)
: "null";
var inputsHash = ComputeSha256(inputJson);
// Compute outputs hash (sorted for determinism)
var sortedOutputs = outputs
.OrderBy(o => o.RelativePath, StringComparer.Ordinal)
.Select(o => o.Sha256)
.ToList();
var outputsJson = JsonSerializer.Serialize(sortedOutputs, JsonOptions);
var outputsHash = ComputeSha256(outputsJson);
// Compute transcript hash
var transcriptJson = JsonSerializer.Serialize(transcript, JsonOptions);
var transcriptHash = ComputeSha256(transcriptJson);
// Compute root hash
var combined = $"{inputsHash}|{outputsHash}|{transcriptHash}";
var rootHash = ComputeSha256(combined);
return new BundleImportHashChain(
InputsHash: inputsHash,
OutputsHash: outputsHash,
TranscriptHash: transcriptHash,
RootHash: rootHash,
Algorithm: "sha256");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static string ComputeSha256(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,383 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using System.Globalization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Service for capturing bundle import evidence.
/// Per TASKRUN-AIRGAP-58-001.
/// </summary>
public interface IBundleImportEvidenceService
{
/// <summary>
/// Captures evidence for a bundle import operation.
/// </summary>
Task<BundleImportEvidenceResult> CaptureAsync(
BundleImportEvidence evidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports evidence to a portable bundle format.
/// </summary>
Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
string jobId,
string outputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evidence for a bundle import job.
/// </summary>
Task<BundleImportEvidence?> GetAsync(
string jobId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of capturing bundle import evidence.
/// </summary>
public sealed record BundleImportEvidenceResult(
/// <summary>Whether capture was successful.</summary>
bool Success,
/// <summary>The captured snapshot.</summary>
PackRunEvidenceSnapshot? Snapshot,
/// <summary>Evidence pointer for linking.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Error message if capture failed.</summary>
string? Error);
/// <summary>
/// Result of exporting to portable bundle.
/// </summary>
public sealed record PortableEvidenceBundleResult(
/// <summary>Whether export was successful.</summary>
bool Success,
/// <summary>Path to the exported bundle.</summary>
string? OutputPath,
/// <summary>SHA-256 of the bundle.</summary>
string? BundleSha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Error message if export failed.</summary>
string? Error);
/// <summary>
/// Default implementation of bundle import evidence service.
/// </summary>
public sealed class BundleImportEvidenceService : IBundleImportEvidenceService
{
private readonly IPackRunEvidenceStore _store;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<BundleImportEvidenceService> _logger;
public BundleImportEvidenceService(
IPackRunEvidenceStore store,
ILogger<BundleImportEvidenceService> logger,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<BundleImportEvidenceResult> CaptureAsync(
BundleImportEvidence evidence,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evidence);
try
{
var materials = new List<PackRunEvidenceMaterial>();
// Add input manifest
if (evidence.InputManifest is not null)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"input",
"manifest.json",
evidence.InputManifest,
new Dictionary<string, string>
{
["bundleId"] = evidence.InputManifest.BundleId,
["bundleVersion"] = evidence.InputManifest.BundleVersion
}));
}
// Add output files as materials
foreach (var output in evidence.OutputFiles)
{
materials.Add(new PackRunEvidenceMaterial(
Section: "output",
Path: output.RelativePath,
Sha256: output.Sha256,
SizeBytes: output.SizeBytes,
MediaType: output.MediaType,
Attributes: new Dictionary<string, string>
{
["stagedAt"] = output.StagedAt.ToString("O", CultureInfo.InvariantCulture)
}));
}
// Add transcript
materials.Add(PackRunEvidenceMaterial.FromJson(
"transcript",
"import-log.json",
evidence.Transcript));
// Add validation result
if (evidence.ValidationResult is not null)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"validation",
"result.json",
evidence.ValidationResult));
}
// Add hash chain
materials.Add(PackRunEvidenceMaterial.FromJson(
"hashchain",
"chain.json",
evidence.HashChain));
// Create metadata
var metadata = new Dictionary<string, string>
{
["jobId"] = evidence.JobId,
["status"] = evidence.Status.ToString(),
["sourcePath"] = evidence.SourcePath,
["startedAt"] = evidence.StartedAt.ToString("O", CultureInfo.InvariantCulture),
["outputCount"] = evidence.OutputFiles.Count.ToString(),
["rootHash"] = evidence.HashChain.RootHash
};
if (evidence.CompletedAt.HasValue)
{
metadata["completedAt"] = evidence.CompletedAt.Value.ToString("O", CultureInfo.InvariantCulture);
metadata["durationMs"] = ((evidence.CompletedAt.Value - evidence.StartedAt).TotalMilliseconds).ToString("F0");
}
if (!string.IsNullOrWhiteSpace(evidence.InitiatedBy))
{
metadata["initiatedBy"] = evidence.InitiatedBy;
}
// Create snapshot
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId: evidence.TenantId,
runId: evidence.JobId,
planHash: evidence.HashChain.RootHash,
kind: PackRunEvidenceSnapshotKind.BundleImport,
materials: materials,
metadata: metadata);
// Store snapshot
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
// Emit timeline event
if (_timelineEmitter is not null)
{
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: evidence.TenantId,
eventType: "bundle.import.evidence_captured",
source: "taskrunner-bundle-import",
occurredAt: DateTimeOffset.UtcNow,
runId: evidence.JobId,
planHash: evidence.HashChain.RootHash,
attributes: new Dictionary<string, string>
{
["snapshotId"] = snapshot.SnapshotId.ToString(),
["rootHash"] = snapshot.RootHash,
["status"] = evidence.Status.ToString(),
["outputCount"] = evidence.OutputFiles.Count.ToString()
},
evidencePointer: evidencePointer),
cancellationToken);
}
_logger.LogInformation(
"Captured bundle import evidence for job {JobId} with {OutputCount} outputs, root hash {RootHash}",
evidence.JobId,
evidence.OutputFiles.Count,
evidence.HashChain.RootHash);
return new BundleImportEvidenceResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to capture bundle import evidence for job {JobId}", evidence.JobId);
return new BundleImportEvidenceResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PortableEvidenceBundleResult> ExportToPortableBundleAsync(
string jobId,
string outputPath,
CancellationToken cancellationToken = default)
{
try
{
// Get all snapshots for this job
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
if (snapshots.Count == 0)
{
return new PortableEvidenceBundleResult(
Success: false,
OutputPath: null,
BundleSha256: null,
SizeBytes: 0,
Error: $"No evidence found for job {jobId}");
}
// Create portable bundle structure
var bundleManifest = new PortableEvidenceBundleManifest
{
Version = "1.0.0",
CreatedAt = DateTimeOffset.UtcNow,
JobId = jobId,
SnapshotCount = snapshots.Count,
Snapshots = snapshots.Select(s => new PortableSnapshotReference
{
SnapshotId = s.SnapshotId,
Kind = s.Kind.ToString(),
RootHash = s.RootHash,
CreatedAt = s.CreatedAt,
MaterialCount = s.Materials.Count
}).ToList()
};
// Serialize bundle
var bundleJson = System.Text.Json.JsonSerializer.Serialize(new
{
manifest = bundleManifest,
snapshots = snapshots
}, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = true
});
// Write to file
await File.WriteAllTextAsync(outputPath, bundleJson, cancellationToken);
var fileInfo = new FileInfo(outputPath);
// Compute bundle hash
var bundleBytes = await File.ReadAllBytesAsync(outputPath, cancellationToken);
var hash = System.Security.Cryptography.SHA256.HashData(bundleBytes);
var bundleSha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
_logger.LogInformation(
"Exported portable evidence bundle for job {JobId} to {OutputPath}, size {SizeBytes} bytes",
jobId,
outputPath,
fileInfo.Length);
return new PortableEvidenceBundleResult(
Success: true,
OutputPath: outputPath,
BundleSha256: bundleSha256,
SizeBytes: fileInfo.Length,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export portable evidence bundle for job {JobId}", jobId);
return new PortableEvidenceBundleResult(
Success: false,
OutputPath: null,
BundleSha256: null,
SizeBytes: 0,
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<BundleImportEvidence?> GetAsync(
string jobId,
CancellationToken cancellationToken = default)
{
var snapshots = await _store.GetByRunIdAsync(jobId, cancellationToken);
var importSnapshot = snapshots.FirstOrDefault(s => s.Kind == PackRunEvidenceSnapshotKind.BundleImport);
if (importSnapshot is null)
{
return null;
}
// Reconstruct evidence from snapshot
return ReconstructEvidence(importSnapshot);
}
private static BundleImportEvidence? ReconstructEvidence(PackRunEvidenceSnapshot snapshot)
{
// This would deserialize the stored materials back into the evidence structure
// For now, return a minimal reconstruction from metadata
var metadata = snapshot.Metadata ?? new Dictionary<string, string>();
return new BundleImportEvidence(
JobId: metadata.GetValueOrDefault("jobId", snapshot.RunId),
TenantId: snapshot.TenantId,
SourcePath: metadata.GetValueOrDefault("sourcePath", "unknown"),
StartedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("startedAt"), out var started)
? started : snapshot.CreatedAt,
CompletedAt: DateTimeOffset.TryParse(metadata.GetValueOrDefault("completedAt"), out var completed)
? completed : null,
Status: Enum.TryParse<BundleImportStatus>(metadata.GetValueOrDefault("status"), out var status)
? status : BundleImportStatus.Completed,
ErrorMessage: null,
InitiatedBy: metadata.GetValueOrDefault("initiatedBy"),
InputManifest: null,
OutputFiles: [],
Transcript: [],
ValidationResult: null,
HashChain: new BundleImportHashChain(
InputsHash: "sha256:reconstructed",
OutputsHash: "sha256:reconstructed",
TranscriptHash: "sha256:reconstructed",
RootHash: metadata.GetValueOrDefault("rootHash", snapshot.RootHash),
Algorithm: "sha256"));
}
private sealed class PortableEvidenceBundleManifest
{
public required string Version { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string JobId { get; init; }
public required int SnapshotCount { get; init; }
public required IReadOnlyList<PortableSnapshotReference> Snapshots { get; init; }
}
private sealed class PortableSnapshotReference
{
public required Guid SnapshotId { get; init; }
public required string Kind { get; init; }
public required string RootHash { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required int MaterialCount { get; init; }
}
}

View File

@@ -0,0 +1,504 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TaskRunner.Core.Execution;
using System.Globalization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Service for capturing pack run evidence snapshots.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public interface IPackRunEvidenceSnapshotService
{
/// <summary>
/// Captures a run completion snapshot with all materials.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CaptureRunCompletionAsync(
string tenantId,
string runId,
string planHash,
PackRunState state,
IReadOnlyList<PackRunStepTranscript>? transcripts = null,
IReadOnlyList<PackRunApprovalEvidence>? approvals = null,
IReadOnlyList<PackRunPolicyEvidence>? policyEvaluations = null,
PackRunEnvironmentDigest? environmentDigest = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Captures a step execution snapshot.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CaptureStepExecutionAsync(
string tenantId,
string runId,
string planHash,
PackRunStepTranscript transcript,
CancellationToken cancellationToken = default);
/// <summary>
/// Captures an approval decision snapshot.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CaptureApprovalDecisionAsync(
string tenantId,
string runId,
string planHash,
PackRunApprovalEvidence approval,
CancellationToken cancellationToken = default);
/// <summary>
/// Captures a policy evaluation snapshot.
/// </summary>
Task<PackRunEvidenceSnapshotResult> CapturePolicyEvaluationAsync(
string tenantId,
string runId,
string planHash,
PackRunPolicyEvidence evaluation,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of evidence snapshot capture.
/// </summary>
public sealed record PackRunEvidenceSnapshotResult(
/// <summary>Whether capture was successful.</summary>
bool Success,
/// <summary>The captured snapshot.</summary>
PackRunEvidenceSnapshot? Snapshot,
/// <summary>Evidence pointer for timeline events.</summary>
PackRunEvidencePointer? EvidencePointer,
/// <summary>Error message if capture failed.</summary>
string? Error);
/// <summary>
/// Default implementation of evidence snapshot service.
/// </summary>
public sealed class PackRunEvidenceSnapshotService : IPackRunEvidenceSnapshotService
{
private readonly IPackRunEvidenceStore _store;
private readonly IPackRunRedactionGuard _redactionGuard;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunEvidenceSnapshotService> _logger;
private readonly PackRunEvidenceSnapshotOptions _options;
public PackRunEvidenceSnapshotService(
IPackRunEvidenceStore store,
IPackRunRedactionGuard redactionGuard,
ILogger<PackRunEvidenceSnapshotService> logger,
IPackRunTimelineEventEmitter? timelineEmitter = null,
PackRunEvidenceSnapshotOptions? options = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_redactionGuard = redactionGuard ?? throw new ArgumentNullException(nameof(redactionGuard));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timelineEmitter = timelineEmitter;
_options = options ?? PackRunEvidenceSnapshotOptions.Default;
}
public async Task<PackRunEvidenceSnapshotResult> CaptureRunCompletionAsync(
string tenantId,
string runId,
string planHash,
PackRunState state,
IReadOnlyList<PackRunStepTranscript>? transcripts = null,
IReadOnlyList<PackRunApprovalEvidence>? approvals = null,
IReadOnlyList<PackRunPolicyEvidence>? policyEvaluations = null,
PackRunEnvironmentDigest? environmentDigest = null,
CancellationToken cancellationToken = default)
{
try
{
var materials = new List<PackRunEvidenceMaterial>();
// Add state summary
var stateSummary = CreateStateSummary(state);
materials.Add(PackRunEvidenceMaterial.FromJson(
"summary",
"run-state.json",
stateSummary));
// Add transcripts (redacted)
if (transcripts is not null)
{
foreach (var transcript in transcripts)
{
var redacted = _redactionGuard.RedactTranscript(transcript);
materials.Add(PackRunEvidenceMaterial.FromJson(
"transcript",
$"{redacted.StepId}.json",
redacted,
new Dictionary<string, string> { ["stepId"] = redacted.StepId }));
}
}
// Add approvals (redacted)
if (approvals is not null)
{
foreach (var approval in approvals)
{
var redacted = _redactionGuard.RedactApproval(approval);
materials.Add(PackRunEvidenceMaterial.FromJson(
"approval",
$"{redacted.ApprovalId}.json",
redacted,
new Dictionary<string, string> { ["approvalId"] = redacted.ApprovalId }));
}
}
// Add policy evaluations
if (policyEvaluations is not null)
{
foreach (var evaluation in policyEvaluations)
{
materials.Add(PackRunEvidenceMaterial.FromJson(
"policy",
$"{evaluation.PolicyName}.json",
evaluation,
new Dictionary<string, string> { ["policyName"] = evaluation.PolicyName }));
}
}
// Add environment digest (redacted)
if (environmentDigest is not null)
{
var redacted = _redactionGuard.RedactEnvironment(environmentDigest);
materials.Add(PackRunEvidenceMaterial.FromJson(
"environment",
"digest.json",
redacted));
}
// Create snapshot
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepCount"] = state.Steps.Count.ToString(),
["capturedAt"] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.RunCompletion,
materials,
metadata);
// Store snapshot
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
// Emit timeline event if emitter available
if (_timelineEmitter is not null)
{
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: "pack.evidence.captured",
source: "taskrunner-evidence",
occurredAt: DateTimeOffset.UtcNow,
runId: runId,
planHash: planHash,
attributes: new Dictionary<string, string>
{
["snapshotId"] = snapshot.SnapshotId.ToString(),
["rootHash"] = snapshot.RootHash,
["materialCount"] = materials.Count.ToString()
},
evidencePointer: evidencePointer),
cancellationToken);
}
_logger.LogInformation(
"Captured run completion evidence for run {RunId} with {MaterialCount} materials, root hash {RootHash}",
runId, materials.Count, snapshot.RootHash);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture run completion evidence for run {RunId}",
runId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
public async Task<PackRunEvidenceSnapshotResult> CaptureStepExecutionAsync(
string tenantId,
string runId,
string planHash,
PackRunStepTranscript transcript,
CancellationToken cancellationToken = default)
{
try
{
var redacted = _redactionGuard.RedactTranscript(transcript);
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromJson(
"transcript",
$"{redacted.StepId}.json",
redacted,
new Dictionary<string, string> { ["stepId"] = redacted.StepId })
};
// Add artifacts if present
if (redacted.Artifacts is not null)
{
foreach (var artifact in redacted.Artifacts)
{
materials.Add(new PackRunEvidenceMaterial(
Section: "artifact",
Path: artifact.Name,
Sha256: artifact.Sha256,
SizeBytes: artifact.SizeBytes,
MediaType: artifact.MediaType,
Attributes: new Dictionary<string, string> { ["stepId"] = redacted.StepId }));
}
}
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["stepId"] = transcript.StepId,
["status"] = transcript.Status,
["attempt"] = transcript.Attempt.ToString()
};
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.StepExecution,
materials,
metadata);
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
_logger.LogDebug(
"Captured step execution evidence for run {RunId} step {StepId}",
runId, transcript.StepId);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture step execution evidence for run {RunId} step {StepId}",
runId, transcript.StepId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
public async Task<PackRunEvidenceSnapshotResult> CaptureApprovalDecisionAsync(
string tenantId,
string runId,
string planHash,
PackRunApprovalEvidence approval,
CancellationToken cancellationToken = default)
{
try
{
var redacted = _redactionGuard.RedactApproval(approval);
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromJson(
"approval",
$"{redacted.ApprovalId}.json",
redacted)
};
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["approvalId"] = approval.ApprovalId,
["decision"] = approval.Decision,
["approver"] = _redactionGuard.RedactIdentity(approval.Approver)
};
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.ApprovalDecision,
materials,
metadata);
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
_logger.LogDebug(
"Captured approval decision evidence for run {RunId} approval {ApprovalId}",
runId, approval.ApprovalId);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture approval decision evidence for run {RunId}",
runId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
public async Task<PackRunEvidenceSnapshotResult> CapturePolicyEvaluationAsync(
string tenantId,
string runId,
string planHash,
PackRunPolicyEvidence evaluation,
CancellationToken cancellationToken = default)
{
try
{
var materials = new List<PackRunEvidenceMaterial>
{
PackRunEvidenceMaterial.FromJson(
"policy",
$"{evaluation.PolicyName}.json",
evaluation)
};
var metadata = new Dictionary<string, string>
{
["runId"] = runId,
["planHash"] = planHash,
["policyName"] = evaluation.PolicyName,
["result"] = evaluation.Result
};
if (evaluation.PolicyVersion is not null)
{
metadata["policyVersion"] = evaluation.PolicyVersion;
}
var snapshot = PackRunEvidenceSnapshot.Create(
tenantId,
runId,
planHash,
PackRunEvidenceSnapshotKind.PolicyEvaluation,
materials,
metadata);
await _store.StoreAsync(snapshot, cancellationToken);
var evidencePointer = PackRunEvidencePointer.Bundle(
snapshot.SnapshotId,
snapshot.RootHash);
_logger.LogDebug(
"Captured policy evaluation evidence for run {RunId} policy {PolicyName}",
runId, evaluation.PolicyName);
return new PackRunEvidenceSnapshotResult(
Success: true,
Snapshot: snapshot,
EvidencePointer: evidencePointer,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to capture policy evaluation evidence for run {RunId}",
runId);
return new PackRunEvidenceSnapshotResult(
Success: false,
Snapshot: null,
EvidencePointer: null,
Error: ex.Message);
}
}
private static object CreateStateSummary(PackRunState state)
{
var stepSummaries = state.Steps.Values.Select(s => new
{
s.StepId,
Kind = s.Kind.ToString(),
s.Enabled,
Status = s.Status.ToString(),
s.Attempts,
s.StatusReason
}).ToList();
return new
{
state.RunId,
state.PlanHash,
state.RequestedAt,
state.CreatedAt,
state.UpdatedAt,
StepCount = state.Steps.Count,
Steps = stepSummaries
};
}
}
/// <summary>
/// Options for evidence snapshot service.
/// </summary>
public sealed record PackRunEvidenceSnapshotOptions(
/// <summary>Maximum transcript output length before truncation.</summary>
int MaxTranscriptOutputLength,
/// <summary>Maximum comment length before truncation.</summary>
int MaxCommentLength,
/// <summary>Whether to include step outputs.</summary>
bool IncludeStepOutput,
/// <summary>Whether to emit timeline events.</summary>
bool EmitTimelineEvents)
{
/// <summary>Default options.</summary>
public static PackRunEvidenceSnapshotOptions Default => new(
MaxTranscriptOutputLength: 64 * 1024, // 64KB
MaxCommentLength: 4096,
IncludeStepOutput: true,
EmitTimelineEvents: true);
}

View File

@@ -0,0 +1,203 @@
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Store for pack run evidence snapshots.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public interface IPackRunEvidenceStore
{
/// <summary>
/// Stores an evidence snapshot.
/// </summary>
Task StoreAsync(
PackRunEvidenceSnapshot snapshot,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves an evidence snapshot by ID.
/// </summary>
Task<PackRunEvidenceSnapshot?> GetAsync(
Guid snapshotId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists evidence snapshots for a run.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evidence snapshots by run ID only (across all tenants).
/// For bundle import evidence lookups.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists evidence snapshots by kind for a run.
/// </summary>
Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,
PackRunEvidenceSnapshotKind kind,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies the integrity of a snapshot by recomputing its Merkle root.
/// </summary>
Task<PackRunEvidenceVerificationResult> VerifyAsync(
Guid snapshotId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of evidence verification.
/// </summary>
public sealed record PackRunEvidenceVerificationResult(
/// <summary>Whether verification passed.</summary>
bool Valid,
/// <summary>The snapshot that was verified.</summary>
Guid SnapshotId,
/// <summary>Expected root hash.</summary>
string ExpectedHash,
/// <summary>Computed root hash.</summary>
string ComputedHash,
/// <summary>Error message if verification failed.</summary>
string? Error);
/// <summary>
/// In-memory evidence store for testing.
/// </summary>
public sealed class InMemoryPackRunEvidenceStore : IPackRunEvidenceStore
{
private readonly Dictionary<Guid, PackRunEvidenceSnapshot> _snapshots = new();
private readonly object _lock = new();
public Task StoreAsync(
PackRunEvidenceSnapshot snapshot,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_snapshots[snapshot.SnapshotId] = snapshot;
}
return Task.CompletedTask;
}
public Task<PackRunEvidenceSnapshot?> GetAsync(
Guid snapshotId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_snapshots.TryGetValue(snapshotId, out var snapshot);
return Task.FromResult(snapshot);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByRunAsync(
string tenantId,
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.TenantId == tenantId && s.RunId == runId)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> GetByRunIdAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.RunId == runId)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<IReadOnlyList<PackRunEvidenceSnapshot>> ListByKindAsync(
string tenantId,
string runId,
PackRunEvidenceSnapshotKind kind,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var results = _snapshots.Values
.Where(s => s.TenantId == tenantId && s.RunId == runId && s.Kind == kind)
.OrderBy(s => s.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<PackRunEvidenceSnapshot>>(results);
}
}
public Task<PackRunEvidenceVerificationResult> VerifyAsync(
Guid snapshotId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_snapshots.TryGetValue(snapshotId, out var snapshot))
{
return Task.FromResult(new PackRunEvidenceVerificationResult(
Valid: false,
SnapshotId: snapshotId,
ExpectedHash: string.Empty,
ComputedHash: string.Empty,
Error: "Snapshot not found"));
}
// Recompute by creating a new snapshot with same materials
var recomputed = PackRunEvidenceSnapshot.Create(
snapshot.TenantId,
snapshot.RunId,
snapshot.PlanHash,
snapshot.Kind,
snapshot.Materials,
snapshot.Metadata);
var valid = snapshot.RootHash == recomputed.RootHash;
return Task.FromResult(new PackRunEvidenceVerificationResult(
Valid: valid,
SnapshotId: snapshotId,
ExpectedHash: snapshot.RootHash,
ComputedHash: recomputed.RootHash,
Error: valid ? null : "Root hash mismatch"));
}
}
/// <summary>Gets all snapshots (for testing).</summary>
public IReadOnlyList<PackRunEvidenceSnapshot> GetAll()
{
lock (_lock) { return _snapshots.Values.ToList(); }
}
/// <summary>Clears all snapshots (for testing).</summary>
public void Clear()
{
lock (_lock) { _snapshots.Clear(); }
}
/// <summary>Gets snapshot count.</summary>
public int Count
{
get { lock (_lock) { return _snapshots.Count; } }
}
}

View File

@@ -0,0 +1,270 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Redaction guard for sensitive data in evidence snapshots.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public interface IPackRunRedactionGuard
{
/// <summary>
/// Redacts sensitive data from a step transcript.
/// </summary>
PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript);
/// <summary>
/// Redacts sensitive data from an approval evidence record.
/// </summary>
PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval);
/// <summary>
/// Redacts sensitive data from an environment digest.
/// </summary>
PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest);
/// <summary>
/// Redacts an identity string (e.g., email, username).
/// </summary>
string RedactIdentity(string identity);
/// <summary>
/// Redacts a string value that may contain secrets.
/// </summary>
string RedactValue(string value);
}
/// <summary>
/// Options for redaction guard.
/// </summary>
public sealed record PackRunRedactionGuardOptions(
/// <summary>Patterns that indicate sensitive variable names.</summary>
IReadOnlyList<string> SensitiveVariablePatterns,
/// <summary>Patterns that indicate sensitive content in output.</summary>
IReadOnlyList<string> SensitiveContentPatterns,
/// <summary>Whether to hash redacted values for correlation.</summary>
bool HashRedactedValues,
/// <summary>Maximum length of output before truncation.</summary>
int MaxOutputLength,
/// <summary>Whether to preserve email domain.</summary>
bool PreserveEmailDomain)
{
/// <summary>Default redaction options.</summary>
public static PackRunRedactionGuardOptions Default => new(
SensitiveVariablePatterns: new[]
{
"(?i)password",
"(?i)secret",
"(?i)token",
"(?i)api_key",
"(?i)apikey",
"(?i)auth",
"(?i)credential",
"(?i)private_key",
"(?i)privatekey",
"(?i)access_key",
"(?i)accesskey",
"(?i)connection_string",
"(?i)connectionstring"
},
SensitiveContentPatterns: new[]
{
@"(?i)bearer\s+[a-zA-Z0-9\-_.]+",
@"(?i)basic\s+[a-zA-Z0-9+/=]+",
@"-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----",
@"(?i)password\s*[=:]\s*\S+",
@"(?i)secret\s*[=:]\s*\S+",
@"(?i)token\s*[=:]\s*\S+"
},
HashRedactedValues: true,
MaxOutputLength: 64 * 1024,
PreserveEmailDomain: false);
}
/// <summary>
/// Default implementation of redaction guard.
/// </summary>
public sealed partial class PackRunRedactionGuard : IPackRunRedactionGuard
{
private const string RedactedPlaceholder = "[REDACTED]";
private const string TruncatedSuffix = "...[TRUNCATED]";
private readonly PackRunRedactionGuardOptions _options;
private readonly List<Regex> _sensitiveVarPatterns;
private readonly List<Regex> _sensitiveContentPatterns;
public PackRunRedactionGuard(PackRunRedactionGuardOptions? options = null)
{
_options = options ?? PackRunRedactionGuardOptions.Default;
_sensitiveVarPatterns = _options.SensitiveVariablePatterns
.Select(p => new Regex(p, RegexOptions.Compiled))
.ToList();
_sensitiveContentPatterns = _options.SensitiveContentPatterns
.Select(p => new Regex(p, RegexOptions.Compiled))
.ToList();
}
public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript)
{
var redactedOutput = transcript.Output is not null
? RedactOutput(transcript.Output)
: null;
var redactedError = transcript.Error is not null
? RedactOutput(transcript.Error)
: null;
var redactedEnvDigest = transcript.EnvironmentDigest is not null
? RedactEnvDigestString(transcript.EnvironmentDigest)
: null;
return transcript with
{
Output = redactedOutput,
Error = redactedError,
EnvironmentDigest = redactedEnvDigest
};
}
public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval)
{
var redactedApprover = RedactIdentity(approval.Approver);
var redactedComments = approval.Comments is not null
? RedactOutput(approval.Comments)
: null;
var redactedGrantedBy = approval.GrantedBy?.Select(RedactIdentity).ToList();
return approval with
{
Approver = redactedApprover,
Comments = redactedComments,
GrantedBy = redactedGrantedBy
};
}
public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest)
{
// Seeds are already expected to be redacted or hashed
// Environment variable names are kept, values should not be present
// Tool images are public information
return digest;
}
public string RedactIdentity(string identity)
{
if (string.IsNullOrEmpty(identity))
return identity;
// Check if it's an email
if (identity.Contains('@'))
{
var parts = identity.Split('@');
if (parts.Length == 2)
{
var localPart = parts[0];
var domain = parts[1];
var redactedLocal = localPart.Length <= 2
? RedactedPlaceholder
: $"{localPart[0]}***{localPart[^1]}";
if (_options.PreserveEmailDomain)
{
return $"{redactedLocal}@{domain}";
}
return $"{redactedLocal}@[DOMAIN]";
}
}
// For non-email identities, hash if configured
if (_options.HashRedactedValues)
{
return $"[USER:{ComputeShortHash(identity)}]";
}
return RedactedPlaceholder;
}
public string RedactValue(string value)
{
if (string.IsNullOrEmpty(value))
return value;
if (_options.HashRedactedValues)
{
return $"[HASH:{ComputeShortHash(value)}]";
}
return RedactedPlaceholder;
}
private string RedactOutput(string output)
{
if (string.IsNullOrEmpty(output))
return output;
var result = output;
// Apply content pattern redaction
foreach (var pattern in _sensitiveContentPatterns)
{
result = pattern.Replace(result, match =>
{
if (_options.HashRedactedValues)
{
return $"[REDACTED:{ComputeShortHash(match.Value)}]";
}
return RedactedPlaceholder;
});
}
// Truncate if too long
if (result.Length > _options.MaxOutputLength)
{
result = result[..(_options.MaxOutputLength - TruncatedSuffix.Length)] + TruncatedSuffix;
}
return result;
}
private string RedactEnvDigestString(string digest)
{
// Environment digest is typically already a hash, preserve it
return digest;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
// Return first 8 characters of hex hash
return Convert.ToHexString(hash)[..8].ToLowerInvariant();
}
}
/// <summary>
/// No-op redaction guard for testing (preserves all data).
/// </summary>
public sealed class NoOpPackRunRedactionGuard : IPackRunRedactionGuard
{
public static NoOpPackRunRedactionGuard Instance { get; } = new();
private NoOpPackRunRedactionGuard() { }
public PackRunStepTranscript RedactTranscript(PackRunStepTranscript transcript) => transcript;
public PackRunApprovalEvidence RedactApproval(PackRunApprovalEvidence approval) => approval;
public PackRunEnvironmentDigest RedactEnvironment(PackRunEnvironmentDigest digest) => digest;
public string RedactIdentity(string identity) => identity;
public string RedactValue(string value) => value;
}

View File

@@ -0,0 +1,360 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.Evidence;
/// <summary>
/// Evidence snapshot for pack run execution.
/// Per TASKRUN-OBS-53-001.
/// </summary>
public sealed record PackRunEvidenceSnapshot(
/// <summary>Unique snapshot identifier.</summary>
Guid SnapshotId,
/// <summary>Tenant scope.</summary>
string TenantId,
/// <summary>Run ID this snapshot belongs to.</summary>
string RunId,
/// <summary>Plan hash that was executed.</summary>
string PlanHash,
/// <summary>When the snapshot was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Snapshot kind.</summary>
PackRunEvidenceSnapshotKind Kind,
/// <summary>Materials included in this snapshot.</summary>
IReadOnlyList<PackRunEvidenceMaterial> Materials,
/// <summary>Computed Merkle root hash of all materials.</summary>
string RootHash,
/// <summary>Snapshot metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Creates a new snapshot with computed root hash.
/// </summary>
public static PackRunEvidenceSnapshot Create(
string tenantId,
string runId,
string planHash,
PackRunEvidenceSnapshotKind kind,
IReadOnlyList<PackRunEvidenceMaterial> materials,
IReadOnlyDictionary<string, string>? metadata = null)
{
var rootHash = ComputeMerkleRoot(materials);
return new PackRunEvidenceSnapshot(
SnapshotId: Guid.NewGuid(),
TenantId: tenantId,
RunId: runId,
PlanHash: planHash,
CreatedAt: DateTimeOffset.UtcNow,
Kind: kind,
Materials: materials,
RootHash: rootHash,
Metadata: metadata);
}
/// <summary>
/// Computes Merkle root from materials.
/// </summary>
private static string ComputeMerkleRoot(IReadOnlyList<PackRunEvidenceMaterial> materials)
{
if (materials.Count == 0)
{
// Empty root: 64 zeros
return "sha256:" + new string('0', 64);
}
// Sort materials by canonical path for determinism
var sorted = materials
.OrderBy(m => m.Section, StringComparer.Ordinal)
.ThenBy(m => m.Path, StringComparer.Ordinal)
.ToList();
// Build leaves from material hashes
var leaves = sorted.Select(m => m.Sha256).ToList();
// Compute Merkle root
while (leaves.Count > 1)
{
var nextLevel = new List<string>();
for (var i = 0; i < leaves.Count; i += 2)
{
if (i + 1 < leaves.Count)
{
nextLevel.Add(HashPair(leaves[i], leaves[i + 1]));
}
else
{
nextLevel.Add(HashPair(leaves[i], leaves[i]));
}
}
leaves = nextLevel;
}
return leaves[0];
}
private static string HashPair(string left, string right)
{
var combined = left + right;
var bytes = Encoding.UTF8.GetBytes(combined);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Deserializes from JSON.
/// </summary>
public static PackRunEvidenceSnapshot? FromJson(string json)
=> JsonSerializer.Deserialize<PackRunEvidenceSnapshot>(json, JsonOptions);
}
/// <summary>
/// Kind of pack run evidence snapshot.
/// </summary>
public enum PackRunEvidenceSnapshotKind
{
/// <summary>Run completion snapshot.</summary>
RunCompletion,
/// <summary>Step execution snapshot.</summary>
StepExecution,
/// <summary>Approval decision snapshot.</summary>
ApprovalDecision,
/// <summary>Policy evaluation snapshot.</summary>
PolicyEvaluation,
/// <summary>Artifact manifest snapshot.</summary>
ArtifactManifest,
/// <summary>Environment digest snapshot.</summary>
EnvironmentDigest,
/// <summary>Bundle import snapshot (TASKRUN-AIRGAP-58-001).</summary>
BundleImport
}
/// <summary>
/// Material included in evidence snapshot.
/// </summary>
public sealed record PackRunEvidenceMaterial(
/// <summary>Section (e.g., "transcript", "artifact", "policy").</summary>
string Section,
/// <summary>Path within section.</summary>
string Path,
/// <summary>SHA-256 digest of content.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType,
/// <summary>Custom attributes.</summary>
IReadOnlyDictionary<string, string>? Attributes)
{
/// <summary>
/// Creates material from content bytes.
/// </summary>
public static PackRunEvidenceMaterial FromContent(
string section,
string path,
byte[] content,
string mediaType = "application/octet-stream",
IReadOnlyDictionary<string, string>? attributes = null)
{
var hash = SHA256.HashData(content);
var sha256 = $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
return new PackRunEvidenceMaterial(
Section: section,
Path: path,
Sha256: sha256,
SizeBytes: content.Length,
MediaType: mediaType,
Attributes: attributes);
}
/// <summary>
/// Creates material from string content.
/// </summary>
public static PackRunEvidenceMaterial FromString(
string section,
string path,
string content,
string mediaType = "text/plain",
IReadOnlyDictionary<string, string>? attributes = null)
{
return FromContent(section, path, Encoding.UTF8.GetBytes(content), mediaType, attributes);
}
/// <summary>
/// Creates material from JSON object.
/// </summary>
public static PackRunEvidenceMaterial FromJson<T>(
string section,
string path,
T obj,
IReadOnlyDictionary<string, string>? attributes = null)
{
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
return FromString(section, path, json, "application/json", attributes);
}
/// <summary>
/// Canonical path for ordering.
/// </summary>
public string CanonicalPath => $"{Section}/{Path}";
}
/// <summary>
/// Step transcript for evidence capture.
/// </summary>
public sealed record PackRunStepTranscript(
/// <summary>Step identifier.</summary>
string StepId,
/// <summary>Step kind.</summary>
string Kind,
/// <summary>Execution start time.</summary>
DateTimeOffset StartedAt,
/// <summary>Execution end time.</summary>
DateTimeOffset? EndedAt,
/// <summary>Final status.</summary>
string Status,
/// <summary>Attempt number.</summary>
int Attempt,
/// <summary>Duration in milliseconds.</summary>
double? DurationMs,
/// <summary>Output (redacted if needed).</summary>
string? Output,
/// <summary>Error message (redacted if needed).</summary>
string? Error,
/// <summary>Environment variables digest.</summary>
string? EnvironmentDigest,
/// <summary>Artifacts produced.</summary>
IReadOnlyList<PackRunArtifactReference>? Artifacts);
/// <summary>
/// Reference to artifact in evidence.
/// </summary>
public sealed record PackRunArtifactReference(
/// <summary>Artifact name.</summary>
string Name,
/// <summary>SHA-256 digest.</summary>
string Sha256,
/// <summary>Size in bytes.</summary>
long SizeBytes,
/// <summary>Media type.</summary>
string MediaType);
/// <summary>
/// Approval record for evidence.
/// </summary>
public sealed record PackRunApprovalEvidence(
/// <summary>Approval identifier.</summary>
string ApprovalId,
/// <summary>Approver identity.</summary>
string Approver,
/// <summary>When approved.</summary>
DateTimeOffset ApprovedAt,
/// <summary>Approval decision.</summary>
string Decision,
/// <summary>Required grants.</summary>
IReadOnlyList<string> RequiredGrants,
/// <summary>Granted by.</summary>
IReadOnlyList<string>? GrantedBy,
/// <summary>Comments (redacted if needed).</summary>
string? Comments);
/// <summary>
/// Policy evaluation record for evidence.
/// </summary>
public sealed record PackRunPolicyEvidence(
/// <summary>Policy name.</summary>
string PolicyName,
/// <summary>Policy version.</summary>
string? PolicyVersion,
/// <summary>Evaluation result.</summary>
string Result,
/// <summary>When evaluated.</summary>
DateTimeOffset EvaluatedAt,
/// <summary>Evaluation duration in milliseconds.</summary>
double DurationMs,
/// <summary>Matched rules.</summary>
IReadOnlyList<string>? MatchedRules,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest);
/// <summary>
/// Environment digest for evidence.
/// </summary>
public sealed record PackRunEnvironmentDigest(
/// <summary>When digest was computed.</summary>
DateTimeOffset ComputedAt,
/// <summary>Tool image digests (name -> sha256).</summary>
IReadOnlyDictionary<string, string> ToolImages,
/// <summary>Seed values (redacted).</summary>
IReadOnlyDictionary<string, string>? Seeds,
/// <summary>Environment variables (redacted).</summary>
IReadOnlyList<string>? EnvironmentVariableNames,
/// <summary>Combined digest of all inputs.</summary>
string InputsDigest);

View File

@@ -0,0 +1,10 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunApprovalStore
{
Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken);
Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunArtifactReader
{
Task<IReadOnlyList<PackRunArtifactRecord>> ListAsync(string runId, CancellationToken cancellationToken);
}
public sealed record PackRunArtifactRecord(
string Name,
string Type,
string? SourcePath,
string? StoredPath,
string Status,
string? Notes,
DateTimeOffset CapturedAt,
string? ExpressionJson = null);

View File

@@ -0,0 +1,12 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunArtifactUploader
{
Task UploadAsync(
PackRunExecutionContext context,
PackRunState state,
IReadOnlyList<TaskPackPlanOutput> outputs,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobDispatcher
{
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobScheduler
{
Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.TaskRunner.Core.Execution;
/// <summary>
/// Persists pack run log entries in a deterministic append-only fashion.
/// </summary>
public interface IPackRunLogStore
{
/// <summary>
/// Appends a single log entry to the run log.
/// </summary>
Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken);
/// <summary>
/// Returns the log entries for the specified run in chronological order.
/// </summary>
IAsyncEnumerable<PackRunLogEntry> ReadAsync(string runId, CancellationToken cancellationToken);
/// <summary>
/// Determines whether any log entries exist for the specified run.
/// </summary>
Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken);
}
/// <summary>
/// Represents a single structured log entry emitted during a pack run.
/// </summary>
public sealed record PackRunLogEntry(
DateTimeOffset Timestamp,
string Level,
string EventType,
string Message,
string? StepId,
IReadOnlyDictionary<string, string>? Metadata);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunNotificationPublisher
{
Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken);
Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunProvenanceWriter
{
Task WriteAsync(PackRunExecutionContext context, PackRunState state, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,19 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunStepExecutor
{
Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken);
}
public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null)
{
public static PackRunStepExecutionResult Success() => new(true, null);
public static PackRunStepExecutionResult Failure(string error)
=> new(false, string.IsNullOrWhiteSpace(error) ? "Unknown error" : error);
}

View File

@@ -0,0 +1,178 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalCoordinator
{
private readonly ConcurrentDictionary<string, PackRunApprovalState> approvals;
private readonly IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements;
private PackRunApprovalCoordinator(
IReadOnlyDictionary<string, PackRunApprovalState> approvals,
IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements)
{
this.approvals = new ConcurrentDictionary<string, PackRunApprovalState>(approvals);
this.requirements = requirements;
}
public static PackRunApprovalCoordinator Create(TaskPackPlan plan, DateTimeOffset requestTimestamp)
{
ArgumentNullException.ThrowIfNull(plan);
var requirements = TaskPackPlanInsights
.CollectApprovalRequirements(plan)
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalRequirement(
requirement.ApprovalId,
requirement.Grants.ToImmutableArray(),
requirement.StepIds.ToImmutableArray(),
requirement.Messages.ToImmutableArray(),
requirement.ReasonTemplate),
StringComparer.Ordinal);
var states = requirements.Values
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalState(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.StepIds,
requirement.Messages,
requirement.ReasonTemplate,
requestTimestamp,
PackRunApprovalStatus.Pending),
StringComparer.Ordinal);
return new PackRunApprovalCoordinator(states, requirements);
}
public static PackRunApprovalCoordinator Restore(TaskPackPlan plan, IReadOnlyList<PackRunApprovalState> existingStates, DateTimeOffset requestedAt)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(existingStates);
var coordinator = Create(plan, requestedAt);
foreach (var state in existingStates)
{
coordinator.approvals[state.ApprovalId] = state;
}
return coordinator;
}
public IReadOnlyList<PackRunApprovalState> GetApprovals()
=> approvals.Values
.OrderBy(state => state.ApprovalId, StringComparer.Ordinal)
.ToImmutableArray();
public bool HasPendingApprovals => approvals.Values.Any(state => state.Status == PackRunApprovalStatus.Pending);
public ApprovalActionResult Approve(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Approve(actorId, completedAt, summary));
var shouldResume = approvals.Values.All(state => state.Status == PackRunApprovalStatus.Approved);
return new ApprovalActionResult(updated, shouldResume);
}
public ApprovalActionResult Reject(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Reject(actorId, completedAt, summary));
return new ApprovalActionResult(updated, false);
}
public ApprovalActionResult Expire(string approvalId, DateTimeOffset expiredAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Expire(expiredAt, summary));
return new ApprovalActionResult(updated, false);
}
public IReadOnlyList<ApprovalNotification> BuildNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = TaskPackPlanInsights.CollectApprovalRequirements(plan);
var notifications = new List<ApprovalNotification>(hints.Count);
foreach (var hint in hints)
{
if (!requirements.TryGetValue(hint.ApprovalId, out var requirement))
{
continue;
}
notifications.Add(new ApprovalNotification(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.Messages,
requirement.StepIds,
requirement.ReasonTemplate));
}
return notifications;
}
public IReadOnlyList<PolicyGateNotification> BuildPolicyNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var policyHints = TaskPackPlanInsights.CollectPolicyGateHints(plan);
return policyHints
.Select(hint => new PolicyGateNotification(
hint.StepId,
hint.Message,
hint.Parameters.Select(parameter => new PolicyGateNotificationParameter(
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error)).ToImmutableArray()))
.ToImmutableArray();
}
}
public sealed record PackRunApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages,
string? ReasonTemplate);
public sealed record ApprovalActionResult(PackRunApprovalState State, bool ShouldResumeRun);
public sealed record ApprovalNotification(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> Messages,
IReadOnlyList<string> StepIds,
string? ReasonTemplate);
public sealed record PolicyGateNotification(string StepId, string? Message, IReadOnlyList<PolicyGateNotificationParameter> Parameters);
public sealed record PolicyGateNotificationParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);

View File

@@ -0,0 +1,84 @@
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalState
{
public PackRunApprovalState(
string approvalId,
IReadOnlyList<string> requiredGrants,
IReadOnlyList<string> stepIds,
IReadOnlyList<string> messages,
string? reasonTemplate,
DateTimeOffset requestedAt,
PackRunApprovalStatus status,
string? actorId = null,
DateTimeOffset? completedAt = null,
string? summary = null)
{
if (string.IsNullOrWhiteSpace(approvalId))
{
throw new ArgumentException("Approval id must not be empty.", nameof(approvalId));
}
ApprovalId = approvalId;
RequiredGrants = requiredGrants.ToImmutableArray();
StepIds = stepIds.ToImmutableArray();
Messages = messages.ToImmutableArray();
ReasonTemplate = reasonTemplate;
RequestedAt = requestedAt;
Status = status;
ActorId = actorId;
CompletedAt = completedAt;
Summary = summary;
}
public string ApprovalId { get; }
public IReadOnlyList<string> RequiredGrants { get; }
public IReadOnlyList<string> StepIds { get; }
public IReadOnlyList<string> Messages { get; }
public string? ReasonTemplate { get; }
public DateTimeOffset RequestedAt { get; }
public PackRunApprovalStatus Status { get; }
public string? ActorId { get; }
public DateTimeOffset? CompletedAt { get; }
public string? Summary { get; }
public PackRunApprovalState Approve(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Approved, actorId, completedAt, summary);
public PackRunApprovalState Reject(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Rejected, actorId, completedAt, summary);
public PackRunApprovalState Expire(DateTimeOffset expiredAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Expired, actorId: null, expiredAt, summary);
private PackRunApprovalState Transition(PackRunApprovalStatus status, string? actorId, DateTimeOffset completedAt, string? summary)
{
if (Status != PackRunApprovalStatus.Pending)
{
throw new InvalidOperationException($"Approval '{ApprovalId}' is already {Status}.");
}
return new PackRunApprovalState(
ApprovalId,
RequiredGrants,
StepIds,
Messages,
ReasonTemplate,
RequestedAt,
status,
actorId,
completedAt,
summary);
}
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.TaskRunner.Core.Execution;
public enum PackRunApprovalStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Expired = 3
}

View File

@@ -0,0 +1,25 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionContext
{
public PackRunExecutionContext(string runId, TaskPackPlan plan, DateTimeOffset requestedAt, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(plan);
RunId = runId;
Plan = plan;
RequestedAt = requestedAt;
TenantId = string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim();
}
public string RunId { get; }
public TaskPackPlan Plan { get; }
public DateTimeOffset RequestedAt { get; }
public string? TenantId { get; }
}

View File

@@ -0,0 +1,241 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraph
{
public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false);
public PackRunExecutionGraph(IReadOnlyList<PackRunExecutionStep> steps, TaskPackPlanFailurePolicy? failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
FailurePolicy = failurePolicy ?? DefaultFailurePolicy;
}
public IReadOnlyList<PackRunExecutionStep> Steps { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
}
public enum PackRunStepKind
{
Unknown = 0,
Run,
GateApproval,
GatePolicy,
Parallel,
Map,
Loop,
Conditional
}
public sealed class PackRunExecutionStep
{
public PackRunExecutionStep(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
string? approvalId,
string? gateMessage,
int? maxParallel,
bool continueOnError,
IReadOnlyList<PackRunExecutionStep> children,
PackRunLoopConfig? loopConfig = null,
PackRunConditionalConfig? conditionalConfig = null,
PackRunPolicyGateConfig? policyGateConfig = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
ApprovalId = approvalId;
GateMessage = gateMessage;
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopConfig = loopConfig;
ConditionalConfig = conditionalConfig;
PolicyGateConfig = policyGateConfig;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public IReadOnlyList<PackRunExecutionStep> Children { get; }
/// <summary>Loop step configuration (when Kind == Loop).</summary>
public PackRunLoopConfig? LoopConfig { get; }
/// <summary>Conditional step configuration (when Kind == Conditional).</summary>
public PackRunConditionalConfig? ConditionalConfig { get; }
/// <summary>Policy gate configuration (when Kind == GatePolicy).</summary>
public PackRunPolicyGateConfig? PolicyGateConfig { get; }
public static IReadOnlyDictionary<string, TaskPackPlanParameterValue> EmptyParameters { get; } =
new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal));
public static IReadOnlyList<PackRunExecutionStep> EmptyChildren { get; } =
Array.Empty<PackRunExecutionStep>();
}
/// <summary>
/// Configuration for loop steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunLoopConfig(
/// <summary>Expression yielding items to iterate over.</summary>
string? ItemsExpression,
/// <summary>Static items array (alternative to expression).</summary>
IReadOnlyList<object>? StaticItems,
/// <summary>Range specification (alternative to expression).</summary>
PackRunLoopRange? Range,
/// <summary>Variable name bound to current item (default: "item").</summary>
string Iterator,
/// <summary>Variable name bound to current index (default: "index").</summary>
string Index,
/// <summary>Maximum iterations (safety limit).</summary>
int MaxIterations,
/// <summary>Aggregation mode for loop outputs.</summary>
PackRunLoopAggregationMode AggregationMode,
/// <summary>JMESPath to extract from each iteration result.</summary>
string? OutputPath)
{
public static PackRunLoopConfig Default => new(
null, null, null, "item", "index", 1000, PackRunLoopAggregationMode.Collect, null);
}
/// <summary>Range specification for loop iteration.</summary>
public sealed record PackRunLoopRange(int Start, int End, int Step = 1);
/// <summary>Loop output aggregation modes.</summary>
public enum PackRunLoopAggregationMode
{
/// <summary>Collect outputs into array.</summary>
Collect = 0,
/// <summary>Deep merge objects.</summary>
Merge,
/// <summary>Keep only last output.</summary>
Last,
/// <summary>Keep only first output.</summary>
First,
/// <summary>Discard outputs.</summary>
None
}
/// <summary>
/// Configuration for conditional steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunConditionalConfig(
/// <summary>Ordered branches (first matching executes).</summary>
IReadOnlyList<PackRunConditionalBranch> Branches,
/// <summary>Steps to execute if no branch matches.</summary>
IReadOnlyList<PackRunExecutionStep>? ElseBranch,
/// <summary>Whether to union outputs from all branches.</summary>
bool OutputUnion);
/// <summary>A conditional branch with condition and body.</summary>
public sealed record PackRunConditionalBranch(
/// <summary>Condition expression (JMESPath or operator-based).</summary>
string ConditionExpression,
/// <summary>Steps to execute if condition matches.</summary>
IReadOnlyList<PackRunExecutionStep> Body);
/// <summary>
/// Configuration for policy gate steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunPolicyGateConfig(
/// <summary>Policy identifier in the registry.</summary>
string PolicyId,
/// <summary>Specific policy version (semver).</summary>
string? PolicyVersion,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest,
/// <summary>JMESPath expression to construct policy input.</summary>
string? InputExpression,
/// <summary>Timeout for policy evaluation.</summary>
TimeSpan Timeout,
/// <summary>What to do on policy failure.</summary>
PackRunPolicyFailureAction FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount,
/// <summary>Delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Override approvers (if action is RequestOverride).</summary>
IReadOnlyList<string>? OverrideApprovers,
/// <summary>Step ID to branch to (if action is Branch).</summary>
string? BranchTo,
/// <summary>Whether to record decision in evidence locker.</summary>
bool RecordDecision,
/// <summary>Whether to record policy input.</summary>
bool RecordInput,
/// <summary>Whether to record rationale.</summary>
bool RecordRationale,
/// <summary>Whether to create DSSE attestation.</summary>
bool CreateAttestation)
{
public static PackRunPolicyGateConfig Default(string policyId) => new(
policyId, null, null, null,
TimeSpan.FromMinutes(5),
PackRunPolicyFailureAction.Abort, 0, TimeSpan.FromSeconds(10),
null, null, true, false, true, false);
}
/// <summary>Policy gate failure actions.</summary>
public enum PackRunPolicyFailureAction
{
/// <summary>Abort the run.</summary>
Abort = 0,
/// <summary>Log warning and continue.</summary>
Warn,
/// <summary>Request override approval.</summary>
RequestOverride,
/// <summary>Branch to specified step.</summary>
Branch
}

View File

@@ -0,0 +1,244 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraphBuilder
{
public PackRunExecutionGraph Build(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var steps = plan.Steps.Select(ConvertStep).ToList();
var failurePolicy = plan.FailurePolicy;
return new PackRunExecutionGraph(steps, failurePolicy);
}
private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step)
{
var kind = DetermineKind(step.Type);
var parameters = step.Parameters is null
? PackRunExecutionStep.EmptyParameters
: new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(
new Dictionary<string, TaskPackPlanParameterValue>(step.Parameters, StringComparer.Ordinal));
var children = step.Children is null
? PackRunExecutionStep.EmptyChildren
: step.Children.Select(ConvertStep).ToList();
var maxParallel = TryGetInt(parameters, "maxParallel");
var continueOnError = TryGetBool(parameters, "continueOnError");
// Extract type-specific configurations
var loopConfig = kind == PackRunStepKind.Loop ? ExtractLoopConfig(parameters, children) : null;
var conditionalConfig = kind == PackRunStepKind.Conditional ? ExtractConditionalConfig(parameters, children) : null;
var policyGateConfig = kind == PackRunStepKind.GatePolicy ? ExtractPolicyGateConfig(parameters, step) : null;
return new PackRunExecutionStep(
step.Id,
step.TemplateId,
kind,
step.Enabled,
step.Uses,
parameters,
step.ApprovalId,
step.GateMessage,
maxParallel,
continueOnError,
children,
loopConfig,
conditionalConfig,
policyGateConfig);
}
private static PackRunStepKind DetermineKind(string? type)
=> type switch
{
"run" => PackRunStepKind.Run,
"gate.approval" => PackRunStepKind.GateApproval,
"gate.policy" => PackRunStepKind.GatePolicy,
"parallel" => PackRunStepKind.Parallel,
"map" => PackRunStepKind.Map,
"loop" => PackRunStepKind.Loop,
"conditional" => PackRunStepKind.Conditional,
_ => PackRunStepKind.Unknown
};
private static PackRunLoopConfig ExtractLoopConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var itemsExpression = TryGetString(parameters, "items");
var iterator = TryGetString(parameters, "iterator") ?? "item";
var index = TryGetString(parameters, "index") ?? "index";
var maxIterations = TryGetInt(parameters, "maxIterations") ?? 1000;
var aggregationMode = ParseAggregationMode(TryGetString(parameters, "aggregation"));
var outputPath = TryGetString(parameters, "outputPath");
// Parse range if present
PackRunLoopRange? range = null;
if (parameters.TryGetValue("range", out var rangeValue) && rangeValue.Value is JsonObject rangeObj)
{
var start = rangeObj["start"]?.GetValue<int>() ?? 0;
var end = rangeObj["end"]?.GetValue<int>() ?? 0;
var step = rangeObj["step"]?.GetValue<int>() ?? 1;
range = new PackRunLoopRange(start, end, step);
}
// Parse static items if present
IReadOnlyList<object>? staticItems = null;
if (parameters.TryGetValue("staticItems", out var staticValue) && staticValue.Value is JsonArray arr)
{
staticItems = arr.Select(n => (object)(n?.ToString() ?? "")).ToList();
}
return new PackRunLoopConfig(
itemsExpression, staticItems, range, iterator, index,
maxIterations, aggregationMode, outputPath);
}
private static PackRunConditionalConfig ExtractConditionalConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var branches = new List<PackRunConditionalBranch>();
IReadOnlyList<PackRunExecutionStep>? elseBranch = null;
var outputUnion = TryGetBool(parameters, "outputUnion");
// Parse branches from parameters
if (parameters.TryGetValue("branches", out var branchesValue) && branchesValue.Value is JsonArray branchArray)
{
foreach (var branchNode in branchArray)
{
if (branchNode is not JsonObject branchObj) continue;
var condition = branchObj["condition"]?.ToString() ?? "true";
var bodySteps = new List<PackRunExecutionStep>();
// Body would be parsed from the plan's children structure
// For now, use empty body - actual body comes from step children
branches.Add(new PackRunConditionalBranch(condition, bodySteps));
}
}
// If no explicit branches parsed, treat children as the primary branch body
if (branches.Count == 0 && children.Count > 0)
{
branches.Add(new PackRunConditionalBranch("true", children));
}
return new PackRunConditionalConfig(branches, elseBranch, outputUnion);
}
private static PackRunPolicyGateConfig? ExtractPolicyGateConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
TaskPackPlanStep step)
{
var policyId = TryGetString(parameters, "policyId") ?? TryGetString(parameters, "policy");
if (string.IsNullOrEmpty(policyId)) return null;
var policyVersion = TryGetString(parameters, "policyVersion");
var policyDigest = TryGetString(parameters, "policyDigest");
var inputExpression = TryGetString(parameters, "inputExpression");
var timeout = ParseTimeSpan(TryGetString(parameters, "timeout"), TimeSpan.FromMinutes(5));
var failureAction = ParsePolicyFailureAction(TryGetString(parameters, "failureAction"));
var retryCount = TryGetInt(parameters, "retryCount") ?? 0;
var retryDelay = ParseTimeSpan(TryGetString(parameters, "retryDelay"), TimeSpan.FromSeconds(10));
var recordDecision = TryGetBool(parameters, "recordDecision") || !parameters.ContainsKey("recordDecision");
var recordInput = TryGetBool(parameters, "recordInput");
var recordRationale = TryGetBool(parameters, "recordRationale") || !parameters.ContainsKey("recordRationale");
var createAttestation = TryGetBool(parameters, "attestation");
// Parse override approvers
IReadOnlyList<string>? overrideApprovers = null;
if (parameters.TryGetValue("overrideApprovers", out var approversValue) && approversValue.Value is JsonArray arr)
{
overrideApprovers = arr.Select(n => n?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList();
}
var branchTo = TryGetString(parameters, "branchTo");
return new PackRunPolicyGateConfig(
policyId, policyVersion, policyDigest, inputExpression,
timeout, failureAction, retryCount, retryDelay,
overrideApprovers, branchTo,
recordDecision, recordInput, recordRationale, createAttestation);
}
private static PackRunLoopAggregationMode ParseAggregationMode(string? mode)
=> mode?.ToLowerInvariant() switch
{
"collect" => PackRunLoopAggregationMode.Collect,
"merge" => PackRunLoopAggregationMode.Merge,
"last" => PackRunLoopAggregationMode.Last,
"first" => PackRunLoopAggregationMode.First,
"none" => PackRunLoopAggregationMode.None,
_ => PackRunLoopAggregationMode.Collect
};
private static PackRunPolicyFailureAction ParsePolicyFailureAction(string? action)
=> action?.ToLowerInvariant() switch
{
"abort" => PackRunPolicyFailureAction.Abort,
"warn" => PackRunPolicyFailureAction.Warn,
"requestoverride" => PackRunPolicyFailureAction.RequestOverride,
"branch" => PackRunPolicyFailureAction.Branch,
_ => PackRunPolicyFailureAction.Abort
};
private static TimeSpan ParseTimeSpan(string? value, TimeSpan defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
// Parse formats like "30s", "5m", "1h"
if (value.Length < 2) return defaultValue;
var unit = value[^1];
if (!int.TryParse(value[..^1], out var number)) return defaultValue;
return unit switch
{
's' => TimeSpan.FromSeconds(number),
'm' => TimeSpan.FromMinutes(number),
'h' => TimeSpan.FromHours(number),
'd' => TimeSpan.FromDays(number),
_ => defaultValue
};
}
private static int? TryGetInt(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return null;
}
return jsonValue.TryGetValue<int>(out var result) ? result : null;
}
private static bool TryGetBool(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return false;
}
return jsonValue.TryGetValue<bool>(out var result) && result;
}
private static string? TryGetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value))
{
return null;
}
return value.Value switch
{
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var str) => str,
_ => value.Value?.ToString()
};
}
}

View File

@@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunGateStateUpdater
{
public static PackRunGateStateUpdateResult Apply(
PackRunState state,
PackRunExecutionGraph graph,
PackRunApprovalCoordinator coordinator,
DateTimeOffset timestamp)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(coordinator);
var approvals = coordinator.GetApprovals()
.SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval)))
.GroupBy(tuple => tuple.stepId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.First().approval,
StringComparer.Ordinal);
var mutable = new Dictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
var changed = false;
var hasBlockingFailure = false;
foreach (var step in EnumerateSteps(graph.Steps))
{
if (!mutable.TryGetValue(step.Id, out var record))
{
continue;
}
switch (step.Kind)
{
case PackRunStepKind.GateApproval:
if (!approvals.TryGetValue(step.Id, out var approvalState))
{
continue;
}
switch (approvalState.Status)
{
case PackRunApprovalStatus.Pending:
break;
case PackRunApprovalStatus.Approved:
if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null)
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
case PackRunApprovalStatus.Rejected:
case PackRunApprovalStatus.Expired:
var failureReason = BuildFailureReason(approvalState);
if (record.Status != PackRunStepExecutionStatus.Failed ||
!string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Failed,
StatusReason = failureReason,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
hasBlockingFailure = true;
break;
}
break;
case PackRunStepKind.GatePolicy:
if (record.Status == PackRunStepExecutionStatus.Pending &&
string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
}
}
if (!changed)
{
return new PackRunGateStateUpdateResult(state, hasBlockingFailure);
}
var updatedState = state with
{
UpdatedAt = timestamp,
Steps = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable)
};
return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure);
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
if (steps.Count == 0)
{
yield break;
}
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
private static string BuildFailureReason(PackRunApprovalState state)
{
var baseReason = state.Status switch
{
PackRunApprovalStatus.Rejected => "approval-rejected",
PackRunApprovalStatus.Expired => "approval-expired",
_ => "approval-invalid"
};
if (string.IsNullOrWhiteSpace(state.Summary))
{
return baseReason;
}
var summary = state.Summary.Trim();
return $"{baseReason}:{summary}";
}
}
public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure);

View File

@@ -0,0 +1,84 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunProcessor
{
private readonly IPackRunApprovalStore approvalStore;
private readonly IPackRunNotificationPublisher notificationPublisher;
private readonly ILogger<PackRunProcessor> logger;
public PackRunProcessor(
IPackRunApprovalStore approvalStore,
IPackRunNotificationPublisher notificationPublisher,
ILogger<PackRunProcessor> logger)
{
this.approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore));
this.notificationPublisher = notificationPublisher ?? throw new ArgumentNullException(nameof(notificationPublisher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PackRunProcessorResult> ProcessNewRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var existing = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
PackRunApprovalCoordinator coordinator;
bool shouldResume;
if (existing.Count > 0)
{
coordinator = PackRunApprovalCoordinator.Restore(context.Plan, existing, context.RequestedAt);
shouldResume = !coordinator.HasPendingApprovals;
logger.LogInformation("Run {RunId} approvals restored (pending: {Pending}).", context.RunId, coordinator.HasPendingApprovals);
}
else
{
coordinator = PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
await approvalStore.SaveAsync(context.RunId, coordinator.GetApprovals(), cancellationToken).ConfigureAwait(false);
var approvalNotifications = coordinator.BuildNotifications(context.Plan);
foreach (var notification in approvalNotifications)
{
await notificationPublisher.PublishApprovalRequestedAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Approval requested for run {RunId} gate {ApprovalId} requiring grants {Grants}.",
context.RunId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
}
var policyNotifications = coordinator.BuildPolicyNotifications(context.Plan);
foreach (var notification in policyNotifications)
{
await notificationPublisher.PublishPolicyGatePendingAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogDebug(
"Policy gate pending for run {RunId} step {StepId}.",
context.RunId,
notification.StepId);
}
shouldResume = !coordinator.HasPendingApprovals;
}
if (shouldResume)
{
logger.LogInformation("Run {RunId} has no approvals; proceeding immediately.", context.RunId);
}
return new PackRunProcessorResult(coordinator, shouldResume);
}
public async Task<PackRunApprovalCoordinator> RestoreAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var states = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
if (states.Count == 0)
{
return PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
}
return PackRunApprovalCoordinator.Restore(context.Plan, states, context.RequestedAt);
}
}

View File

@@ -0,0 +1,5 @@
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunProcessorResult(
PackRunApprovalCoordinator ApprovalCoordinator,
bool ShouldResumeImmediately);

View File

@@ -0,0 +1,60 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunState(
string RunId,
string PlanHash,
TaskPackPlan Plan,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, PackRunStepStateRecord> Steps,
string? TenantId = null)
{
public static PackRunState Create(
string runId,
string planHash,
TaskPackPlan plan,
TaskPackPlanFailurePolicy failurePolicy,
DateTimeOffset requestedAt,
IReadOnlyDictionary<string, PackRunStepStateRecord> steps,
DateTimeOffset timestamp,
string? tenantId = null)
=> new(
runId,
planHash,
plan,
failurePolicy,
requestedAt,
timestamp,
timestamp,
new ReadOnlyDictionary<string, PackRunStepStateRecord>(new Dictionary<string, PackRunStepStateRecord>(steps, StringComparer.Ordinal)),
tenantId);
}
public sealed record PackRunStepStateRecord(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
public interface IPackRunStateStore
{
Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken);
Task SaveAsync(PackRunState state, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,117 @@
using StellaOps.TaskRunner.Core.Execution.Simulation;
namespace StellaOps.TaskRunner.Core.Execution;
/// <summary>
/// Builds deterministic <see cref="PackRunState"/> snapshots for freshly scheduled runs.
/// </summary>
public static class PackRunStateFactory
{
public static PackRunState CreateInitialState(
PackRunExecutionContext context,
PackRunExecutionGraph graph,
PackRunSimulationEngine simulationEngine,
DateTimeOffset timestamp)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(simulationEngine);
var simulation = simulationEngine.Simulate(context.Plan);
var simulationIndex = IndexSimulation(simulation.Steps);
var stepRecords = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
foreach (var step in EnumerateSteps(graph.Steps))
{
var simulationStatus = simulationIndex.TryGetValue(step.Id, out var node)
? node.Status
: PackRunSimulationStatus.Pending;
var status = step.Enabled ? PackRunStepExecutionStatus.Pending : PackRunStepExecutionStatus.Skipped;
string? statusReason = null;
if (!step.Enabled)
{
statusReason = "disabled";
}
else if (simulationStatus == PackRunSimulationStatus.RequiresApproval)
{
statusReason = "requires-approval";
}
else if (simulationStatus == PackRunSimulationStatus.RequiresPolicy)
{
statusReason = "requires-policy";
}
else if (simulationStatus == PackRunSimulationStatus.Skipped)
{
status = PackRunStepExecutionStatus.Skipped;
statusReason = "condition-false";
}
var record = new PackRunStepStateRecord(
step.Id,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
status,
Attempts: 0,
LastTransitionAt: null,
NextAttemptAt: null,
StatusReason: statusReason);
stepRecords[step.Id] = record;
}
var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
return PackRunState.Create(
context.RunId,
context.Plan.Hash,
context.Plan,
failurePolicy,
context.RequestedAt,
stepRecords,
timestamp,
context.TenantId);
}
private static Dictionary<string, PackRunSimulationNode> IndexSimulation(IReadOnlyList<PackRunSimulationNode> nodes)
{
var result = new Dictionary<string, PackRunSimulationNode>(StringComparer.Ordinal);
foreach (var node in nodes)
{
IndexSimulationNode(node, result);
}
return result;
}
private static void IndexSimulationNode(PackRunSimulationNode node, Dictionary<string, PackRunSimulationNode> accumulator)
{
accumulator[node.Id] = node;
foreach (var child in node.Children)
{
IndexSimulationNode(child, accumulator);
}
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
foreach (var step in steps)
{
yield return step;
if (step.Children.Count == 0)
{
continue;
}
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}

View File

@@ -0,0 +1,121 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunStepStateMachine
{
public static PackRunStepState Create(DateTimeOffset? createdAt = null)
=> new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null);
public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot start step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Running,
LastTransitionAt = startedAt,
NextAttemptAt = null
};
}
public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot complete step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Succeeded,
Attempts = state.Attempts + 1,
LastTransitionAt = completedAt,
NextAttemptAt = null
};
}
public static PackRunStepFailureResult RegisterFailure(
PackRunStepState state,
DateTimeOffset failedAt,
TaskPackPlanFailurePolicy failurePolicy)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(failurePolicy);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot register failure from status {state.Status}.");
}
var attempts = state.Attempts + 1;
if (attempts < failurePolicy.MaxAttempts)
{
var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds));
var nextAttemptAt = failedAt + backoff;
var nextState = state with
{
Status = PackRunStepExecutionStatus.Pending,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = nextAttemptAt
};
return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry);
}
var finalState = state with
{
Status = PackRunStepExecutionStatus.Failed,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = null
};
return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort);
}
public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot skip step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Skipped,
LastTransitionAt = skippedAt,
NextAttemptAt = null
};
}
}
public sealed record PackRunStepState(
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt);
public enum PackRunStepExecutionStatus
{
Pending = 0,
Running,
Succeeded,
Failed,
Skipped
}
public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome);
public enum PackRunStepFailureOutcome
{
Retry = 0,
Abort
}

View File

@@ -0,0 +1,65 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public static class ProvenanceManifestFactory
{
public static ProvenanceManifest Create(PackRunExecutionContext context, PackRunState state, DateTimeOffset completedAt)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(state);
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new ProvenanceStep(
step.StepId,
step.Kind.ToString(),
step.Status.ToString(),
step.Attempts,
step.LastTransitionAt,
step.StatusReason))
.ToList();
var outputs = context.Plan.Outputs
.Select(output => new ProvenanceOutput(output.Name, output.Type))
.ToList();
return new ProvenanceManifest(
context.RunId,
context.TenantId,
context.Plan.Hash,
context.Plan.Metadata.Name,
context.Plan.Metadata.Version,
context.Plan.Metadata.Description,
context.Plan.Metadata.Tags,
context.RequestedAt,
state.CreatedAt,
completedAt,
steps,
outputs);
}
}
public sealed record ProvenanceManifest(
string RunId,
string? TenantId,
string PlanHash,
string PackName,
string PackVersion,
string? PackDescription,
IReadOnlyList<string> PackTags,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset CompletedAt,
IReadOnlyList<ProvenanceStep> Steps,
IReadOnlyList<ProvenanceOutput> Outputs);
public sealed record ProvenanceStep(
string Id,
string Kind,
string Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
string? StatusReason);
public sealed record ProvenanceOutput(string Name, string Type);

View File

@@ -0,0 +1,110 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationEngine
{
private readonly PackRunExecutionGraphBuilder graphBuilder;
public PackRunSimulationEngine()
{
graphBuilder = new PackRunExecutionGraphBuilder();
}
public PackRunSimulationResult Simulate(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var graph = graphBuilder.Build(plan);
var steps = graph.Steps.Select(ConvertStep).ToList();
var outputs = BuildOutputs(plan.Outputs);
return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy);
}
private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step)
{
var status = DetermineStatus(step);
var children = step.Children.Count == 0
? PackRunSimulationNode.Empty
: new ReadOnlyCollection<PackRunSimulationNode>(step.Children.Select(ConvertStep).ToList());
// Extract loop/conditional specific details
var loopInfo = step.Kind == PackRunStepKind.Loop && step.LoopConfig is not null
? new PackRunSimulationLoopInfo(
step.LoopConfig.ItemsExpression,
step.LoopConfig.Iterator,
step.LoopConfig.Index,
step.LoopConfig.MaxIterations,
step.LoopConfig.AggregationMode.ToString().ToLowerInvariant())
: null;
var conditionalInfo = step.Kind == PackRunStepKind.Conditional && step.ConditionalConfig is not null
? new PackRunSimulationConditionalInfo(
step.ConditionalConfig.Branches.Select(b =>
new PackRunSimulationBranch(b.ConditionExpression, b.Body.Count)).ToList(),
step.ConditionalConfig.ElseBranch?.Count ?? 0,
step.ConditionalConfig.OutputUnion)
: null;
var policyInfo = step.Kind == PackRunStepKind.GatePolicy && step.PolicyGateConfig is not null
? new PackRunSimulationPolicyInfo(
step.PolicyGateConfig.PolicyId,
step.PolicyGateConfig.PolicyVersion,
step.PolicyGateConfig.FailureAction.ToString().ToLowerInvariant(),
step.PolicyGateConfig.RetryCount)
: null;
return new PackRunSimulationNode(
step.Id,
step.TemplateId,
step.Kind,
step.Enabled,
step.Uses,
step.ApprovalId,
step.GateMessage,
step.Parameters,
step.MaxParallel,
step.ContinueOnError,
status,
children,
loopInfo,
conditionalInfo,
policyInfo);
}
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
{
if (!step.Enabled)
{
return PackRunSimulationStatus.Skipped;
}
return step.Kind switch
{
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate,
PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch,
_ => PackRunSimulationStatus.Pending
};
}
private static IReadOnlyList<PackRunSimulationOutput> BuildOutputs(IReadOnlyList<TaskPackPlanOutput> outputs)
{
if (outputs.Count == 0)
{
return PackRunSimulationOutput.Empty;
}
var list = new List<PackRunSimulationOutput>(outputs.Count);
foreach (var output in outputs)
{
list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression));
}
return new ReadOnlyCollection<PackRunSimulationOutput>(list);
}
}

View File

@@ -0,0 +1,191 @@
using StellaOps.TaskRunner.Core.Planning;
using System.Collections.ObjectModel;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationResult
{
public PackRunSimulationResult(
IReadOnlyList<PackRunSimulationNode> steps,
IReadOnlyList<PackRunSimulationOutput> outputs,
TaskPackPlanFailurePolicy failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs));
FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy));
}
public IReadOnlyList<PackRunSimulationNode> Steps { get; }
public IReadOnlyList<PackRunSimulationOutput> Outputs { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement);
private static bool ContainsApprovalRequirement(PackRunSimulationNode node)
{
if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy)
{
return true;
}
return node.Children.Any(ContainsApprovalRequirement);
}
}
public sealed class PackRunSimulationNode
{
public PackRunSimulationNode(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
string? approvalId,
string? gateMessage,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
int? maxParallel,
bool continueOnError,
PackRunSimulationStatus status,
IReadOnlyList<PackRunSimulationNode> children,
PackRunSimulationLoopInfo? loopInfo = null,
PackRunSimulationConditionalInfo? conditionalInfo = null,
PackRunSimulationPolicyInfo? policyInfo = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
ApprovalId = approvalId;
GateMessage = gateMessage;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Status = status;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopInfo = loopInfo;
ConditionalInfo = conditionalInfo;
PolicyInfo = policyInfo;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public PackRunSimulationStatus Status { get; }
public IReadOnlyList<PackRunSimulationNode> Children { get; }
/// <summary>Loop step simulation info (when Kind == Loop).</summary>
public PackRunSimulationLoopInfo? LoopInfo { get; }
/// <summary>Conditional step simulation info (when Kind == Conditional).</summary>
public PackRunSimulationConditionalInfo? ConditionalInfo { get; }
/// <summary>Policy gate simulation info (when Kind == GatePolicy).</summary>
public PackRunSimulationPolicyInfo? PolicyInfo { get; }
public static IReadOnlyList<PackRunSimulationNode> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationNode>(Array.Empty<PackRunSimulationNode>());
}
public enum PackRunSimulationStatus
{
Pending = 0,
Skipped,
RequiresApproval,
RequiresPolicy,
/// <summary>Loop step will iterate over items.</summary>
WillIterate,
/// <summary>Conditional step will branch based on conditions.</summary>
WillBranch
}
/// <summary>Loop step simulation details.</summary>
public sealed record PackRunSimulationLoopInfo(
/// <summary>Items expression to iterate over.</summary>
string? ItemsExpression,
/// <summary>Iterator variable name.</summary>
string Iterator,
/// <summary>Index variable name.</summary>
string Index,
/// <summary>Maximum iterations allowed.</summary>
int MaxIterations,
/// <summary>Aggregation mode for outputs.</summary>
string AggregationMode);
/// <summary>Conditional step simulation details.</summary>
public sealed record PackRunSimulationConditionalInfo(
/// <summary>Branch conditions and body step counts.</summary>
IReadOnlyList<PackRunSimulationBranch> Branches,
/// <summary>Number of steps in else branch.</summary>
int ElseStepCount,
/// <summary>Whether outputs are unioned.</summary>
bool OutputUnion);
/// <summary>A conditional branch summary.</summary>
public sealed record PackRunSimulationBranch(
/// <summary>Condition expression.</summary>
string Condition,
/// <summary>Number of steps in body.</summary>
int StepCount);
/// <summary>Policy gate simulation details.</summary>
public sealed record PackRunSimulationPolicyInfo(
/// <summary>Policy identifier.</summary>
string PolicyId,
/// <summary>Policy version (if specified).</summary>
string? PolicyVersion,
/// <summary>Failure action.</summary>
string FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount);
public sealed class PackRunSimulationOutput
{
public PackRunSimulationOutput(
string name,
string type,
TaskPackPlanParameterValue? path,
TaskPackPlanParameterValue? expression)
{
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name;
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type;
Path = path;
Expression = expression;
}
public string Name { get; }
public string Type { get; }
public TaskPackPlanParameterValue? Path { get; }
public TaskPackPlanParameterValue? Expression { get; }
public bool RequiresRuntimeValue =>
(Path?.RequiresRuntimeValue ?? false) ||
(Expression?.RequiresRuntimeValue ?? false);
public static IReadOnlyList<PackRunSimulationOutput> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationOutput>(Array.Empty<PackRunSimulationOutput>());
}

View File

@@ -0,0 +1,16 @@
using System.Diagnostics.Metrics;
namespace StellaOps.TaskRunner.Core.Execution;
public static class TaskRunnerTelemetry
{
public const string MeterName = "stellaops.taskrunner";
public static readonly Meter Meter = new(MeterName);
public static readonly Histogram<double> StepDurationMs =
Meter.CreateHistogram<double>("taskrunner.step.duration.ms", unit: "ms");
public static readonly Counter<long> StepRetryCount =
Meter.CreateCounter<long>("taskrunner.step.retry.count");
public static readonly UpDownCounter<long> RunningSteps =
Meter.CreateUpDownCounter<long>("taskrunner.steps.running");
}

View File

@@ -0,0 +1,596 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.Expressions;
internal static class TaskPackExpressions
{
private static readonly Regex ExpressionPattern = new("^\\s*\\{\\{(.+)\\}\\}\\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex ComparisonPattern = new("^(?<left>.+?)\\s*(?<op>==|!=)\\s*(?<right>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex InPattern = new("^(?<left>.+?)\\s+in\\s+(?<right>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static bool TryEvaluateBoolean(string? candidate, TaskPackExpressionContext context, out bool value, out string? error)
{
value = false;
error = null;
if (string.IsNullOrWhiteSpace(candidate))
{
value = true;
return true;
}
if (!TryExtractExpression(candidate, out var expression))
{
return TryParseBooleanLiteral(candidate.Trim(), out value, out error);
}
expression = expression.Trim();
return TryEvaluateBooleanInternal(expression, context, out value, out error);
}
public static TaskPackValueResolution EvaluateValue(JsonNode? node, TaskPackExpressionContext context)
{
if (node is null)
{
return TaskPackValueResolution.FromValue(null);
}
if (node is JsonValue valueNode && valueNode.TryGetValue(out string? stringValue))
{
if (!TryExtractExpression(stringValue, out var expression))
{
return TaskPackValueResolution.FromValue(valueNode);
}
var trimmed = expression.Trim();
return EvaluateExpression(trimmed, context);
}
return TaskPackValueResolution.FromValue(node);
}
public static TaskPackValueResolution EvaluateString(string value, TaskPackExpressionContext context)
{
if (!TryExtractExpression(value, out var expression))
{
return TaskPackValueResolution.FromValue(JsonValue.Create(value));
}
return EvaluateExpression(expression.Trim(), context);
}
private static bool TryEvaluateBooleanInternal(string expression, TaskPackExpressionContext context, out bool result, out string? error)
{
result = false;
error = null;
if (TrySplitTopLevel(expression, "||", out var left, out var right) ||
TrySplitTopLevel(expression, " or ", out left, out right))
{
if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error))
{
return false;
}
if (leftValue)
{
result = true;
return true;
}
if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error))
{
return false;
}
result = rightValue;
return true;
}
if (TrySplitTopLevel(expression, "&&", out left, out right) ||
TrySplitTopLevel(expression, " and ", out left, out right))
{
if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error))
{
return false;
}
if (!leftValue)
{
result = false;
return true;
}
if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error))
{
return false;
}
result = rightValue;
return true;
}
if (expression.StartsWith("not ", StringComparison.Ordinal))
{
var inner = expression["not ".Length..].Trim();
if (!TryEvaluateBooleanInternal(inner, context, out var innerValue, out error))
{
return false;
}
result = !innerValue;
return true;
}
if (TryEvaluateComparison(expression, context, out result, out error))
{
return error is null;
}
var resolution = EvaluateExpression(expression, context);
if (!resolution.Resolved)
{
error = resolution.Error ?? $"Expression '{expression}' requires runtime evaluation.";
return false;
}
result = ToBoolean(resolution.Value);
return true;
}
private static bool TryEvaluateComparison(string expression, TaskPackExpressionContext context, out bool value, out string? error)
{
value = false;
error = null;
var comparisonMatch = ComparisonPattern.Match(expression);
if (comparisonMatch.Success)
{
var left = comparisonMatch.Groups["left"].Value.Trim();
var op = comparisonMatch.Groups["op"].Value;
var right = comparisonMatch.Groups["right"].Value.Trim();
var leftResolution = EvaluateOperand(left, context);
if (!leftResolution.IsValid(out error))
{
return false;
}
var rightResolution = EvaluateOperand(right, context);
if (!rightResolution.IsValid(out error))
{
return false;
}
if (!leftResolution.TryGetValue(out var leftValue, out error) ||
!rightResolution.TryGetValue(out var rightValue, out error))
{
return false;
}
value = CompareNodes(leftValue, rightValue, op == "==");
return true;
}
var inMatch = InPattern.Match(expression);
if (inMatch.Success)
{
var member = inMatch.Groups["left"].Value.Trim();
var collection = inMatch.Groups["right"].Value.Trim();
var memberResolution = EvaluateOperand(member, context);
if (!memberResolution.IsValid(out error))
{
return false;
}
var collectionResolution = EvaluateOperand(collection, context);
if (!collectionResolution.IsValid(out error))
{
return false;
}
if (!memberResolution.TryGetValue(out var memberValue, out error) ||
!collectionResolution.TryGetValue(out var collectionValue, out error))
{
return false;
}
value = EvaluateMembership(memberValue, collectionValue);
return true;
}
return false;
}
private static OperandResolution EvaluateOperand(string expression, TaskPackExpressionContext context)
{
if (TryParseStringLiteral(expression, out var literal))
{
return OperandResolution.FromValue(JsonValue.Create(literal));
}
if (bool.TryParse(expression, out var boolLiteral))
{
return OperandResolution.FromValue(JsonValue.Create(boolLiteral));
}
if (double.TryParse(expression, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var numberLiteral))
{
return OperandResolution.FromValue(JsonValue.Create(numberLiteral));
}
var resolution = EvaluateExpression(expression, context);
if (!resolution.Resolved)
{
if (resolution.RequiresRuntimeValue && resolution.Error is null)
{
return OperandResolution.FromRuntime(expression);
}
return OperandResolution.FromError(resolution.Error ?? $"Expression '{expression}' could not be resolved.");
}
return OperandResolution.FromValue(resolution.Value);
}
private static TaskPackValueResolution EvaluateExpression(string expression, TaskPackExpressionContext context)
{
if (!TryResolvePath(expression, context, out var resolved, out var requiresRuntime, out var error))
{
return TaskPackValueResolution.FromError(expression, error ?? $"Failed to resolve expression '{expression}'.");
}
if (requiresRuntime)
{
return TaskPackValueResolution.FromDeferred(expression);
}
return TaskPackValueResolution.FromValue(resolved);
}
private static bool TryResolvePath(string expression, TaskPackExpressionContext context, out JsonNode? value, out bool requiresRuntime, out string? error)
{
value = null;
error = null;
requiresRuntime = false;
if (string.IsNullOrWhiteSpace(expression))
{
error = "Expression cannot be empty.";
return false;
}
var segments = expression.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
error = $"Expression '{expression}' is invalid.";
return false;
}
var root = segments[0];
switch (root)
{
case "inputs":
if (segments.Length == 1)
{
error = "Expression must reference a specific input (e.g., inputs.example).";
return false;
}
if (!context.Inputs.TryGetValue(segments[1], out var current))
{
error = $"Input '{segments[1]}' was not supplied.";
return false;
}
value = Traverse(current, segments, startIndex: 2);
return true;
case "item":
if (context.CurrentItem is null)
{
error = "Expression references 'item' outside of a map iteration.";
return false;
}
value = Traverse(context.CurrentItem, segments, startIndex: 1);
return true;
case "steps":
if (segments.Length < 2)
{
error = "Step expressions must specify a step identifier (e.g., steps.plan.outputs.value).";
return false;
}
var stepId = segments[1];
if (!context.StepExists(stepId))
{
error = $"Step '{stepId}' referenced before it is defined.";
return false;
}
requiresRuntime = true;
value = null;
return true;
case "secrets":
if (segments.Length < 2)
{
error = "Secret expressions must specify a secret name (e.g., secrets.jiraToken).";
return false;
}
var secretName = segments[1];
if (!context.SecretExists(secretName))
{
error = $"Secret '{secretName}' is not declared in the manifest.";
return false;
}
requiresRuntime = true;
value = null;
return true;
default:
error = $"Expression '{expression}' references '{root}', supported roots are inputs, item, steps, and secrets.";
return false;
}
}
private static JsonNode? Traverse(JsonNode? current, IReadOnlyList<string> segments, int startIndex)
{
for (var i = startIndex; i < segments.Count && current is not null; i++)
{
var segment = segments[i];
if (current is JsonObject obj)
{
if (!obj.TryGetPropertyValue(segment, out current))
{
current = null;
}
}
else if (current is JsonArray array)
{
current = TryGetArrayElement(array, segment);
}
else
{
current = null;
}
}
return current;
}
private static JsonNode? TryGetArrayElement(JsonArray array, string segment)
{
if (int.TryParse(segment, out var index) && index >= 0 && index < array.Count)
{
return array[index];
}
return null;
}
private static bool TryExtractExpression(string candidate, out string expression)
{
var match = ExpressionPattern.Match(candidate);
if (!match.Success)
{
expression = candidate;
return false;
}
expression = match.Groups[1].Value;
return true;
}
private static bool TryParseBooleanLiteral(string value, out bool result, out string? error)
{
if (bool.TryParse(value, out result))
{
error = null;
return true;
}
error = $"Unable to parse boolean literal '{value}'.";
return false;
}
private static bool TrySplitTopLevel(string expression, string token, out string left, out string right)
{
var inSingle = false;
var inDouble = false;
for (var i = 0; i <= expression.Length - token.Length; i++)
{
var c = expression[i];
if (c == '\'' && !inDouble)
{
inSingle = !inSingle;
}
else if (c == '"' && !inSingle)
{
inDouble = !inDouble;
}
if (inSingle || inDouble)
{
continue;
}
if (expression.AsSpan(i, token.Length).SequenceEqual(token))
{
left = expression[..i].Trim();
right = expression[(i + token.Length)..].Trim();
return true;
}
}
left = string.Empty;
right = string.Empty;
return false;
}
private static bool TryParseStringLiteral(string candidate, out string? literal)
{
literal = null;
if (candidate.Length >= 2)
{
if ((candidate[0] == '"' && candidate[^1] == '"') ||
(candidate[0] == '\'' && candidate[^1] == '\''))
{
literal = candidate[1..^1];
return true;
}
}
return false;
}
private static bool CompareNodes(JsonNode? left, JsonNode? right, bool equality)
{
if (left is null && right is null)
{
return equality;
}
if (left is null || right is null)
{
return !equality;
}
var comparison = JsonNode.DeepEquals(left, right);
return equality ? comparison : !comparison;
}
private static bool EvaluateMembership(JsonNode? member, JsonNode? collection)
{
if (collection is JsonArray array)
{
foreach (var element in array)
{
if (JsonNode.DeepEquals(member, element))
{
return true;
}
}
return false;
}
if (collection is JsonValue value && value.TryGetValue(out string? text) && member is JsonValue memberValue && memberValue.TryGetValue(out string? memberText))
{
return text?.Contains(memberText, StringComparison.Ordinal) ?? false;
}
return false;
}
private static bool ToBoolean(JsonNode? node)
{
if (node is null)
{
return false;
}
if (node is JsonValue value)
{
if (value.TryGetValue<bool>(out var boolValue))
{
return boolValue;
}
if (value.TryGetValue<string>(out var stringValue))
{
return !string.IsNullOrWhiteSpace(stringValue);
}
if (value.TryGetValue<double>(out var number))
{
return Math.Abs(number) > double.Epsilon;
}
}
if (node is JsonArray array)
{
return array.Count > 0;
}
if (node is JsonObject obj)
{
return obj.Count > 0;
}
return true;
}
private readonly record struct OperandResolution(JsonNode? Value, string? Error, bool RequiresRuntime)
{
public bool IsValid(out string? error)
{
error = Error;
return string.IsNullOrEmpty(Error);
}
public bool TryGetValue(out JsonNode? value, out string? error)
{
if (RequiresRuntime)
{
error = "Expression requires runtime evaluation.";
value = null;
return false;
}
value = Value;
error = Error;
return error is null;
}
public static OperandResolution FromValue(JsonNode? value)
=> new(value, null, false);
public static OperandResolution FromRuntime(string expression)
=> new(null, $"Expression '{expression}' requires runtime evaluation.", true);
public static OperandResolution FromError(string error)
=> new(null, error, false);
}
}
internal readonly record struct TaskPackExpressionContext(
IReadOnlyDictionary<string, JsonNode?> Inputs,
ISet<string> KnownSteps,
ISet<string> KnownSecrets,
JsonNode? CurrentItem)
{
public static TaskPackExpressionContext Create(
IReadOnlyDictionary<string, JsonNode?> inputs,
ISet<string> knownSteps,
ISet<string> knownSecrets)
=> new(inputs, knownSteps, knownSecrets, null);
public bool StepExists(string stepId) => KnownSteps.Contains(stepId);
public void RegisterStep(string stepId) => KnownSteps.Add(stepId);
public bool SecretExists(string secretName) => KnownSecrets.Contains(secretName);
public TaskPackExpressionContext WithItem(JsonNode? item) => new(Inputs, KnownSteps, KnownSecrets, item);
}
internal readonly record struct TaskPackValueResolution(bool Resolved, JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue)
{
public static TaskPackValueResolution FromValue(JsonNode? value)
=> new(true, value, null, null, false);
public static TaskPackValueResolution FromDeferred(string expression)
=> new(false, null, expression, null, true);
public static TaskPackValueResolution FromError(string expression, string error)
=> new(false, null, expression, error, false);
}

View File

@@ -0,0 +1,534 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Events;
namespace StellaOps.TaskRunner.Core.IncidentMode;
/// <summary>
/// Service for managing pack run incident mode.
/// Per TASKRUN-OBS-55-001.
/// </summary>
public interface IPackRunIncidentModeService
{
/// <summary>
/// Activates incident mode for a run.
/// </summary>
Task<IncidentModeActivationResult> ActivateAsync(
IncidentModeActivationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Deactivates incident mode for a run.
/// </summary>
Task<IncidentModeActivationResult> DeactivateAsync(
string runId,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current incident mode status for a run.
/// </summary>
Task<PackRunIncidentModeStatus> GetStatusAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Handles an SLO breach notification.
/// </summary>
Task<IncidentModeActivationResult> HandleSloBreachAsync(
SloBreachNotification notification,
CancellationToken cancellationToken = default);
/// <summary>
/// Escalates incident mode to a higher level.
/// </summary>
Task<IncidentModeActivationResult> EscalateAsync(
string runId,
IncidentEscalationLevel newLevel,
string? reason = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets settings for the current incident mode level.
/// </summary>
IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level);
}
/// <summary>
/// Store for incident mode state.
/// </summary>
public interface IPackRunIncidentModeStore
{
/// <summary>
/// Stores incident mode status.
/// </summary>
Task StoreAsync(
string runId,
PackRunIncidentModeStatus status,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets incident mode status.
/// </summary>
Task<PackRunIncidentModeStatus?> GetAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all runs in incident mode.
/// </summary>
Task<IReadOnlyList<string>> ListActiveRunsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Removes incident mode status.
/// </summary>
Task RemoveAsync(
string runId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Settings for incident mode levels.
/// </summary>
public sealed record IncidentModeSettings(
/// <summary>Escalation level.</summary>
IncidentEscalationLevel Level,
/// <summary>Retention policy.</summary>
IncidentRetentionPolicy RetentionPolicy,
/// <summary>Telemetry settings.</summary>
IncidentTelemetrySettings TelemetrySettings,
/// <summary>Debug capture settings.</summary>
IncidentDebugCaptureSettings DebugCaptureSettings);
/// <summary>
/// Default implementation of pack run incident mode service.
/// </summary>
public sealed class PackRunIncidentModeService : IPackRunIncidentModeService
{
private readonly IPackRunIncidentModeStore _store;
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
private readonly ILogger<PackRunIncidentModeService> _logger;
private readonly TimeProvider _timeProvider;
public PackRunIncidentModeService(
IPackRunIncidentModeStore store,
ILogger<PackRunIncidentModeService> logger,
TimeProvider? timeProvider = null,
IPackRunTimelineEventEmitter? timelineEmitter = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_timelineEmitter = timelineEmitter;
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> ActivateAsync(
IncidentModeActivationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var now = _timeProvider.GetUtcNow();
var settings = GetSettingsForLevel(request.Level);
var expiresAt = request.DurationMinutes.HasValue
? now.AddMinutes(request.DurationMinutes.Value)
: (DateTimeOffset?)null;
var status = new PackRunIncidentModeStatus(
Active: true,
Level: request.Level,
ActivatedAt: now,
ActivationReason: request.Reason,
Source: request.Source,
ExpiresAt: expiresAt,
RetentionPolicy: settings.RetentionPolicy,
TelemetrySettings: settings.TelemetrySettings,
DebugCaptureSettings: settings.DebugCaptureSettings);
await _store.StoreAsync(request.RunId, status, cancellationToken);
// Emit timeline event
await EmitTimelineEventAsync(
request.TenantId,
request.RunId,
PackRunIncidentEventTypes.IncidentModeActivated,
new Dictionary<string, string>
{
["level"] = request.Level.ToString(),
["source"] = request.Source.ToString(),
["reason"] = request.Reason,
["requestedBy"] = request.RequestedBy ?? "system"
},
cancellationToken);
_logger.LogWarning(
"Incident mode activated for run {RunId} at level {Level} due to: {Reason}",
request.RunId,
request.Level,
request.Reason);
return new IncidentModeActivationResult(
Success: true,
Status: status,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId);
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> DeactivateAsync(
string runId,
string? reason = null,
CancellationToken cancellationToken = default)
{
try
{
var current = await _store.GetAsync(runId, cancellationToken);
if (current is null || !current.Active)
{
return new IncidentModeActivationResult(
Success: true,
Status: PackRunIncidentModeStatus.Inactive(),
Error: null);
}
await _store.RemoveAsync(runId, cancellationToken);
var inactive = PackRunIncidentModeStatus.Inactive();
// Emit timeline event (using default tenant since we don't have it)
await EmitTimelineEventAsync(
"default",
runId,
PackRunIncidentEventTypes.IncidentModeDeactivated,
new Dictionary<string, string>
{
["previousLevel"] = current.Level.ToString(),
["reason"] = reason ?? "Manual deactivation",
["activeDuration"] = current.ActivatedAt.HasValue
? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString()
: "unknown"
},
cancellationToken);
_logger.LogInformation(
"Incident mode deactivated for run {RunId}. Reason: {Reason}",
runId,
reason ?? "Manual deactivation");
return new IncidentModeActivationResult(
Success: true,
Status: inactive,
Error: null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId);
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: ex.Message);
}
}
/// <inheritdoc />
public async Task<PackRunIncidentModeStatus> GetStatusAsync(
string runId,
CancellationToken cancellationToken = default)
{
var status = await _store.GetAsync(runId, cancellationToken);
if (status is null)
{
return PackRunIncidentModeStatus.Inactive();
}
// Check if expired
if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow())
{
await _store.RemoveAsync(runId, cancellationToken);
return PackRunIncidentModeStatus.Inactive();
}
return status;
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> HandleSloBreachAsync(
SloBreachNotification notification,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
if (string.IsNullOrWhiteSpace(notification.ResourceId))
{
_logger.LogWarning(
"Received SLO breach notification {BreachId} without resource ID, skipping incident activation",
notification.BreachId);
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: "No resource ID in SLO breach notification");
}
// Map severity to escalation level
var level = notification.Severity?.ToUpperInvariant() switch
{
"CRITICAL" => IncidentEscalationLevel.Critical,
"HIGH" => IncidentEscalationLevel.High,
"MEDIUM" => IncidentEscalationLevel.Medium,
"LOW" => IncidentEscalationLevel.Low,
_ => IncidentEscalationLevel.Medium
};
var request = new IncidentModeActivationRequest(
RunId: notification.ResourceId,
TenantId: notification.TenantId ?? "default",
Level: level,
Source: IncidentModeSource.SloBreach,
Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})",
DurationMinutes: 60, // Auto-expire after 1 hour
RequestedBy: "slo-monitor");
_logger.LogWarning(
"Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}",
notification.BreachId,
notification.SloName,
notification.ResourceId);
return await ActivateAsync(request, cancellationToken);
}
/// <inheritdoc />
public async Task<IncidentModeActivationResult> EscalateAsync(
string runId,
IncidentEscalationLevel newLevel,
string? reason = null,
CancellationToken cancellationToken = default)
{
var current = await _store.GetAsync(runId, cancellationToken);
if (current is null || !current.Active)
{
return new IncidentModeActivationResult(
Success: false,
Status: PackRunIncidentModeStatus.Inactive(),
Error: "Incident mode is not active for this run");
}
if (newLevel <= current.Level)
{
return new IncidentModeActivationResult(
Success: false,
Status: current,
Error: $"Cannot escalate to {newLevel} - current level is {current.Level}");
}
var settings = GetSettingsForLevel(newLevel);
var now = _timeProvider.GetUtcNow();
var escalated = current with
{
Level = newLevel,
ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]",
RetentionPolicy = settings.RetentionPolicy,
TelemetrySettings = settings.TelemetrySettings,
DebugCaptureSettings = settings.DebugCaptureSettings
};
await _store.StoreAsync(runId, escalated, cancellationToken);
// Emit timeline event
await EmitTimelineEventAsync(
"default",
runId,
PackRunIncidentEventTypes.IncidentModeEscalated,
new Dictionary<string, string>
{
["previousLevel"] = current.Level.ToString(),
["newLevel"] = newLevel.ToString(),
["reason"] = reason ?? "Manual escalation"
},
cancellationToken);
_logger.LogWarning(
"Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}",
runId,
current.Level,
newLevel,
reason ?? "Manual escalation");
return new IncidentModeActivationResult(
Success: true,
Status: escalated,
Error: null);
}
/// <inheritdoc />
public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch
{
IncidentEscalationLevel.None => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Default(),
IncidentTelemetrySettings.Default(),
IncidentDebugCaptureSettings.Default()),
IncidentEscalationLevel.Low => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 },
IncidentTelemetrySettings.Default() with
{
EnhancedTelemetryActive = true,
LogVerbosity = IncidentLogVerbosity.Verbose,
TraceSamplingRate = 0.5
},
IncidentDebugCaptureSettings.Default()),
IncidentEscalationLevel.Medium => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Extended(),
IncidentTelemetrySettings.Enhanced(),
IncidentDebugCaptureSettings.Basic()),
IncidentEscalationLevel.High => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 },
IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug },
IncidentDebugCaptureSettings.Full()),
IncidentEscalationLevel.Critical => new IncidentModeSettings(
level,
IncidentRetentionPolicy.Maximum(),
IncidentTelemetrySettings.Maximum(),
IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }),
_ => throw new ArgumentOutOfRangeException(nameof(level))
};
private async Task EmitTimelineEventAsync(
string tenantId,
string runId,
string eventType,
IReadOnlyDictionary<string, string> attributes,
CancellationToken cancellationToken)
{
if (_timelineEmitter is null) return;
await _timelineEmitter.EmitAsync(
PackRunTimelineEvent.Create(
tenantId: tenantId,
eventType: eventType,
source: "taskrunner-incident-mode",
occurredAt: _timeProvider.GetUtcNow(),
runId: runId,
severity: PackRunEventSeverity.Warning,
attributes: attributes),
cancellationToken);
}
}
/// <summary>
/// Incident mode timeline event types.
/// </summary>
public static class PackRunIncidentEventTypes
{
/// <summary>Incident mode activated.</summary>
public const string IncidentModeActivated = "pack.incident.activated";
/// <summary>Incident mode deactivated.</summary>
public const string IncidentModeDeactivated = "pack.incident.deactivated";
/// <summary>Incident mode escalated.</summary>
public const string IncidentModeEscalated = "pack.incident.escalated";
/// <summary>SLO breach detected.</summary>
public const string SloBreachDetected = "pack.incident.slo_breach";
}
/// <summary>
/// In-memory incident mode store for testing.
/// </summary>
public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore
{
private readonly Dictionary<string, PackRunIncidentModeStatus> _statuses = new();
private readonly object _lock = new();
/// <inheritdoc />
public Task StoreAsync(
string runId,
PackRunIncidentModeStatus status,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_statuses[runId] = status;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<PackRunIncidentModeStatus?> GetAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_statuses.TryGetValue(runId, out var status);
return Task.FromResult(status);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListActiveRunsAsync(
CancellationToken cancellationToken = default)
{
lock (_lock)
{
var active = _statuses
.Where(kvp => kvp.Value.Active)
.Select(kvp => kvp.Key)
.ToList();
return Task.FromResult<IReadOnlyList<string>>(active);
}
}
/// <inheritdoc />
public Task RemoveAsync(
string runId,
CancellationToken cancellationToken = default)
{
lock (_lock)
{
_statuses.Remove(runId);
}
return Task.CompletedTask;
}
/// <summary>Gets count of stored statuses.</summary>
public int Count
{
get { lock (_lock) { return _statuses.Count; } }
}
/// <summary>Clears all statuses.</summary>
public void Clear()
{
lock (_lock) { _statuses.Clear(); }
}
}

View File

@@ -0,0 +1,363 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.IncidentMode;
/// <summary>
/// Incident mode status for a pack run.
/// Per TASKRUN-OBS-55-001.
/// </summary>
public sealed record PackRunIncidentModeStatus(
/// <summary>Whether incident mode is active.</summary>
bool Active,
/// <summary>Current escalation level.</summary>
IncidentEscalationLevel Level,
/// <summary>When incident mode was activated.</summary>
DateTimeOffset? ActivatedAt,
/// <summary>Reason for activation.</summary>
string? ActivationReason,
/// <summary>Source of activation (SLO breach, manual, etc.).</summary>
IncidentModeSource Source,
/// <summary>When incident mode will auto-deactivate (if set).</summary>
DateTimeOffset? ExpiresAt,
/// <summary>Current retention policy in effect.</summary>
IncidentRetentionPolicy RetentionPolicy,
/// <summary>Active telemetry escalation settings.</summary>
IncidentTelemetrySettings TelemetrySettings,
/// <summary>Debug artifact capture settings.</summary>
IncidentDebugCaptureSettings DebugCaptureSettings)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Creates a default inactive status.
/// </summary>
public static PackRunIncidentModeStatus Inactive() => new(
Active: false,
Level: IncidentEscalationLevel.None,
ActivatedAt: null,
ActivationReason: null,
Source: IncidentModeSource.None,
ExpiresAt: null,
RetentionPolicy: IncidentRetentionPolicy.Default(),
TelemetrySettings: IncidentTelemetrySettings.Default(),
DebugCaptureSettings: IncidentDebugCaptureSettings.Default());
/// <summary>
/// Serializes to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
}
/// <summary>
/// Incident escalation levels.
/// </summary>
public enum IncidentEscalationLevel
{
/// <summary>No incident mode.</summary>
None = 0,
/// <summary>Low severity - enhanced logging.</summary>
Low = 1,
/// <summary>Medium severity - debug capture enabled.</summary>
Medium = 2,
/// <summary>High severity - full debug + extended retention.</summary>
High = 3,
/// <summary>Critical - maximum telemetry + indefinite retention.</summary>
Critical = 4
}
/// <summary>
/// Source of incident mode activation.
/// </summary>
public enum IncidentModeSource
{
/// <summary>No incident mode.</summary>
None,
/// <summary>Activated manually by operator.</summary>
Manual,
/// <summary>Activated by SLO breach webhook.</summary>
SloBreach,
/// <summary>Activated by error rate threshold.</summary>
ErrorRate,
/// <summary>Activated by policy evaluation.</summary>
PolicyTrigger,
/// <summary>Activated by external system.</summary>
External
}
/// <summary>
/// Retention policy during incident mode.
/// </summary>
public sealed record IncidentRetentionPolicy(
/// <summary>Whether extended retention is active.</summary>
bool ExtendedRetentionActive,
/// <summary>Log retention in days.</summary>
int LogRetentionDays,
/// <summary>Artifact retention in days.</summary>
int ArtifactRetentionDays,
/// <summary>Debug capture retention in days.</summary>
int DebugCaptureRetentionDays,
/// <summary>Trace retention in days.</summary>
int TraceRetentionDays)
{
/// <summary>Default retention policy.</summary>
public static IncidentRetentionPolicy Default() => new(
ExtendedRetentionActive: false,
LogRetentionDays: 7,
ArtifactRetentionDays: 30,
DebugCaptureRetentionDays: 3,
TraceRetentionDays: 7);
/// <summary>Extended retention for incident mode.</summary>
public static IncidentRetentionPolicy Extended() => new(
ExtendedRetentionActive: true,
LogRetentionDays: 90,
ArtifactRetentionDays: 180,
DebugCaptureRetentionDays: 30,
TraceRetentionDays: 90);
/// <summary>Maximum retention for critical incidents.</summary>
public static IncidentRetentionPolicy Maximum() => new(
ExtendedRetentionActive: true,
LogRetentionDays: 365,
ArtifactRetentionDays: 365,
DebugCaptureRetentionDays: 90,
TraceRetentionDays: 365);
}
/// <summary>
/// Telemetry settings during incident mode.
/// </summary>
public sealed record IncidentTelemetrySettings(
/// <summary>Whether enhanced telemetry is active.</summary>
bool EnhancedTelemetryActive,
/// <summary>Log verbosity level.</summary>
IncidentLogVerbosity LogVerbosity,
/// <summary>Trace sampling rate (0.0 to 1.0).</summary>
double TraceSamplingRate,
/// <summary>Whether to capture environment variables.</summary>
bool CaptureEnvironment,
/// <summary>Whether to capture step inputs/outputs.</summary>
bool CaptureStepIo,
/// <summary>Whether to capture network calls.</summary>
bool CaptureNetworkCalls,
/// <summary>Maximum trace spans per step.</summary>
int MaxTraceSpansPerStep)
{
/// <summary>Default telemetry settings.</summary>
public static IncidentTelemetrySettings Default() => new(
EnhancedTelemetryActive: false,
LogVerbosity: IncidentLogVerbosity.Normal,
TraceSamplingRate: 0.1,
CaptureEnvironment: false,
CaptureStepIo: false,
CaptureNetworkCalls: false,
MaxTraceSpansPerStep: 100);
/// <summary>Enhanced telemetry for incident mode.</summary>
public static IncidentTelemetrySettings Enhanced() => new(
EnhancedTelemetryActive: true,
LogVerbosity: IncidentLogVerbosity.Verbose,
TraceSamplingRate: 1.0,
CaptureEnvironment: true,
CaptureStepIo: true,
CaptureNetworkCalls: true,
MaxTraceSpansPerStep: 1000);
/// <summary>Maximum telemetry for debugging.</summary>
public static IncidentTelemetrySettings Maximum() => new(
EnhancedTelemetryActive: true,
LogVerbosity: IncidentLogVerbosity.Debug,
TraceSamplingRate: 1.0,
CaptureEnvironment: true,
CaptureStepIo: true,
CaptureNetworkCalls: true,
MaxTraceSpansPerStep: 10000);
}
/// <summary>
/// Log verbosity levels for incident mode.
/// </summary>
public enum IncidentLogVerbosity
{
/// <summary>Minimal logging (errors only).</summary>
Minimal,
/// <summary>Normal logging.</summary>
Normal,
/// <summary>Verbose logging.</summary>
Verbose,
/// <summary>Debug logging (maximum detail).</summary>
Debug
}
/// <summary>
/// Debug artifact capture settings.
/// </summary>
public sealed record IncidentDebugCaptureSettings(
/// <summary>Whether debug capture is active.</summary>
bool CaptureActive,
/// <summary>Whether to capture heap dumps.</summary>
bool CaptureHeapDumps,
/// <summary>Whether to capture thread dumps.</summary>
bool CaptureThreadDumps,
/// <summary>Whether to capture profiling data.</summary>
bool CaptureProfilingData,
/// <summary>Whether to capture system metrics.</summary>
bool CaptureSystemMetrics,
/// <summary>Maximum capture size in MB.</summary>
int MaxCaptureSizeMb,
/// <summary>Capture interval in seconds.</summary>
int CaptureIntervalSeconds)
{
/// <summary>Default capture settings (disabled).</summary>
public static IncidentDebugCaptureSettings Default() => new(
CaptureActive: false,
CaptureHeapDumps: false,
CaptureThreadDumps: false,
CaptureProfilingData: false,
CaptureSystemMetrics: false,
MaxCaptureSizeMb: 0,
CaptureIntervalSeconds: 0);
/// <summary>Basic debug capture.</summary>
public static IncidentDebugCaptureSettings Basic() => new(
CaptureActive: true,
CaptureHeapDumps: false,
CaptureThreadDumps: true,
CaptureProfilingData: false,
CaptureSystemMetrics: true,
MaxCaptureSizeMb: 100,
CaptureIntervalSeconds: 60);
/// <summary>Full debug capture.</summary>
public static IncidentDebugCaptureSettings Full() => new(
CaptureActive: true,
CaptureHeapDumps: true,
CaptureThreadDumps: true,
CaptureProfilingData: true,
CaptureSystemMetrics: true,
MaxCaptureSizeMb: 500,
CaptureIntervalSeconds: 30);
}
/// <summary>
/// SLO breach notification payload.
/// </summary>
public sealed record SloBreachNotification(
/// <summary>Breach identifier.</summary>
[property: JsonPropertyName("breachId")]
string BreachId,
/// <summary>SLO that was breached.</summary>
[property: JsonPropertyName("sloName")]
string SloName,
/// <summary>Breach severity.</summary>
[property: JsonPropertyName("severity")]
string Severity,
/// <summary>When the breach occurred.</summary>
[property: JsonPropertyName("occurredAt")]
DateTimeOffset OccurredAt,
/// <summary>Current metric value.</summary>
[property: JsonPropertyName("currentValue")]
double CurrentValue,
/// <summary>Threshold that was breached.</summary>
[property: JsonPropertyName("threshold")]
double Threshold,
/// <summary>Target metric value.</summary>
[property: JsonPropertyName("target")]
double Target,
/// <summary>Affected resource (run ID, step ID, etc.).</summary>
[property: JsonPropertyName("resourceId")]
string? ResourceId,
/// <summary>Affected tenant.</summary>
[property: JsonPropertyName("tenantId")]
string? TenantId,
/// <summary>Additional context.</summary>
[property: JsonPropertyName("context")]
IReadOnlyDictionary<string, string>? Context);
/// <summary>
/// Request to activate incident mode.
/// </summary>
public sealed record IncidentModeActivationRequest(
/// <summary>Run ID to activate incident mode for.</summary>
string RunId,
/// <summary>Tenant ID.</summary>
string TenantId,
/// <summary>Escalation level to activate.</summary>
IncidentEscalationLevel Level,
/// <summary>Activation source.</summary>
IncidentModeSource Source,
/// <summary>Reason for activation.</summary>
string Reason,
/// <summary>Duration in minutes (null for indefinite).</summary>
int? DurationMinutes,
/// <summary>Operator or system that requested activation.</summary>
string? RequestedBy);
/// <summary>
/// Result of incident mode activation.
/// </summary>
public sealed record IncidentModeActivationResult(
/// <summary>Whether activation succeeded.</summary>
bool Success,
/// <summary>Current incident mode status.</summary>
PackRunIncidentModeStatus Status,
/// <summary>Error message if activation failed.</summary>
string? Error);

View File

@@ -0,0 +1,105 @@
using StellaOps.TaskRunner.Core.Expressions;
using System.Collections.Immutable;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Planning;
public sealed class TaskPackPlan
{
public TaskPackPlan(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
string hash,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
{
Metadata = metadata;
Inputs = inputs;
Steps = steps;
Hash = hash;
Approvals = approvals;
Secrets = secrets;
Outputs = outputs;
FailurePolicy = failurePolicy;
}
public TaskPackPlanMetadata Metadata { get; }
public IReadOnlyDictionary<string, JsonNode?> Inputs { get; }
public IReadOnlyList<TaskPackPlanStep> Steps { get; }
public string Hash { get; }
public IReadOnlyList<TaskPackPlanApproval> Approvals { get; }
public IReadOnlyList<TaskPackPlanSecret> Secrets { get; }
public IReadOnlyList<TaskPackPlanOutput> Outputs { get; }
public TaskPackPlanFailurePolicy? FailurePolicy { get; }
}
public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
public sealed record TaskPackPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<TaskPackPlanStep>? Children);
public sealed record TaskPackPlanParameterValue(
JsonNode? Value,
string? Expression,
string? Error,
bool RequiresRuntimeValue)
{
internal static TaskPackPlanParameterValue FromResolution(TaskPackValueResolution resolution)
=> new(resolution.Value, resolution.Expression, resolution.Error, resolution.RequiresRuntimeValue);
}
public sealed record TaskPackPlanApproval(
string Id,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate);
public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description);
public sealed record TaskPackPlanOutput(
string Name,
string Type,
TaskPackPlanParameterValue? Path,
TaskPackPlanParameterValue? Expression);
public sealed record TaskPackPlanFailurePolicy(
int MaxAttempts,
int BackoffSeconds,
bool ContinueOnError);
public sealed class TaskPackPlanResult
{
public TaskPackPlanResult(TaskPackPlan? plan, ImmutableArray<TaskPackPlanError> errors)
{
Plan = plan;
Errors = errors;
}
public TaskPackPlan? Plan { get; }
public ImmutableArray<TaskPackPlanError> Errors { get; }
public bool Success => Plan is not null && Errors.IsDefaultOrEmpty;
}
public sealed record TaskPackPlanError(string Path, string Message);

View File

@@ -0,0 +1,120 @@
using StellaOps.TaskRunner.Core.Serialization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Planning;
internal static class TaskPackPlanHasher
{
public static string ComputeHash(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
{
var canonical = new CanonicalPlan(
new CanonicalMetadata(metadata.Name, metadata.Version, metadata.Description, metadata.Tags),
inputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal),
steps.Select(ToCanonicalStep).ToList(),
approvals
.OrderBy(a => a.Id, StringComparer.Ordinal)
.Select(a => new CanonicalApproval(a.Id, a.Grants.OrderBy(g => g, StringComparer.Ordinal).ToList(), a.ExpiresAfter, a.ReasonTemplate))
.ToList(),
secrets
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new CanonicalSecret(s.Name, s.Scope, s.Description))
.ToList(),
outputs
.OrderBy(o => o.Name, StringComparer.Ordinal)
.Select(ToCanonicalOutput)
.ToList(),
failurePolicy is null
? null
: new CanonicalFailurePolicy(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError));
var json = CanonicalJson.Serialize(canonical);
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return $"sha256:{ConvertToHex(hashBytes)}";
}
private static string ConvertToHex(byte[] hashBytes)
{
var builder = new StringBuilder(hashBytes.Length * 2);
foreach (var b in hashBytes)
{
builder.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private static CanonicalPlanStep ToCanonicalStep(TaskPackPlanStep step)
=> new(
step.Id,
step.TemplateId,
step.Name,
step.Type,
step.Enabled,
step.Uses,
step.Parameters?.ToDictionary(
kvp => kvp.Key,
kvp => new CanonicalParameter(kvp.Value.Value, kvp.Value.Expression, kvp.Value.Error, kvp.Value.RequiresRuntimeValue),
StringComparer.Ordinal),
step.ApprovalId,
step.GateMessage,
step.Children?.Select(ToCanonicalStep).ToList());
private sealed record CanonicalPlan(
CanonicalMetadata Metadata,
IDictionary<string, JsonNode?> Inputs,
IReadOnlyList<CanonicalPlanStep> Steps,
IReadOnlyList<CanonicalApproval> Approvals,
IReadOnlyList<CanonicalSecret> Secrets,
IReadOnlyList<CanonicalOutput> Outputs,
CanonicalFailurePolicy? FailurePolicy);
private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
private sealed record CanonicalPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IDictionary<string, CanonicalParameter>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<CanonicalPlanStep>? Children);
private sealed record CanonicalApproval(string Id, IReadOnlyList<string> Grants, string? ExpiresAfter, string? ReasonTemplate);
private sealed record CanonicalSecret(string Name, string Scope, string? Description);
private sealed record CanonicalParameter(JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue);
private sealed record CanonicalOutput(
string Name,
string Type,
CanonicalParameter? Path,
CanonicalParameter? Expression);
private sealed record CanonicalFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
private static CanonicalOutput ToCanonicalOutput(TaskPackPlanOutput output)
=> new(
output.Name,
output.Type,
ToCanonicalParameter(output.Path),
ToCanonicalParameter(output.Expression));
private static CanonicalParameter? ToCanonicalParameter(TaskPackPlanParameterValue? value)
=> value is null ? null : new CanonicalParameter(value.Value, value.Expression, value.Error, value.RequiresRuntimeValue);
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Planning;
public static class TaskPackPlanInsights
{
public static IReadOnlyList<TaskPackPlanApprovalRequirement> CollectApprovalRequirements(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var approvals = plan.Approvals.ToDictionary(approval => approval.Id, StringComparer.Ordinal);
var builders = new Dictionary<string, ApprovalRequirementBuilder>(StringComparer.Ordinal);
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal) && !string.IsNullOrEmpty(step.ApprovalId))
{
if (!builders.TryGetValue(step.ApprovalId, out var builder))
{
builder = new ApprovalRequirementBuilder(step.ApprovalId);
builders[step.ApprovalId] = builder;
}
builder.AddStep(step);
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return builders.Values
.Select(builder => builder.Build(approvals))
.OrderBy(requirement => requirement.ApprovalId, StringComparer.Ordinal)
.ToList();
}
public static IReadOnlyList<TaskPackPlanNotificationHint> CollectNotificationHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var notifications = new List<TaskPackPlanNotificationHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "approval-request", step.GateMessage, step.ApprovalId));
}
else if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "policy-gate", step.GateMessage, null));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return notifications;
}
public static IReadOnlyList<TaskPackPlanPolicyGateHint> CollectPolicyGateHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = new List<TaskPackPlanPolicyGateHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
var parameters = step.Parameters?
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => new TaskPackPlanPolicyParameter(
kvp.Key,
kvp.Value.RequiresRuntimeValue,
kvp.Value.Expression,
kvp.Value.Error))
.ToList() ?? new List<TaskPackPlanPolicyParameter>();
hints.Add(new TaskPackPlanPolicyGateHint(step.Id, step.GateMessage, parameters));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return hints;
}
private sealed class ApprovalRequirementBuilder
{
private readonly HashSet<string> stepIds = new(StringComparer.Ordinal);
private readonly List<string> messages = new();
public ApprovalRequirementBuilder(string approvalId)
{
ApprovalId = approvalId;
}
public string ApprovalId { get; }
public void AddStep(TaskPackPlanStep step)
{
stepIds.Add(step.Id);
if (!string.IsNullOrWhiteSpace(step.GateMessage))
{
messages.Add(step.GateMessage!);
}
}
public TaskPackPlanApprovalRequirement Build(IReadOnlyDictionary<string, TaskPackPlanApproval> knownApprovals)
{
knownApprovals.TryGetValue(ApprovalId, out var approval);
var orderedSteps = stepIds
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var orderedMessages = messages
.Where(message => !string.IsNullOrWhiteSpace(message))
.Distinct(StringComparer.Ordinal)
.ToList();
return new TaskPackPlanApprovalRequirement(
ApprovalId,
approval?.Grants ?? Array.Empty<string>(),
approval?.ExpiresAfter,
approval?.ReasonTemplate,
orderedSteps,
orderedMessages);
}
}
}
public sealed record TaskPackPlanApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages);
public sealed record TaskPackPlanNotificationHint(
string StepId,
string Type,
string? Message,
string? ApprovalId);
public sealed record TaskPackPlanPolicyGateHint(
string StepId,
string? Message,
IReadOnlyList<TaskPackPlanPolicyParameter> Parameters);
public sealed record TaskPackPlanPolicyParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);

View File

@@ -0,0 +1,878 @@
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Expressions;
using StellaOps.TaskRunner.Core.TaskPacks;
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Planning;
public sealed class TaskPackPlanner
{
private static readonly string[] NetworkParameterHints = { "url", "uri", "endpoint", "host", "registry", "mirror", "address" };
private readonly TaskPackManifestValidator validator;
private readonly IEgressPolicy? egressPolicy;
public TaskPackPlanner(IEgressPolicy? egressPolicy = null)
{
validator = new TaskPackManifestValidator();
this.egressPolicy = egressPolicy;
}
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
ValidateSandboxAndSlo(manifest, errors);
var validation = validator.Validate(manifest);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
{
errors.Add(new TaskPackPlanError(error.Path, error.Message));
}
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var effectiveInputs = MaterializeInputs(manifest.Spec.Inputs, providedInputs, errors);
if (errors.Count > 0)
{
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var stepTracker = new HashSet<string>(StringComparer.Ordinal);
var secretTracker = new HashSet<string>(StringComparer.Ordinal);
if (manifest.Spec.Secrets is not null)
{
foreach (var secret in manifest.Spec.Secrets)
{
secretTracker.Add(secret.Name);
}
}
var context = TaskPackExpressionContext.Create(effectiveInputs, stepTracker, secretTracker);
var packName = manifest.Metadata.Name;
var packVersion = manifest.Metadata.Version;
var planSteps = new List<TaskPackPlanStep>();
var steps = manifest.Spec.Steps;
for (var i = 0; i < steps.Count; i++)
{
var step = steps[i];
var planStep = BuildStep(packName, packVersion, step, context, $"spec.steps[{i}]", errors);
planSteps.Add(planStep);
}
if (errors.Count > 0)
{
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var metadata = new TaskPackPlanMetadata(
manifest.Metadata.Name,
manifest.Metadata.Version,
manifest.Metadata.Description,
manifest.Metadata.Tags?.ToList() ?? new List<string>());
var planApprovals = manifest.Spec.Approvals?
.Select(approval => new TaskPackPlanApproval(
approval.Id,
NormalizeGrants(approval.Grants),
approval.ExpiresAfter,
approval.ReasonTemplate))
.ToList() ?? new List<TaskPackPlanApproval>();
var planSecrets = manifest.Spec.Secrets?
.Select(secret => new TaskPackPlanSecret(secret.Name, secret.Scope, secret.Description))
.ToList() ?? new List<TaskPackPlanSecret>();
var planOutputs = MaterializeOutputs(manifest.Spec.Outputs, context, errors);
if (errors.Count > 0)
{
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var failurePolicy = MaterializeFailurePolicy(manifest.Spec.Failure);
var hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs, failurePolicy);
var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs, failurePolicy);
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
}
private static void ValidateSandboxAndSlo(TaskPackManifest manifest, ImmutableArray<TaskPackPlanError>.Builder errors)
{
// TP6: sandbox quotas must be present.
var sandbox = manifest.Spec.Sandbox;
if (sandbox is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox", "Sandbox settings are required (mode, egressAllowlist, CPU/memory, quotaSeconds)."));
}
else
{
if (string.IsNullOrWhiteSpace(sandbox.Mode))
{
errors.Add(new TaskPackPlanError("spec.sandbox.mode", "Sandbox mode is required (sealed or restricted)."));
}
if (sandbox.EgressAllowlist is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox.egressAllowlist", "Egress allowlist must be declared (empty list allowed)."));
}
if (sandbox.CpuLimitMillicores <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.cpuLimitMillicores", "CPU limit must be > 0."));
}
if (sandbox.MemoryLimitMiB <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.memoryLimitMiB", "Memory limit must be > 0."));
}
if (sandbox.QuotaSeconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.quotaSeconds", "quotaSeconds must be > 0."));
}
}
// TP9: SLOs must be declared and positive.
var slo = manifest.Spec.Slo;
if (slo is null)
{
errors.Add(new TaskPackPlanError("spec.slo", "SLO section is required (runP95Seconds, approvalP95Seconds, maxQueueDepth)."));
return;
}
if (slo.RunP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.runP95Seconds", "runP95Seconds must be > 0."));
}
if (slo.ApprovalP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.approvalP95Seconds", "approvalP95Seconds must be > 0."));
}
if (slo.MaxQueueDepth <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.maxQueueDepth", "maxQueueDepth must be > 0."));
}
}
private Dictionary<string, JsonNode?> MaterializeInputs(
IReadOnlyList<TaskPackInput>? definitions,
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var effective = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
if (definitions is not null)
{
foreach (var input in definitions)
{
if ((providedInputs is not null && providedInputs.TryGetValue(input.Name, out var supplied)))
{
effective[input.Name] = supplied?.DeepClone();
}
else if (input.Default is not null)
{
effective[input.Name] = input.Default.DeepClone();
}
else if (input.Required)
{
errors.Add(new TaskPackPlanError($"inputs.{input.Name}", "Input is required but was not supplied."));
}
}
}
if (providedInputs is not null)
{
foreach (var kvp in providedInputs)
{
if (!effective.ContainsKey(kvp.Key))
{
effective[kvp.Key] = kvp.Value?.DeepClone();
}
}
}
return effective;
}
private static TaskPackPlanFailurePolicy? MaterializeFailurePolicy(TaskPackFailure? failure)
{
if (failure?.Retries is not TaskPackRetryPolicy retries)
{
return null;
}
var maxAttempts = retries.MaxAttempts <= 0 ? 1 : retries.MaxAttempts;
var backoffSeconds = retries.BackoffSeconds < 0 ? 0 : retries.BackoffSeconds;
return new TaskPackPlanFailurePolicy(maxAttempts, backoffSeconds, ContinueOnError: false);
}
private TaskPackPlanStep BuildStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackExpressionContext context,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (!TaskPackExpressions.TryEvaluateBoolean(step.When, context, out var enabled, out var whenError))
{
errors.Add(new TaskPackPlanError($"{path}.when", whenError ?? "Failed to evaluate 'when' expression."));
enabled = false;
}
TaskPackPlanStep planStep;
if (step.Run is not null)
{
planStep = BuildRunStep(packName, packVersion, step, step.Run, context, path, enabled, errors);
}
else if (step.Gate is not null)
{
planStep = BuildGateStep(step, step.Gate, context, path, enabled, errors);
}
else if (step.Parallel is not null)
{
planStep = BuildParallelStep(packName, packVersion, step, step.Parallel, context, path, enabled, errors);
}
else if (step.Map is not null)
{
planStep = BuildMapStep(packName, packVersion, step, step.Map, context, path, enabled, errors);
}
else if (step.Loop is not null)
{
planStep = BuildLoopStep(packName, packVersion, step, step.Loop, context, path, enabled, errors);
}
else if (step.Conditional is not null)
{
planStep = BuildConditionalStep(packName, packVersion, step, step.Conditional, context, path, enabled, errors);
}
else
{
errors.Add(new TaskPackPlanError(path, "Step did not specify run, gate, parallel, map, loop, or conditional."));
planStep = new TaskPackPlanStep(step.Id, step.Id, step.Name, "invalid", enabled, null, null, ApprovalId: null, GateMessage: null, Children: null);
}
context.RegisterStep(step.Id);
return planStep;
}
private TaskPackPlanStep BuildRunStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackRunStep run,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = ResolveParameters(run.With, context, $"{path}.run", errors);
if (egressPolicy?.IsSealed == true)
{
ValidateRunStepEgress(packName, packVersion, step, run, parameters, path, errors);
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"run",
enabled,
run.Uses,
parameters,
ApprovalId: null,
GateMessage: null,
Children: null);
}
private void ValidateRunStepEgress(
string packName,
string packVersion,
TaskPackStep step,
TaskPackRunStep run,
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? parameters,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (egressPolicy is null || !egressPolicy.IsSealed)
{
return;
}
var destinations = new List<Uri>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddDestination(Uri uri)
{
if (seen.Add(uri.ToString()))
{
destinations.Add(uri);
}
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
continue;
}
if (TryParseNetworkUri(entry.Url, out var uri))
{
AddDestination(uri);
}
else
{
errors.Add(new TaskPackPlanError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
}
}
var requiresRuntimeNetwork = false;
if (parameters is not null)
{
foreach (var parameter in parameters)
{
var value = parameter.Value;
if (value.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var literal) && TryParseNetworkUri(literal, out var uri))
{
AddDestination(uri);
}
else if (value.RequiresRuntimeValue && MightBeNetworkParameter(parameter.Key))
{
requiresRuntimeNetwork = true;
}
}
}
if (destinations.Count == 0)
{
if (requiresRuntimeNetwork && (run.Egress is null || run.Egress.Count == 0))
{
errors.Add(new TaskPackPlanError(path, $"Step '{step.Id}' references runtime network parameters while sealed mode is enabled. Declare explicit run.egress URLs or remove external calls."));
}
return;
}
foreach (var destination in destinations)
{
try
{
var request = new EgressRequest(
component: "TaskRunner",
destination: destination,
intent: $"taskpack:{packName}@{packVersion}:{step.Id}",
transport: DetermineTransport(destination),
operation: run.Uses);
egressPolicy.EnsureAllowed(request);
}
catch (AirGapEgressBlockedException blocked)
{
var remediation = blocked.Remediation;
errors.Add(new TaskPackPlanError(
path,
$"Step '{step.Id}' attempted to reach '{destination}' in sealed mode and was blocked. Reason: {blocked.Reason}. Remediation: {remediation}"));
}
}
}
private static bool TryParseNetworkUri(string? value, out Uri uri)
{
uri = default!;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed))
{
return false;
}
if (!IsNetworkScheme(parsed))
{
return false;
}
uri = parsed;
return true;
}
private static bool IsNetworkScheme(Uri uri)
=> string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase);
private static bool MightBeNetworkParameter(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
foreach (var hint in NetworkParameterHints)
{
if (name.Contains(hint, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static EgressTransport DetermineTransport(Uri destination)
=> string.Equals(destination.Scheme, "https", StringComparison.OrdinalIgnoreCase)
? EgressTransport.Https
: string.Equals(destination.Scheme, "http", StringComparison.OrdinalIgnoreCase)
? EgressTransport.Http
: EgressTransport.Any;
private static IReadOnlyList<string> NormalizeGrants(IReadOnlyList<string>? grants)
{
if (grants is null || grants.Count == 0)
{
return Array.Empty<string>();
}
var normalized = new List<string>(grants.Count);
foreach (var grant in grants)
{
if (string.IsNullOrWhiteSpace(grant))
{
continue;
}
var segments = grant
.Split('.', StringSplitOptions.RemoveEmptyEntries)
.Select(segment =>
{
var trimmed = segment.Trim();
if (trimmed.Length == 0)
{
return string.Empty;
}
if (trimmed.Length == 1)
{
return trimmed.ToUpperInvariant();
}
var first = char.ToUpperInvariant(trimmed[0]);
var rest = trimmed[1..].ToLowerInvariant();
return string.Concat(first, rest);
})
.Where(segment => segment.Length > 0)
.ToArray();
if (segments.Length == 0)
{
continue;
}
normalized.Add(string.Join('.', segments));
}
return normalized.Count == 0
? Array.Empty<string>()
: normalized;
}
private TaskPackPlanStep BuildGateStep(
TaskPackStep step,
TaskPackGateStep gate,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
string type;
string? approvalId = null;
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? parameters = null;
if (gate.Approval is not null)
{
type = "gate.approval";
approvalId = gate.Approval.Id;
}
else if (gate.Policy is not null)
{
type = "gate.policy";
var resolvedParams = ResolveParameters(gate.Policy.Parameters, context, $"{path}.gate.policy", errors);
var policyParams = new Dictionary<string, TaskPackPlanParameterValue>(
resolvedParams ?? new Dictionary<string, TaskPackPlanParameterValue>(),
StringComparer.Ordinal);
// Store the policy ID in parameters for downstream config extraction
policyParams["policyId"] = new TaskPackPlanParameterValue(JsonValue.Create(gate.Policy.Policy), null, null, false);
parameters = policyParams;
}
else
{
type = "gate";
errors.Add(new TaskPackPlanError($"{path}.gate", "Gate must specify approval or policy."));
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
type,
enabled,
Uses: null,
parameters,
ApprovalId: approvalId,
GateMessage: gate.Message,
Children: null);
}
private TaskPackPlanStep BuildParallelStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackParallelStep parallel,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var children = new List<TaskPackPlanStep>();
for (var i = 0; i < parallel.Steps.Count; i++)
{
var child = BuildStep(packName, packVersion, parallel.Steps[i], context, $"{path}.parallel.steps[{i}]", errors);
children.Add(child);
}
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
if (parallel.MaxParallel.HasValue)
{
parameters["maxParallel"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.MaxParallel.Value), null, null, false);
}
parameters["continueOnError"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.ContinueOnError), null, null, false);
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"parallel",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private TaskPackPlanStep BuildMapStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackMapStep map,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
var itemsResolution = TaskPackExpressions.EvaluateString(map.Items, context);
JsonArray? itemsArray = null;
if (!itemsResolution.Resolved)
{
if (itemsResolution.Error is not null)
{
errors.Add(new TaskPackPlanError($"{path}.map.items", itemsResolution.Error));
}
else
{
errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression requires runtime evaluation. Packs must provide deterministic item lists at plan time."));
}
}
else if (itemsResolution.Value is JsonArray array)
{
itemsArray = (JsonArray?)array.DeepClone();
}
else
{
errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression must resolve to an array."));
}
if (itemsArray is not null)
{
parameters["items"] = new TaskPackPlanParameterValue(itemsArray, null, null, false);
parameters["iterationCount"] = new TaskPackPlanParameterValue(JsonValue.Create(itemsArray.Count), null, null, false);
}
else
{
parameters["items"] = new TaskPackPlanParameterValue(null, map.Items, "Map items expression could not be resolved.", true);
}
var children = new List<TaskPackPlanStep>();
if (itemsArray is not null)
{
for (var i = 0; i < itemsArray.Count; i++)
{
var item = itemsArray[i];
var iterationContext = context.WithItem(item);
var iterationPath = $"{path}.map.step[{i}]";
var templateStep = BuildStep(packName, packVersion, map.Step, iterationContext, iterationPath, errors);
var childId = $"{step.Id}[{i}]::{map.Step.Id}";
var iterationParameters = templateStep.Parameters is null
? new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal)
: new Dictionary<string, TaskPackPlanParameterValue>(templateStep.Parameters);
iterationParameters["item"] = new TaskPackPlanParameterValue(item?.DeepClone(), null, null, false);
var iterationStep = templateStep with
{
Id = childId,
TemplateId = map.Step.Id,
Parameters = iterationParameters
};
children.Add(iterationStep);
}
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"map",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private TaskPackPlanStep BuildLoopStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackLoopStep loop,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
// Store loop configuration parameters
if (!string.IsNullOrWhiteSpace(loop.Items))
{
parameters["items"] = new TaskPackPlanParameterValue(null, loop.Items, null, true);
}
if (loop.Range is not null)
{
var rangeObj = new JsonObject
{
["start"] = loop.Range.Start,
["end"] = loop.Range.End,
["step"] = loop.Range.Step
};
parameters["range"] = new TaskPackPlanParameterValue(rangeObj, null, null, false);
}
if (loop.StaticItems is not null)
{
var staticArray = new JsonArray();
foreach (var item in loop.StaticItems)
{
staticArray.Add(JsonValue.Create(item?.ToString()));
}
parameters["staticItems"] = new TaskPackPlanParameterValue(staticArray, null, null, false);
}
parameters["iterator"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Iterator), null, null, false);
parameters["index"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Index), null, null, false);
parameters["maxIterations"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.MaxIterations), null, null, false);
if (!string.IsNullOrWhiteSpace(loop.Aggregation))
{
parameters["aggregation"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.Aggregation), null, null, false);
}
if (!string.IsNullOrWhiteSpace(loop.OutputPath))
{
parameters["outputPath"] = new TaskPackPlanParameterValue(JsonValue.Create(loop.OutputPath), null, null, false);
}
// Build child steps (the loop body)
var children = new List<TaskPackPlanStep>();
for (var i = 0; i < loop.Steps.Count; i++)
{
var child = BuildStep(packName, packVersion, loop.Steps[i], context, $"{path}.loop.steps[{i}]", errors);
children.Add(child);
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"loop",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private TaskPackPlanStep BuildConditionalStep(
string packName,
string packVersion,
TaskPackStep step,
TaskPackConditionalStep conditional,
TaskPackExpressionContext context,
string path,
bool enabled,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
// Store branch conditions as metadata
var branchesArray = new JsonArray();
foreach (var branch in conditional.Branches)
{
branchesArray.Add(new JsonObject
{
["condition"] = branch.Condition,
["stepCount"] = branch.Steps.Count
});
}
parameters["branches"] = new TaskPackPlanParameterValue(branchesArray, null, null, false);
parameters["outputUnion"] = new TaskPackPlanParameterValue(JsonValue.Create(conditional.OutputUnion), null, null, false);
// Build all branch bodies and else branch as children
var children = new List<TaskPackPlanStep>();
for (var branchIdx = 0; branchIdx < conditional.Branches.Count; branchIdx++)
{
var branch = conditional.Branches[branchIdx];
for (var stepIdx = 0; stepIdx < branch.Steps.Count; stepIdx++)
{
var child = BuildStep(packName, packVersion, branch.Steps[stepIdx], context, $"{path}.conditional.branches[{branchIdx}].steps[{stepIdx}]", errors);
children.Add(child);
}
}
if (conditional.Else is not null)
{
for (var i = 0; i < conditional.Else.Count; i++)
{
var child = BuildStep(packName, packVersion, conditional.Else[i], context, $"{path}.conditional.else[{i}]", errors);
children.Add(child);
}
}
return new TaskPackPlanStep(
step.Id,
step.Id,
step.Name,
"conditional",
enabled,
Uses: null,
parameters,
ApprovalId: null,
GateMessage: null,
Children: children);
}
private IReadOnlyDictionary<string, TaskPackPlanParameterValue>? ResolveParameters(
IDictionary<string, JsonNode?>? rawParameters,
TaskPackExpressionContext context,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (rawParameters is null || rawParameters.Count == 0)
{
return null;
}
var resolved = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
foreach (var (key, value) in rawParameters)
{
var evaluation = TaskPackExpressions.EvaluateValue(value, context);
if (!evaluation.Resolved && evaluation.Error is not null)
{
errors.Add(new TaskPackPlanError($"{path}.with.{key}", evaluation.Error));
}
resolved[key] = TaskPackPlanParameterValue.FromResolution(evaluation);
}
return resolved;
}
private IReadOnlyList<TaskPackPlanOutput> MaterializeOutputs(
IReadOnlyList<TaskPackOutput>? outputs,
TaskPackExpressionContext context,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (outputs is null || outputs.Count == 0)
{
return Array.Empty<TaskPackPlanOutput>();
}
var results = new List<TaskPackPlanOutput>(outputs.Count);
foreach (var (output, index) in outputs.Select((output, index) => (output, index)))
{
var pathValue = ConvertString(output.Path, context, $"spec.outputs[{index}].path", errors);
var expressionValue = ConvertString(output.Expression, context, $"spec.outputs[{index}].expression", errors);
results.Add(new TaskPackPlanOutput(
output.Name,
output.Type,
pathValue,
expressionValue));
}
return results;
}
private TaskPackPlanParameterValue? ConvertString(
string? value,
TaskPackExpressionContext context,
string path,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var resolution = TaskPackExpressions.EvaluateString(value, context);
if (!resolution.Resolved && resolution.Error is not null)
{
errors.Add(new TaskPackPlanError(path, resolution.Error));
}
return TaskPackPlanParameterValue.FromResolution(resolution);
}
}

View File

@@ -0,0 +1,68 @@
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Serialization;
internal static class CanonicalJson
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var node = JsonSerializer.SerializeToNode(value, SerializerOptions);
if (node is null)
{
throw new InvalidOperationException("Unable to serialize value to JSON node.");
}
var canonical = Canonicalize(node);
return canonical.ToJsonString(SerializerOptions);
}
public static JsonNode Canonicalize(JsonNode node)
{
return node switch
{
JsonObject obj => CanonicalizeObject(obj),
JsonArray array => CanonicalizeArray(array),
_ => node.DeepClone()
};
}
private static JsonObject CanonicalizeObject(JsonObject obj)
{
var canonical = new JsonObject();
foreach (var property in obj.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
if (property.Value is null)
{
canonical[property.Key] = null;
}
else
{
canonical[property.Key] = Canonicalize(property.Value);
}
}
return canonical;
}
private static JsonArray CanonicalizeArray(JsonArray array)
{
var canonical = new JsonArray();
foreach (var element in array)
{
canonical.Add(element is null ? null : Canonicalize(element));
}
return canonical;
}
}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# StellaOps.TaskRunner.Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Core/StellaOps.TaskRunner.Core.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,384 @@
using StellaOps.TaskRunner.Core.AirGap;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifest
{
[JsonPropertyName("apiVersion")]
public required string ApiVersion { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("metadata")]
public required TaskPackMetadata Metadata { get; init; }
[JsonPropertyName("spec")]
public required TaskPackSpec Spec { get; init; }
}
public sealed class TaskPackMetadata
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
[JsonPropertyName("tenantVisibility")]
public IReadOnlyList<string>? TenantVisibility { get; init; }
[JsonPropertyName("maintainers")]
public IReadOnlyList<TaskPackMaintainer>? Maintainers { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public sealed class TaskPackMaintainer
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
[JsonPropertyName("approvals")]
public IReadOnlyList<TaskPackApproval>? Approvals { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("outputs")]
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[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
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("schema")]
public string? Schema { get; init; }
[JsonPropertyName("required")]
public bool Required { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("default")]
public JsonNode? Default { get; init; }
}
public sealed class TaskPackSecret
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("scope")]
public required string Scope { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackApproval
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("grants")]
public IReadOnlyList<string> Grants { get; init; } = Array.Empty<string>();
[JsonPropertyName("expiresAfter")]
public string? ExpiresAfter { get; init; }
[JsonPropertyName("reasonTemplate")]
public string? ReasonTemplate { get; init; }
}
public sealed class TaskPackStep
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("when")]
public string? When { get; init; }
[JsonPropertyName("run")]
public TaskPackRunStep? Run { get; init; }
[JsonPropertyName("gate")]
public TaskPackGateStep? Gate { get; init; }
[JsonPropertyName("parallel")]
public TaskPackParallelStep? Parallel { get; init; }
[JsonPropertyName("map")]
public TaskPackMapStep? Map { get; init; }
[JsonPropertyName("loop")]
public TaskPackLoopStep? Loop { get; init; }
[JsonPropertyName("conditional")]
public TaskPackConditionalStep? Conditional { get; init; }
}
public sealed class TaskPackRunStep
{
[JsonPropertyName("uses")]
public required string Uses { get; init; }
[JsonPropertyName("with")]
public IDictionary<string, JsonNode?>? With { get; init; }
[JsonPropertyName("egress")]
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
}
public sealed class TaskPackRunEgress
{
[JsonPropertyName("url")]
public required string Url { get; init; }
[JsonPropertyName("intent")]
public string? Intent { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackGateStep
{
[JsonPropertyName("approval")]
public TaskPackApprovalGate? Approval { get; init; }
[JsonPropertyName("policy")]
public TaskPackPolicyGate? Policy { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackApprovalGate
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("autoExpireAfter")]
public string? AutoExpireAfter { get; init; }
}
public sealed class TaskPackPolicyGate
{
[JsonPropertyName("policy")]
public required string Policy { get; init; }
[JsonPropertyName("parameters")]
public IDictionary<string, JsonNode?>? Parameters { get; init; }
}
public sealed class TaskPackParallelStep
{
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("maxParallel")]
public int? MaxParallel { get; init; }
[JsonPropertyName("continueOnError")]
public bool ContinueOnError { get; init; }
}
public sealed class TaskPackMapStep
{
[JsonPropertyName("items")]
public required string Items { get; init; }
[JsonPropertyName("step")]
public required TaskPackStep Step { get; init; }
}
public sealed class TaskPackLoopStep
{
[JsonPropertyName("items")]
public string? Items { get; init; }
[JsonPropertyName("range")]
public TaskPackLoopRange? Range { get; init; }
[JsonPropertyName("staticItems")]
public IReadOnlyList<object>? StaticItems { get; init; }
[JsonPropertyName("iterator")]
public string Iterator { get; init; } = "item";
[JsonPropertyName("index")]
public string Index { get; init; } = "index";
[JsonPropertyName("maxIterations")]
public int MaxIterations { get; init; } = 1000;
[JsonPropertyName("aggregation")]
public string? Aggregation { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackLoopRange
{
[JsonPropertyName("start")]
public int Start { get; init; }
[JsonPropertyName("end")]
public int End { get; init; }
[JsonPropertyName("step")]
public int Step { get; init; } = 1;
}
public sealed class TaskPackConditionalStep
{
[JsonPropertyName("branches")]
public IReadOnlyList<TaskPackConditionalBranch> Branches { get; init; } = Array.Empty<TaskPackConditionalBranch>();
[JsonPropertyName("else")]
public IReadOnlyList<TaskPackStep>? Else { get; init; }
[JsonPropertyName("outputUnion")]
public bool OutputUnion { get; init; }
}
public sealed class TaskPackConditionalBranch
{
[JsonPropertyName("condition")]
public required string Condition { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackOutput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("expression")]
public string? Expression { get; init; }
}
public sealed class TaskPackSuccess
{
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackFailure
{
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("retries")]
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}

View File

@@ -0,0 +1,169 @@
using System.Collections;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async Task<TaskPackManifest> LoadAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var yaml = await reader.ReadToEndAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return Deserialize(yaml);
}
public TaskPackManifest Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path must not be empty.", nameof(path));
}
using var stream = File.OpenRead(path);
return LoadAsync(stream).GetAwaiter().GetResult();
}
public TaskPackManifest Deserialize(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
using var reader = new StringReader(yaml);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var node = ConvertToJsonNode(yamlObject);
if (node is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var manifest = node.Deserialize<TaskPackManifest>(SerializerOptions);
if (manifest is null)
{
throw new TaskPackManifestLoadException("Unable to deserialize manifest.");
}
return manifest;
}
catch (TaskPackManifestLoadException)
{
throw;
}
catch (Exception ex)
{
throw new TaskPackManifestLoadException(string.Format(CultureInfo.InvariantCulture, "Failed to parse manifest: {0}", ex.Message), ex);
}
}
private static JsonNode? ConvertToJsonNode(object? value)
{
switch (value)
{
case null:
return null;
case string s:
if (bool.TryParse(s, out var boolValue))
{
return JsonValue.Create(boolValue);
}
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
{
return JsonValue.Create(longValue);
}
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
return JsonValue.Create(doubleValue);
}
return JsonValue.Create(s);
case bool b:
return JsonValue.Create(b);
case int i:
return JsonValue.Create(i);
case long l:
return JsonValue.Create(l);
case double d:
return JsonValue.Create(d);
case float f:
return JsonValue.Create(f);
case decimal dec:
return JsonValue.Create(dec);
case IDictionary<object, object> dictionary:
{
var obj = new JsonObject();
foreach (var kvp in dictionary)
{
var key = Convert.ToString(kvp.Key, CultureInfo.InvariantCulture);
if (string.IsNullOrEmpty(key))
{
continue;
}
obj[key] = ConvertToJsonNode(kvp.Value);
}
return obj;
}
case IEnumerable enumerable:
{
var array = new JsonArray();
foreach (var item in enumerable)
{
array.Add(ConvertToJsonNode(item));
}
return array;
}
default:
return JsonValue.Create(value.ToString());
}
}
}
public sealed class TaskPackManifestLoadException : Exception
{
public TaskPackManifestLoadException(string message)
: base(message)
{
}
public TaskPackManifestLoadException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,351 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestValidator
{
private static readonly Regex NameRegex = new("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex VersionRegex = new("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z-.]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public TaskPackManifestValidationResult Validate(TaskPackManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = new List<TaskPackManifestValidationError>();
if (!string.Equals(manifest.ApiVersion, "stellaops.io/pack.v1", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("apiVersion", "Only apiVersion 'stellaops.io/pack.v1' is supported."));
}
if (!string.Equals(manifest.Kind, "TaskPack", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("kind", "Kind must be 'TaskPack'."));
}
ValidateMetadata(manifest.Metadata, errors);
ValidateSpec(manifest.Spec, errors);
return new TaskPackManifestValidationResult(errors.ToImmutableArray());
}
private static void ValidateMetadata(TaskPackMetadata metadata, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name is required."));
}
else if (!NameRegex.IsMatch(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name must follow DNS-1123 naming (lowercase alphanumeric plus '-')."));
}
if (string.IsNullOrWhiteSpace(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version is required."));
}
else if (!VersionRegex.IsMatch(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version must follow SemVer (major.minor.patch[+/-metadata])."));
}
}
private static void ValidateSpec(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Steps is null || spec.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError("spec.steps", "At least one step is required."));
return;
}
var stepIds = new HashSet<string>(StringComparer.Ordinal);
var approvalIds = new HashSet<string>(StringComparer.Ordinal);
if (spec.Approvals is not null)
{
foreach (var approval in spec.Approvals)
{
if (!approvalIds.Add(approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"spec.approvals[{approval.Id}]", "Duplicate approval id."));
}
}
}
ValidateInputs(spec, errors);
ValidateSteps(spec.Steps, "spec.steps", stepIds, approvalIds, errors);
}
private static void ValidateInputs(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Inputs is null)
{
return;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (input, index) in spec.Inputs.Select((input, index) => (input, index)))
{
var prefix = $"spec.inputs[{index}]";
if (!seen.Add(input.Name))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.name", "Duplicate input name."));
}
if (string.IsNullOrWhiteSpace(input.Type))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.type", "Input type is required."));
}
}
}
private static void ValidateSteps(
IReadOnlyList<TaskPackStep> steps,
string pathPrefix,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
foreach (var (step, index) in steps.Select((step, index) => (step, index)))
{
var path = $"{pathPrefix}[{index}]";
if (!stepIds.Add(step.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.id", "Duplicate step id."));
}
var typeCount = (step.Run is not null ? 1 : 0)
+ (step.Gate is not null ? 1 : 0)
+ (step.Parallel is not null ? 1 : 0)
+ (step.Map is not null ? 1 : 0)
+ (step.Loop is not null ? 1 : 0)
+ (step.Conditional is not null ? 1 : 0);
if (typeCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, map, loop, or conditional."));
}
else if (typeCount > 1)
{
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, map, loop, or conditional."));
}
if (step.Run is not null)
{
ValidateRunStep(step.Run, $"{path}.run", errors);
}
if (step.Gate is not null)
{
ValidateGateStep(step.Gate, approvalIds, $"{path}.gate", errors);
}
if (step.Parallel is not null)
{
ValidateParallelStep(step.Parallel, $"{path}.parallel", stepIds, approvalIds, errors);
}
if (step.Map is not null)
{
ValidateMapStep(step.Map, $"{path}.map", stepIds, approvalIds, errors);
}
if (step.Loop is not null)
{
ValidateLoopStep(step.Loop, $"{path}.loop", stepIds, approvalIds, errors);
}
if (step.Conditional is not null)
{
ValidateConditionalStep(step.Conditional, $"{path}.conditional", stepIds, approvalIds, errors);
}
}
}
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(run.Uses))
{
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
continue;
}
if (string.IsNullOrWhiteSpace(entry.Url))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
}
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
}
}
}
}
private static void ValidateGateStep(TaskPackGateStep gate, HashSet<string> approvalIds, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (gate.Approval is null && gate.Policy is null)
{
errors.Add(new TaskPackManifestValidationError(path, "Gate step requires 'approval' or 'policy'."));
return;
}
if (gate.Approval is not null)
{
if (!approvalIds.Contains(gate.Approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.approval.id", $"Approval '{gate.Approval.Id}' is not declared under spec.approvals."));
}
}
}
private static void ValidateParallelStep(
TaskPackParallelStep parallel,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (parallel.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Parallel step requires nested steps."));
return;
}
ValidateSteps(parallel.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
private static void ValidateMapStep(
TaskPackMapStep map,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(map.Items))
{
errors.Add(new TaskPackManifestValidationError($"{path}.items", "Map step requires 'items' expression."));
}
if (map.Step is null)
{
errors.Add(new TaskPackManifestValidationError($"{path}.step", "Map step requires nested step definition."));
}
else
{
ValidateSteps(new[] { map.Step }, $"{path}.step", stepIds, approvalIds, errors);
}
}
private static void ValidateLoopStep(
TaskPackLoopStep loop,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
// Loop must have one of: items expression, range, or staticItems
var sourceCount = (string.IsNullOrWhiteSpace(loop.Items) ? 0 : 1)
+ (loop.Range is not null ? 1 : 0)
+ (loop.StaticItems is not null ? 1 : 0);
if (sourceCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Loop step requires 'items', 'range', or 'staticItems'."));
}
if (loop.MaxIterations <= 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.maxIterations", "maxIterations must be greater than 0."));
}
if (loop.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Loop step requires nested steps."));
}
else
{
ValidateSteps(loop.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
}
private static void ValidateConditionalStep(
TaskPackConditionalStep conditional,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (conditional.Branches.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.branches", "Conditional step requires at least one branch."));
return;
}
for (var i = 0; i < conditional.Branches.Count; i++)
{
var branch = conditional.Branches[i];
var branchPath = $"{path}.branches[{i}]";
if (string.IsNullOrWhiteSpace(branch.Condition))
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.condition", "Branch requires a condition expression."));
}
if (branch.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.steps", "Branch requires nested steps."));
}
else
{
ValidateSteps(branch.Steps, $"{branchPath}.steps", stepIds, approvalIds, errors);
}
}
if (conditional.Else is not null && conditional.Else.Count > 0)
{
ValidateSteps(conditional.Else, $"{path}.else", stepIds, approvalIds, errors);
}
}
}
public sealed record TaskPackManifestValidationError(string Path, string Message);
public sealed class TaskPackManifestValidationResult
{
public TaskPackManifestValidationResult(ImmutableArray<TaskPackManifestValidationError> errors)
{
Errors = errors;
}
public ImmutableArray<TaskPackManifestValidationError> Errors { get; }
public bool IsValid => Errors.IsDefaultOrEmpty;
}

View File

@@ -0,0 +1,402 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Interface for tenant egress policy enforcement per TASKRUN-TEN-48-001.
/// Controls outbound network access based on tenant restrictions.
/// </summary>
public interface ITenantEgressPolicy
{
/// <summary>
/// Checks whether egress to a given URI is allowed for the tenant.
/// </summary>
ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
Uri targetUri,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether egress to a given host and port is allowed for the tenant.
/// </summary>
ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
string host,
int port,
CancellationToken cancellationToken = default);
/// <summary>
/// Records an egress attempt for auditing.
/// </summary>
ValueTask RecordEgressAttemptAsync(
TenantContext tenant,
string runId,
Uri targetUri,
EgressPolicyResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an egress policy check.
/// </summary>
public sealed record EgressPolicyResult
{
public static EgressPolicyResult Allowed { get; } = new() { IsAllowed = true };
public static EgressPolicyResult BlockedByTenant(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.TenantRestriction,
Message = reason
};
public static EgressPolicyResult BlockedByGlobalPolicy(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.GlobalPolicy,
Message = reason
};
public static EgressPolicyResult BlockedBySuspension(string reason) => new()
{
IsAllowed = false,
BlockReason = EgressBlockReason.TenantSuspended,
Message = reason
};
public bool IsAllowed { get; init; }
public EgressBlockReason? BlockReason { get; init; }
public string? Message { get; init; }
public DateTimeOffset? CheckedAt { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Reason for egress being blocked.
/// </summary>
public enum EgressBlockReason
{
/// <summary>
/// Blocked by tenant-specific restrictions.
/// </summary>
TenantRestriction,
/// <summary>
/// Blocked by global policy (blocklist).
/// </summary>
GlobalPolicy,
/// <summary>
/// Blocked because tenant is suspended.
/// </summary>
TenantSuspended,
/// <summary>
/// Blocked because egress is disabled for this environment.
/// </summary>
EgressDisabled
}
/// <summary>
/// Record of an egress attempt for auditing.
/// </summary>
public sealed record EgressAttemptRecord(
string TenantId,
string ProjectId,
string RunId,
Uri TargetUri,
bool WasAllowed,
EgressBlockReason? BlockReason,
string? BlockMessage,
DateTimeOffset Timestamp);
/// <summary>
/// Default implementation of tenant egress policy.
/// </summary>
public sealed partial class TenantEgressPolicy : ITenantEgressPolicy
{
private readonly TenantEgressPolicyOptions _options;
private readonly IEgressAuditLog _auditLog;
private readonly ILogger<TenantEgressPolicy> _logger;
private readonly HashSet<string> _globalAllowlist;
private readonly HashSet<string> _globalBlocklist;
public TenantEgressPolicy(
TenantEgressPolicyOptions options,
IEgressAuditLog auditLog,
ILogger<TenantEgressPolicy> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_auditLog = auditLog ?? throw new ArgumentNullException(nameof(auditLog));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_globalAllowlist = new HashSet<string>(
options.GlobalAllowlist.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
_globalBlocklist = new HashSet<string>(
options.GlobalBlocklist.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
}
public ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
Uri targetUri,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentNullException.ThrowIfNull(targetUri);
return CheckEgressAsync(tenant, targetUri.Host, targetUri.Port, cancellationToken);
}
public ValueTask<EgressPolicyResult> CheckEgressAsync(
TenantContext tenant,
string host,
int port,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(host);
var normalizedHost = NormalizeHost(host);
// Check if tenant is suspended
if (tenant.Restrictions.Suspended)
{
_logger.LogWarning(
"Egress blocked for suspended tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedBySuspension("Tenant is suspended."));
}
// Check global blocklist first
if (IsInList(_globalBlocklist, normalizedHost))
{
_logger.LogWarning(
"Egress blocked by global blocklist for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is in global blocklist."));
}
// Check if tenant egress is completely blocked
if (tenant.Restrictions.EgressBlocked)
{
// Check tenant-specific allowlist
if (!tenant.Restrictions.AllowedEgressDomains.IsDefaultOrEmpty)
{
var tenantAllowlist = new HashSet<string>(
tenant.Restrictions.AllowedEgressDomains.Select(NormalizeHost),
StringComparer.OrdinalIgnoreCase);
if (IsInList(tenantAllowlist, normalizedHost))
{
_logger.LogDebug(
"Egress allowed via tenant allowlist for {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(EgressPolicyResult.Allowed);
}
}
_logger.LogWarning(
"Egress blocked by tenant restriction for {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByTenant($"Egress blocked for tenant {tenant.TenantId}."));
}
// Check global allowlist (if not allowing by default)
if (!_options.AllowByDefault)
{
if (!IsInList(_globalAllowlist, normalizedHost))
{
_logger.LogWarning(
"Egress blocked (not in allowlist) for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(
EgressPolicyResult.BlockedByGlobalPolicy($"Host {host} is not in allowlist."));
}
}
_logger.LogDebug(
"Egress allowed for tenant {TenantId} to {Host}:{Port}.",
tenant.TenantId,
host,
port);
return ValueTask.FromResult(EgressPolicyResult.Allowed);
}
public async ValueTask RecordEgressAttemptAsync(
TenantContext tenant,
string runId,
Uri targetUri,
EgressPolicyResult result,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(targetUri);
ArgumentNullException.ThrowIfNull(result);
var record = new EgressAttemptRecord(
TenantId: tenant.TenantId,
ProjectId: tenant.ProjectId,
RunId: runId,
TargetUri: targetUri,
WasAllowed: result.IsAllowed,
BlockReason: result.BlockReason,
BlockMessage: result.Message,
Timestamp: DateTimeOffset.UtcNow);
await _auditLog.RecordAsync(record, cancellationToken).ConfigureAwait(false);
if (!result.IsAllowed && _options.LogBlockedAttempts)
{
_logger.LogWarning(
"Egress attempt blocked: Tenant={TenantId}, Run={RunId}, Target={TargetUri}, Reason={Reason}",
tenant.TenantId,
runId,
targetUri,
result.Message);
}
}
private static string NormalizeHost(string host)
{
var normalized = host.Trim().ToLowerInvariant();
if (normalized.StartsWith("*."))
{
return normalized; // Keep wildcard prefix
}
return normalized;
}
private static bool IsInList(HashSet<string> list, string host)
{
// Exact match
if (list.Contains(host))
{
return true;
}
// Wildcard match (*.example.com matches sub.example.com)
var parts = host.Split('.');
for (var i = 1; i < parts.Length; i++)
{
var wildcard = "*." + string.Join('.', parts[i..]);
if (list.Contains(wildcard))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Interface for egress audit logging.
/// </summary>
public interface IEgressAuditLog
{
ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default);
IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of egress audit log for testing.
/// </summary>
public sealed class InMemoryEgressAuditLog : IEgressAuditLog
{
private readonly ConcurrentBag<EgressAttemptRecord> _records = [];
public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default)
{
_records.Add(record);
return ValueTask.CompletedTask;
}
public async IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var query = _records
.Where(r => r.TenantId.Equals(tenantId, StringComparison.Ordinal));
if (runId is not null)
{
query = query.Where(r => r.RunId.Equals(runId, StringComparison.Ordinal));
}
if (since.HasValue)
{
query = query.Where(r => r.Timestamp >= since.Value);
}
foreach (var record in query.OrderBy(r => r.Timestamp))
{
cancellationToken.ThrowIfCancellationRequested();
yield return record;
}
}
/// <summary>
/// Gets all records (for testing).
/// </summary>
public IReadOnlyList<EgressAttemptRecord> GetAllRecords() => [.. _records];
}
/// <summary>
/// Null implementation of egress audit log.
/// </summary>
public sealed class NullEgressAuditLog : IEgressAuditLog
{
public static NullEgressAuditLog Instance { get; } = new();
public ValueTask RecordAsync(EgressAttemptRecord record, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public async IAsyncEnumerable<EgressAttemptRecord> GetRecordsAsync(
string tenantId,
string? runId = null,
DateTimeOffset? since = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
}

View File

@@ -0,0 +1,261 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Interface for resolving tenant-scoped storage paths per TASKRUN-TEN-48-001.
/// Ensures all pack run storage (state, logs, artifacts) uses tenant-prefixed paths.
/// </summary>
public interface ITenantScopedStoragePathResolver
{
/// <summary>
/// Gets the tenant-prefixed path for run state storage.
/// </summary>
string GetStatePath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for run logs storage.
/// </summary>
string GetLogsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for run artifacts storage.
/// </summary>
string GetArtifactsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for approval records storage.
/// </summary>
string GetApprovalsPath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant-prefixed path for provenance records storage.
/// </summary>
string GetProvenancePath(TenantContext tenant, string runId);
/// <summary>
/// Gets the tenant prefix for database collection/table queries.
/// </summary>
string GetDatabasePrefix(TenantContext tenant);
/// <summary>
/// Gets the base directory for a tenant's storage.
/// </summary>
string GetTenantBasePath(TenantContext tenant);
/// <summary>
/// Validates that a given path belongs to the specified tenant.
/// </summary>
bool ValidatePathBelongsToTenant(TenantContext tenant, string path);
}
/// <summary>
/// Default implementation of tenant-scoped storage path resolver.
/// </summary>
public sealed class TenantScopedStoragePathResolver : ITenantScopedStoragePathResolver
{
private readonly TenantStoragePathOptions _options;
private readonly string _rootPath;
public TenantScopedStoragePathResolver(
TenantStoragePathOptions options,
string rootPath)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
_rootPath = Path.GetFullPath(rootPath);
}
public string GetStatePath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.StateBasePath, tenant, runId);
}
public string GetLogsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.LogsBasePath, tenant, runId);
}
public string GetArtifactsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ArtifactsBasePath, tenant, runId);
}
public string GetApprovalsPath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ApprovalsBasePath, tenant, runId);
}
public string GetProvenancePath(TenantContext tenant, string runId)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return BuildPath(_options.ProvenanceBasePath, tenant, runId);
}
public string GetDatabasePrefix(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
return _options.PathStrategy switch
{
TenantPathStrategy.Flat => tenant.FlatPrefix,
TenantPathStrategy.Hashed => ComputeHash(tenant.TenantId),
_ => $"{Sanitize(tenant.TenantId)}:{Sanitize(tenant.ProjectId)}"
};
}
public string GetTenantBasePath(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => Path.Combine(
_rootPath,
Sanitize(tenant.TenantId),
Sanitize(tenant.ProjectId)),
TenantPathStrategy.Flat => Path.Combine(
_rootPath,
tenant.FlatPrefix),
TenantPathStrategy.Hashed => Path.Combine(
_rootPath,
ComputeHash(tenant.TenantId),
Sanitize(tenant.ProjectId)),
_ => Path.Combine(_rootPath, tenant.StoragePrefix)
};
}
public bool ValidatePathBelongsToTenant(TenantContext tenant, string path)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var normalizedPath = Path.GetFullPath(path);
// For hierarchical paths, check that the tenant segment is in the path
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => ContainsTenantSegments(normalizedPath, tenant),
TenantPathStrategy.Flat => normalizedPath.Contains(tenant.FlatPrefix, StringComparison.OrdinalIgnoreCase),
TenantPathStrategy.Hashed => normalizedPath.Contains(ComputeHash(tenant.TenantId), StringComparison.OrdinalIgnoreCase)
&& normalizedPath.Contains(Sanitize(tenant.ProjectId), StringComparison.OrdinalIgnoreCase),
_ => ContainsTenantSegments(normalizedPath, tenant)
};
}
private bool ContainsTenantSegments(string path, TenantContext tenant)
{
// Check that path contains the tenant and project segments in order
var tenantSegment = Path.DirectorySeparatorChar + Sanitize(tenant.TenantId) + Path.DirectorySeparatorChar;
var projectSegment = Path.DirectorySeparatorChar + Sanitize(tenant.ProjectId) + Path.DirectorySeparatorChar;
var tenantIndex = path.IndexOf(tenantSegment, StringComparison.OrdinalIgnoreCase);
if (tenantIndex < 0)
{
return false;
}
var projectIndex = path.IndexOf(projectSegment, tenantIndex + tenantSegment.Length - 1, StringComparison.OrdinalIgnoreCase);
return projectIndex > tenantIndex;
}
private string BuildPath(string basePath, TenantContext tenant, string runId)
{
var safeRunId = Sanitize(runId);
return _options.PathStrategy switch
{
TenantPathStrategy.Hierarchical => Path.Combine(
_rootPath,
basePath,
Sanitize(tenant.TenantId),
Sanitize(tenant.ProjectId),
safeRunId),
TenantPathStrategy.Flat => Path.Combine(
_rootPath,
basePath,
$"{tenant.FlatPrefix}_{safeRunId}"),
TenantPathStrategy.Hashed => Path.Combine(
_rootPath,
basePath,
ComputeHash(tenant.TenantId),
Sanitize(tenant.ProjectId),
safeRunId),
_ => Path.Combine(_rootPath, basePath, tenant.StoragePrefix, safeRunId)
};
}
private static string Sanitize(string value)
{
var result = value.Trim().ToLowerInvariant();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
result = result.Replace('/', '_').Replace('\\', '_');
return string.IsNullOrWhiteSpace(result) ? "unknown" : result;
}
private static string ComputeHash(string value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexStringLower(bytes)[..16]; // First 16 chars of hex hash
}
}
/// <summary>
/// Storage path context for a specific pack run with tenant scoping.
/// </summary>
public sealed record TenantScopedStoragePaths(
string StatePath,
string LogsPath,
string ArtifactsPath,
string ApprovalsPath,
string ProvenancePath,
string DatabasePrefix,
string TenantBasePath)
{
/// <summary>
/// Creates storage paths from resolver and tenant context.
/// </summary>
public static TenantScopedStoragePaths Create(
ITenantScopedStoragePathResolver resolver,
TenantContext tenant,
string runId)
{
ArgumentNullException.ThrowIfNull(resolver);
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
return new TenantScopedStoragePaths(
StatePath: resolver.GetStatePath(tenant, runId),
LogsPath: resolver.GetLogsPath(tenant, runId),
ArtifactsPath: resolver.GetArtifactsPath(tenant, runId),
ApprovalsPath: resolver.GetApprovalsPath(tenant, runId),
ProvenancePath: resolver.GetProvenancePath(tenant, runId),
DatabasePrefix: resolver.GetDatabasePrefix(tenant),
TenantBasePath: resolver.GetTenantBasePath(tenant));
}
}

View File

@@ -0,0 +1,426 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Enforces tenant context requirements for pack runs per TASKRUN-TEN-48-001.
/// Validates tenant context, enforces concurrent run limits, and propagates context.
/// </summary>
public interface IPackRunTenantEnforcer
{
/// <summary>
/// Validates that a pack run request has valid tenant context.
/// </summary>
ValueTask<TenantEnforcementResult> ValidateRequestAsync(
PackRunTenantRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates tenant-scoped execution context for a pack run.
/// </summary>
ValueTask<TenantScopedExecutionContext> CreateExecutionContextAsync(
PackRunTenantRequest request,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the start of a pack run for concurrent run tracking.
/// </summary>
ValueTask RecordRunStartAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Records the completion of a pack run for concurrent run tracking.
/// </summary>
ValueTask RecordRunCompletionAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current concurrent run count for a tenant.
/// </summary>
ValueTask<int> GetConcurrentRunCountAsync(
TenantContext tenant,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for a tenant-scoped pack run.
/// </summary>
public sealed record PackRunTenantRequest(
string TenantId,
string ProjectId,
IReadOnlyDictionary<string, string>? Labels = null);
/// <summary>
/// Result of tenant enforcement validation.
/// </summary>
public sealed record TenantEnforcementResult
{
public static TenantEnforcementResult Success(TenantContext tenant) => new()
{
IsValid = true,
Tenant = tenant
};
public static TenantEnforcementResult Failure(string reason, TenantEnforcementFailureKind kind) => new()
{
IsValid = false,
FailureReason = reason,
FailureKind = kind
};
public bool IsValid { get; init; }
public TenantContext? Tenant { get; init; }
public string? FailureReason { get; init; }
public TenantEnforcementFailureKind? FailureKind { get; init; }
}
/// <summary>
/// Kind of tenant enforcement failure.
/// </summary>
public enum TenantEnforcementFailureKind
{
/// <summary>
/// Tenant ID is missing or invalid.
/// </summary>
MissingTenantId,
/// <summary>
/// Project ID is missing or invalid.
/// </summary>
MissingProjectId,
/// <summary>
/// Tenant does not exist or is not found.
/// </summary>
TenantNotFound,
/// <summary>
/// Tenant is suspended.
/// </summary>
TenantSuspended,
/// <summary>
/// Tenant is in read-only mode.
/// </summary>
TenantReadOnly,
/// <summary>
/// Tenant has reached maximum concurrent runs.
/// </summary>
MaxConcurrentRunsReached,
/// <summary>
/// Tenant validation failed for another reason.
/// </summary>
ValidationFailed
}
/// <summary>
/// Tenant-scoped execution context for a pack run.
/// </summary>
public sealed record TenantScopedExecutionContext(
TenantContext Tenant,
TenantScopedStoragePaths StoragePaths,
IReadOnlyDictionary<string, object> LoggingScope);
/// <summary>
/// Default implementation of pack run tenant enforcer.
/// </summary>
public sealed class PackRunTenantEnforcer : IPackRunTenantEnforcer
{
private readonly ITenantContextProvider _tenantProvider;
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenancyEnforcementOptions _options;
private readonly IConcurrentRunTracker _runTracker;
private readonly ILogger<PackRunTenantEnforcer> _logger;
public PackRunTenantEnforcer(
ITenantContextProvider tenantProvider,
ITenantScopedStoragePathResolver pathResolver,
TenancyEnforcementOptions options,
IConcurrentRunTracker runTracker,
ILogger<PackRunTenantEnforcer> logger)
{
_tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_options = options ?? throw new ArgumentNullException(nameof(options));
_runTracker = runTracker ?? throw new ArgumentNullException(nameof(runTracker));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<TenantEnforcementResult> ValidateRequestAsync(
PackRunTenantRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Validate tenant ID
if (string.IsNullOrWhiteSpace(request.TenantId))
{
_logger.LogWarning("Pack run request rejected: missing tenant ID.");
return TenantEnforcementResult.Failure(
"Tenant ID is required for pack runs.",
TenantEnforcementFailureKind.MissingTenantId);
}
// Validate project ID (if required)
if (_options.RequireProjectId && string.IsNullOrWhiteSpace(request.ProjectId))
{
_logger.LogWarning(
"Pack run request rejected for tenant {TenantId}: missing project ID.",
request.TenantId);
return TenantEnforcementResult.Failure(
"Project ID is required for pack runs.",
TenantEnforcementFailureKind.MissingProjectId);
}
// Get tenant context
var tenant = await _tenantProvider.GetContextAsync(
request.TenantId,
request.ProjectId,
cancellationToken).ConfigureAwait(false);
if (tenant is null && _options.ValidateTenantExists)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId}/{ProjectId} not found.",
request.TenantId,
request.ProjectId);
return TenantEnforcementResult.Failure(
$"Tenant {request.TenantId}/{request.ProjectId} not found.",
TenantEnforcementFailureKind.TenantNotFound);
}
// Create tenant context if provider didn't return one
tenant ??= new TenantContext(request.TenantId, request.ProjectId, request.Labels);
// Validate tenant status
if (_options.BlockSuspendedTenants)
{
var validation = await _tenantProvider.ValidateAsync(tenant, cancellationToken)
.ConfigureAwait(false);
if (!validation.IsValid)
{
_logger.LogWarning(
"Pack run request rejected for tenant {TenantId}: {Reason}",
request.TenantId,
validation.Reason);
var kind = validation.IsSuspended
? TenantEnforcementFailureKind.TenantSuspended
: TenantEnforcementFailureKind.ValidationFailed;
return TenantEnforcementResult.Failure(
validation.Reason ?? "Tenant validation failed.",
kind);
}
}
// Check read-only mode
if (tenant.Restrictions.ReadOnly)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId} is in read-only mode.",
request.TenantId);
return TenantEnforcementResult.Failure(
"Tenant is in read-only mode.",
TenantEnforcementFailureKind.TenantReadOnly);
}
// Check concurrent run limit
var maxConcurrent = tenant.Restrictions.MaxConcurrentRuns ?? _options.DefaultMaxConcurrentRuns;
var currentCount = await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken)
.ConfigureAwait(false);
if (currentCount >= maxConcurrent)
{
_logger.LogWarning(
"Pack run request rejected: tenant {TenantId} has reached max concurrent runs ({Count}/{Max}).",
request.TenantId,
currentCount,
maxConcurrent);
return TenantEnforcementResult.Failure(
$"Maximum concurrent runs ({maxConcurrent}) reached for tenant.",
TenantEnforcementFailureKind.MaxConcurrentRunsReached);
}
_logger.LogInformation(
"Pack run request validated for tenant {TenantId}/{ProjectId}.",
request.TenantId,
request.ProjectId);
return TenantEnforcementResult.Success(tenant);
}
public async ValueTask<TenantScopedExecutionContext> CreateExecutionContextAsync(
PackRunTenantRequest request,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var validationResult = await ValidateRequestAsync(request, cancellationToken)
.ConfigureAwait(false);
if (!validationResult.IsValid)
{
throw new TenantEnforcementException(
validationResult.FailureReason ?? "Tenant validation failed.",
validationResult.FailureKind ?? TenantEnforcementFailureKind.ValidationFailed);
}
var tenant = validationResult.Tenant!;
var storagePaths = TenantScopedStoragePaths.Create(_pathResolver, tenant, runId);
var loggingScope = new Dictionary<string, object>(tenant.ToLoggingScope())
{
["RunId"] = runId
};
return new TenantScopedExecutionContext(tenant, storagePaths, loggingScope);
}
public async ValueTask RecordRunStartAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await _runTracker.IncrementAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Recorded run start for tenant {TenantId}, run {RunId}.",
tenant.TenantId,
runId);
}
public async ValueTask RecordRunCompletionAsync(
TenantContext tenant,
string runId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
await _runTracker.DecrementAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Recorded run completion for tenant {TenantId}, run {RunId}.",
tenant.TenantId,
runId);
}
public async ValueTask<int> GetConcurrentRunCountAsync(
TenantContext tenant,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tenant);
return await _runTracker.GetCountAsync(tenant.TenantId, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>
/// Exception thrown when tenant enforcement fails.
/// </summary>
public sealed class TenantEnforcementException : Exception
{
public TenantEnforcementException(string message, TenantEnforcementFailureKind kind)
: base(message)
{
Kind = kind;
}
public TenantEnforcementFailureKind Kind { get; }
}
/// <summary>
/// Interface for tracking concurrent pack runs per tenant.
/// </summary>
public interface IConcurrentRunTracker
{
ValueTask<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default);
ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of concurrent run tracker for testing.
/// </summary>
public sealed class InMemoryConcurrentRunTracker : IConcurrentRunTracker
{
private readonly Dictionary<string, HashSet<string>> _runsByTenant = new(StringComparer.Ordinal);
private readonly object _lock = new();
public ValueTask<int> GetCountAsync(string tenantId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
return ValueTask.FromResult(
_runsByTenant.TryGetValue(tenantId, out var runs) ? runs.Count : 0);
}
}
public ValueTask IncrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_runsByTenant.TryGetValue(tenantId, out var runs))
{
runs = new HashSet<string>(StringComparer.Ordinal);
_runsByTenant[tenantId] = runs;
}
runs.Add(runId);
}
return ValueTask.CompletedTask;
}
public ValueTask DecrementAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_runsByTenant.TryGetValue(tenantId, out var runs))
{
runs.Remove(runId);
if (runs.Count == 0)
{
_runsByTenant.Remove(tenantId);
}
}
}
return ValueTask.CompletedTask;
}
/// <summary>
/// Gets all active runs for a tenant (for testing).
/// </summary>
public IReadOnlySet<string> GetActiveRuns(string tenantId)
{
lock (_lock)
{
return _runsByTenant.TryGetValue(tenantId, out var runs)
? new HashSet<string>(runs)
: new HashSet<string>();
}
}
}

View File

@@ -0,0 +1,153 @@
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Configuration options for tenancy enforcement per TASKRUN-TEN-48-001.
/// </summary>
public sealed class TenancyEnforcementOptions
{
/// <summary>
/// Whether tenancy enforcement is enabled. When true, all pack runs
/// must have valid tenant context.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to require project ID in addition to tenant ID.
/// </summary>
public bool RequireProjectId { get; set; } = true;
/// <summary>
/// Whether to enforce tenant-prefixed storage paths.
/// </summary>
public bool EnforceStoragePrefixes { get; set; } = true;
/// <summary>
/// Whether to enforce egress policies for restricted tenants.
/// </summary>
public bool EnforceEgressPolicies { get; set; } = true;
/// <summary>
/// Whether to propagate tenant context to step logs.
/// </summary>
public bool PropagateToLogs { get; set; } = true;
/// <summary>
/// Whether to block runs for suspended tenants.
/// </summary>
public bool BlockSuspendedTenants { get; set; } = true;
/// <summary>
/// Whether to validate tenant exists before starting run.
/// </summary>
public bool ValidateTenantExists { get; set; } = true;
/// <summary>
/// Default maximum concurrent runs per tenant when not specified
/// in tenant restrictions.
/// </summary>
public int DefaultMaxConcurrentRuns { get; set; } = 10;
/// <summary>
/// Default retention period in days for run artifacts when not specified
/// in tenant restrictions.
/// </summary>
public int DefaultRetentionDays { get; set; } = 30;
/// <summary>
/// Storage path configuration for tenant scoping.
/// </summary>
public TenantStoragePathOptions Storage { get; set; } = new();
/// <summary>
/// Egress policy configuration.
/// </summary>
public TenantEgressPolicyOptions Egress { get; set; } = new();
}
/// <summary>
/// Storage path options for tenant scoping.
/// </summary>
public sealed class TenantStoragePathOptions
{
/// <summary>
/// Path segment strategy for tenant prefixes.
/// </summary>
public TenantPathStrategy PathStrategy { get; set; } = TenantPathStrategy.Hierarchical;
/// <summary>
/// Base path for run state storage.
/// </summary>
public string StateBasePath { get; set; } = "runs";
/// <summary>
/// Base path for run logs storage.
/// </summary>
public string LogsBasePath { get; set; } = "logs";
/// <summary>
/// Base path for run artifacts storage.
/// </summary>
public string ArtifactsBasePath { get; set; } = "artifacts";
/// <summary>
/// Base path for approval records storage.
/// </summary>
public string ApprovalsBasePath { get; set; } = "approvals";
/// <summary>
/// Base path for provenance records storage.
/// </summary>
public string ProvenanceBasePath { get; set; } = "provenance";
}
/// <summary>
/// Tenant path strategy for storage prefixes.
/// </summary>
public enum TenantPathStrategy
{
/// <summary>
/// Hierarchical paths: {base}/{tenantId}/{projectId}/{runId}
/// </summary>
Hierarchical,
/// <summary>
/// Flat paths with prefix: {base}/{tenantId}_{projectId}_{runId}
/// </summary>
Flat,
/// <summary>
/// Hashed tenant prefixes for privacy: {base}/{hash(tenantId)}/{projectId}/{runId}
/// </summary>
Hashed
}
/// <summary>
/// Egress policy options for tenant scoping.
/// </summary>
public sealed class TenantEgressPolicyOptions
{
/// <summary>
/// Whether to allow egress by default when not restricted.
/// </summary>
public bool AllowByDefault { get; set; } = true;
/// <summary>
/// Global egress allowlist applied to all tenants.
/// </summary>
public List<string> GlobalAllowlist { get; set; } = [];
/// <summary>
/// Global egress blocklist applied to all tenants.
/// </summary>
public List<string> GlobalBlocklist { get; set; } = [];
/// <summary>
/// Whether to log blocked egress attempts.
/// </summary>
public bool LogBlockedAttempts { get; set; } = true;
/// <summary>
/// Whether to fail the run on blocked egress attempts.
/// </summary>
public bool FailOnBlockedAttempts { get; set; } = false;
}

View File

@@ -0,0 +1,228 @@
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Tenancy;
/// <summary>
/// Tenant context for pack runs per TASKRUN-TEN-48-001.
/// Provides required tenant/project context for every pack run, enabling
/// tenant-scoped storage prefixes and egress policy enforcement.
/// </summary>
public sealed record TenantContext
{
/// <summary>
/// Creates a new tenant context. Both tenant and project IDs are required.
/// </summary>
public TenantContext(
string tenantId,
string projectId,
IReadOnlyDictionary<string, string>? labels = null,
TenantRestrictions? restrictions = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
TenantId = tenantId.Trim();
ProjectId = projectId.Trim();
Labels = labels?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty;
Restrictions = restrictions ?? TenantRestrictions.None;
}
/// <summary>
/// Unique identifier for the tenant (organization/account).
/// </summary>
public string TenantId { get; }
/// <summary>
/// Unique identifier for the project within the tenant.
/// </summary>
public string ProjectId { get; }
/// <summary>
/// Optional labels for filtering and grouping.
/// </summary>
public ImmutableDictionary<string, string> Labels { get; }
/// <summary>
/// Restrictions applied to this tenant context.
/// </summary>
public TenantRestrictions Restrictions { get; }
/// <summary>
/// Gets a storage-safe path prefix for this tenant context.
/// Format: {tenantId}/{projectId}
/// </summary>
public string StoragePrefix => $"{SanitizePathSegment(TenantId)}/{SanitizePathSegment(ProjectId)}";
/// <summary>
/// Gets a flat storage key prefix for this tenant context.
/// Format: {tenantId}_{projectId}
/// </summary>
public string FlatPrefix => $"{SanitizePathSegment(TenantId)}_{SanitizePathSegment(ProjectId)}";
/// <summary>
/// Creates a logging scope dictionary with tenant context.
/// </summary>
public IReadOnlyDictionary<string, object> ToLoggingScope() =>
new Dictionary<string, object>
{
["TenantId"] = TenantId,
["ProjectId"] = ProjectId
};
private static string SanitizePathSegment(string value)
{
var result = value.Trim().ToLowerInvariant();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
// Also replace path separators for flat prefixes
result = result.Replace('/', '_').Replace('\\', '_');
return string.IsNullOrWhiteSpace(result) ? "unknown" : result;
}
}
/// <summary>
/// Restrictions that can be applied to a tenant context.
/// </summary>
public sealed record TenantRestrictions
{
public static TenantRestrictions None { get; } = new();
/// <summary>
/// Whether egress (outbound network) is blocked for this tenant.
/// </summary>
public bool EgressBlocked { get; init; }
/// <summary>
/// Allowed egress domains when egress is restricted (not fully blocked).
/// Empty means all domains blocked when EgressBlocked is true.
/// </summary>
public ImmutableArray<string> AllowedEgressDomains { get; init; } = [];
/// <summary>
/// Whether the tenant is in read-only mode (no writes allowed).
/// </summary>
public bool ReadOnly { get; init; }
/// <summary>
/// Whether the tenant is suspended (no operations allowed).
/// </summary>
public bool Suspended { get; init; }
/// <summary>
/// Maximum concurrent pack runs allowed for this tenant.
/// Null means unlimited.
/// </summary>
public int? MaxConcurrentRuns { get; init; }
/// <summary>
/// Maximum retention period for run artifacts in days.
/// Null means default retention applies.
/// </summary>
public int? MaxRetentionDays { get; init; }
}
/// <summary>
/// Provider interface for tenant context resolution.
/// </summary>
public interface ITenantContextProvider
{
/// <summary>
/// Gets the tenant context for a given tenant and project ID.
/// </summary>
ValueTask<TenantContext?> GetContextAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates that the tenant context is active and not suspended.
/// </summary>
ValueTask<TenantValidationResult> ValidateAsync(
TenantContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of tenant validation.
/// </summary>
public sealed record TenantValidationResult
{
public static TenantValidationResult Valid { get; } = new() { IsValid = true };
public static TenantValidationResult Invalid(string reason) => new()
{
IsValid = false,
Reason = reason
};
public static TenantValidationResult Suspended(string reason) => new()
{
IsValid = false,
IsSuspended = true,
Reason = reason
};
public bool IsValid { get; init; }
public bool IsSuspended { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// In-memory implementation of tenant context provider for testing.
/// </summary>
public sealed class InMemoryTenantContextProvider : ITenantContextProvider
{
private readonly Dictionary<string, TenantContext> _contexts = new(StringComparer.Ordinal);
private readonly HashSet<string> _suspendedTenants = new(StringComparer.Ordinal);
public ValueTask<TenantContext?> GetContextAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default)
{
var key = $"{tenantId}:{projectId}";
return ValueTask.FromResult(_contexts.TryGetValue(key, out var context) ? context : null);
}
public ValueTask<TenantValidationResult> ValidateAsync(
TenantContext context,
CancellationToken cancellationToken = default)
{
if (context.Restrictions.Suspended || _suspendedTenants.Contains(context.TenantId))
{
return ValueTask.FromResult(TenantValidationResult.Suspended("Tenant is suspended."));
}
return ValueTask.FromResult(TenantValidationResult.Valid);
}
/// <summary>
/// Registers a tenant context (for testing).
/// </summary>
public void Register(TenantContext context)
{
var key = $"{context.TenantId}:{context.ProjectId}";
_contexts[key] = context;
}
/// <summary>
/// Suspends a tenant (for testing).
/// </summary>
public void Suspend(string tenantId)
{
_suspendedTenants.Add(tenantId);
}
/// <summary>
/// Unsuspends a tenant (for testing).
/// </summary>
public void Unsuspend(string tenantId)
{
_suspendedTenants.Remove(tenantId);
}
}

View File

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

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// Executes built-in bundle ingestion helpers: validates checksums and stages bundles to a deterministic destination.
/// </summary>
public sealed class BundleIngestionStepExecutor : IPackRunStepExecutor
{
private const string BuiltInUses = "bundle.ingest";
private readonly string stagingRoot;
private readonly ILogger<BundleIngestionStepExecutor> logger;
public BundleIngestionStepExecutor(IOptions<PackRunWorkerOptions> options, ILogger<BundleIngestionStepExecutor> logger)
{
ArgumentNullException.ThrowIfNull(options);
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
stagingRoot = Path.Combine(options.Value.ArtifactsPath, "bundles");
Directory.CreateDirectory(stagingRoot);
}
public Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken)
{
// Non-bundle helpers are treated as no-op success for now.
if (!IsBundleIngestStep(step))
{
return Task.FromResult(PackRunStepExecutionResult.Success());
}
var sourcePath = GetString(parameters, "path");
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
{
return Task.FromResult(PackRunStepExecutionResult.Failure("Bundle path missing or not found."));
}
var checksum = GetString(parameters, "checksum") ?? GetString(parameters, "checksumSha256");
if (string.IsNullOrWhiteSpace(checksum))
{
return Task.FromResult(PackRunStepExecutionResult.Failure("Checksum is required for bundle ingestion."));
}
var actual = ComputeSha256(sourcePath);
if (!checksum.Equals(actual, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(PackRunStepExecutionResult.Failure($"Checksum mismatch: expected {checksum}, actual {actual}."));
}
var deterministicDir = Path.Combine(stagingRoot, checksum.ToLowerInvariant());
var destination = GetString(parameters, "destinationPath")
?? Path.Combine(deterministicDir, Path.GetFileName(sourcePath));
try
{
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(sourcePath, destination, overwrite: true);
// Persist deterministic metadata for downstream evidence
var metadataPath = Path.Combine(deterministicDir, "metadata.json");
var metadata = new
{
source = Path.GetFullPath(sourcePath),
checksumSha256 = checksum.ToLowerInvariant(),
stagedPath = Path.GetFullPath(destination)
};
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = false
});
File.WriteAllText(metadataPath, json);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to stage bundle to {Destination}.", destination);
return Task.FromResult(PackRunStepExecutionResult.Failure("Failed to stage bundle."));
}
return Task.FromResult(PackRunStepExecutionResult.Success());
}
private static string? GetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return null;
}
return jsonValue.TryGetValue<string>(out var result) ? result : null;
}
private static bool IsBundleIngestStep(PackRunExecutionStep step)
=> !string.IsNullOrWhiteSpace(step.Uses) &&
step.Kind == PackRunStepKind.Run &&
step.Uses.Contains(BuiltInUses, StringComparison.OrdinalIgnoreCase);
private static string ComputeSha256(string path)
{
using var stream = File.OpenRead(path);
var hash = SHA256.HashData(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,119 @@
using StellaOps.TaskRunner.Core.Execution;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilePackRunApprovalStore : IPackRunApprovalStore
{
private readonly string rootPath;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public FilePackRunApprovalStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = rootPath;
Directory.CreateDirectory(rootPath);
}
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
var json = SerializeApprovals(approvals);
File.WriteAllText(path, json);
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
if (!File.Exists(path))
{
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)Array.Empty<PackRunApprovalState>());
}
var json = File.ReadAllText(path);
var approvals = DeserializeApprovals(json);
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)approvals);
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
var approvals = (await GetAsync(runId, cancellationToken).ConfigureAwait(false)).ToList();
var index = approvals.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal));
if (index < 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
approvals[index] = approval;
await SaveAsync(runId, approvals, cancellationToken).ConfigureAwait(false);
}
private string GetFilePath(string runId)
{
var safeFile = $"{runId}.json";
return Path.Combine(rootPath, safeFile);
}
private string SerializeApprovals(IReadOnlyList<PackRunApprovalState> approvals)
{
var array = new JsonArray();
foreach (var approval in approvals)
{
var node = new JsonObject
{
["approvalId"] = approval.ApprovalId,
["status"] = approval.Status.ToString(),
["requestedAt"] = approval.RequestedAt,
["actorId"] = approval.ActorId,
["completedAt"] = approval.CompletedAt,
["summary"] = approval.Summary,
["requiredGrants"] = new JsonArray(approval.RequiredGrants.Select(grant => (JsonNode)grant).ToArray()),
["stepIds"] = new JsonArray(approval.StepIds.Select(step => (JsonNode)step).ToArray()),
["messages"] = new JsonArray(approval.Messages.Select(message => (JsonNode)message).ToArray()),
["reasonTemplate"] = approval.ReasonTemplate
};
array.Add(node);
}
return array.ToJsonString(serializerOptions);
}
private static IReadOnlyList<PackRunApprovalState> DeserializeApprovals(string json)
{
var array = JsonNode.Parse(json)?.AsArray() ?? new JsonArray();
var list = new List<PackRunApprovalState>(array.Count);
foreach (var entry in array)
{
if (entry is not JsonObject obj)
{
continue;
}
var requiredGrants = obj["requiredGrants"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var stepIds = obj["stepIds"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var messages = obj["messages"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
Enum.TryParse(obj["status"]?.GetValue<string>(), ignoreCase: true, out PackRunApprovalStatus status);
list.Add(new PackRunApprovalState(
obj["approvalId"]?.GetValue<string>() ?? string.Empty,
requiredGrants,
stepIds,
messages,
obj["reasonTemplate"]?.GetValue<string>(),
obj["requestedAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.UtcNow,
status,
obj["actorId"]?.GetValue<string>(),
obj["completedAt"]?.GetValue<DateTimeOffset?>(),
obj["summary"]?.GetValue<string>()));
}
return list;
}
}

View File

@@ -0,0 +1,163 @@
using StellaOps.TaskRunner.Core.Execution;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// Persists pack run logs as newline-delimited JSON for deterministic replay and offline mirroring.
/// </summary>
public sealed class FilePackRunLogStore : IPackRunLogStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly string rootPath;
private readonly ConcurrentDictionary<string, SemaphoreSlim> fileLocks = new(StringComparer.Ordinal);
public FilePackRunLogStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
Directory.CreateDirectory(this.rootPath);
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
var path = GetPath(runId);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var gate = fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var document = PackRunLogEntryDocument.FromDomain(entry);
var json = JsonSerializer.Serialize(document, SerializerOptions);
await File.AppendAllTextAsync(path, json + Environment.NewLine, cancellationToken).ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetPath(runId);
if (!File.Exists(path))
{
yield break;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync().ConfigureAwait(false);
if (line is null)
{
yield break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
PackRunLogEntryDocument? document = null;
try
{
document = JsonSerializer.Deserialize<PackRunLogEntryDocument>(line, SerializerOptions);
}
catch
{
// Skip malformed entries to avoid stopping the stream; diagnostics are captured via worker logs.
}
if (document is null)
{
continue;
}
yield return document.ToDomain();
}
}
public Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetPath(runId);
return Task.FromResult(File.Exists(path));
}
private string GetPath(string runId)
{
var safe = Sanitize(runId);
return Path.Combine(rootPath, $"{safe}.ndjson");
}
private static string Sanitize(string value)
{
var result = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
return string.IsNullOrWhiteSpace(result) ? "run" : result;
}
private sealed record PackRunLogEntryDocument(
DateTimeOffset Timestamp,
string Level,
string EventType,
string Message,
string? StepId,
Dictionary<string, string>? Metadata)
{
public static PackRunLogEntryDocument FromDomain(PackRunLogEntry entry)
{
var metadata = entry.Metadata is null
? null
: new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal);
return new PackRunLogEntryDocument(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
public PackRunLogEntry ToDomain()
{
IReadOnlyDictionary<string, string>? metadata = Metadata is null
? null
: new Dictionary<string, string>(Metadata, StringComparer.Ordinal);
return new PackRunLogEntry(
Timestamp,
Level,
EventType,
Message,
StepId,
metadata);
}
}
}

View File

@@ -0,0 +1,201 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// File-system backed implementation of <see cref="IPackRunStateStore"/> intended for development and air-gapped smoke tests.
/// </summary>
public sealed class FilePackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly string rootPath;
private readonly SemaphoreSlim mutex = new(1, 1);
public FilePackRunStateStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
Directory.CreateDirectory(this.rootPath);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetPath(runId);
if (!File.Exists(path))
{
return null;
}
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return document?.ToDomain();
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var path = GetPath(state.RunId);
var document = StateDocument.FromDomain(state);
Directory.CreateDirectory(rootPath);
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
finally
{
mutex.Release();
}
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(rootPath))
{
return Array.Empty<PackRunState>();
}
var states = new List<PackRunState>();
var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is not null)
{
states.Add(document.ToDomain());
}
}
return states;
}
private string GetPath(string runId)
{
var safeName = SanitizeFileName(runId);
return Path.Combine(rootPath, $"{safeName}.json");
}
private static string SanitizeFileName(string value)
{
var result = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
return result;
}
private sealed record StateDocument(
string RunId,
string PlanHash,
TaskPackPlan Plan,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyList<StepDocument> Steps,
string? TenantId)
{
public static StateDocument FromDomain(PackRunState state)
{
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new StepDocument(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
return new StateDocument(
state.RunId,
state.PlanHash,
state.Plan,
state.FailurePolicy,
state.RequestedAt,
state.CreatedAt,
state.UpdatedAt,
steps,
state.TenantId);
}
public PackRunState ToDomain()
{
var steps = Steps.ToDictionary(
step => step.StepId,
step => new PackRunStepStateRecord(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason),
StringComparer.Ordinal);
return new PackRunState(
RunId,
PlanHash,
Plan,
FailurePolicy,
RequestedAt,
CreatedAt,
UpdatedAt,
steps,
TenantId);
}
}
private sealed record StepDocument(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
}

View File

@@ -0,0 +1,76 @@
using StellaOps.TaskRunner.Core.Execution;
using System.Text.Json;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilesystemPackRunArtifactReader : IPackRunArtifactReader
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly string rootPath;
public FilesystemPackRunArtifactReader(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
}
public async Task<IReadOnlyList<PackRunArtifactRecord>> ListAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var manifestPath = Path.Combine(rootPath, Sanitize(runId), "artifact-manifest.json");
if (!File.Exists(manifestPath))
{
return Array.Empty<PackRunArtifactRecord>();
}
await using var stream = File.OpenRead(manifestPath);
var manifest = await JsonSerializer.DeserializeAsync<ArtifactManifest>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (manifest is null || manifest.Outputs is null)
{
return Array.Empty<PackRunArtifactRecord>();
}
return manifest.Outputs
.OrderBy(output => output.Name, StringComparer.Ordinal)
.Select(output => new PackRunArtifactRecord(
output.Name,
output.Type,
output.SourcePath,
output.StoredPath,
output.Status,
output.Notes,
manifest.UploadedAt,
output.ExpressionJson))
.ToList();
}
private static string Sanitize(string value)
{
var safe = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
safe = safe.Replace(invalid, '_');
}
return string.IsNullOrWhiteSpace(safe) ? "run" : safe;
}
private sealed record ArtifactManifest(
string RunId,
DateTimeOffset UploadedAt,
List<ArtifactRecord> Outputs);
private sealed record ArtifactRecord(
string Name,
string Type,
string? SourcePath,
string? StoredPath,
string Status,
string? Notes,
string? ExpressionJson);
}

View File

@@ -0,0 +1,240 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// Stores pack run artifacts on the local file system so they can be mirrored to the eventual remote store.
/// </summary>
public sealed class FilesystemPackRunArtifactUploader : IPackRunArtifactUploader
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly string rootPath;
private readonly ILogger<FilesystemPackRunArtifactUploader> logger;
private readonly TimeProvider timeProvider;
public FilesystemPackRunArtifactUploader(
string rootPath,
TimeProvider? timeProvider,
ILogger<FilesystemPackRunArtifactUploader> logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.timeProvider = timeProvider ?? TimeProvider.System;
Directory.CreateDirectory(this.rootPath);
}
public async Task UploadAsync(
PackRunExecutionContext context,
PackRunState state,
IReadOnlyList<TaskPackPlanOutput> outputs,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(outputs);
if (outputs.Count == 0)
{
return;
}
var destinationRoot = Path.Combine(rootPath, SanitizeFileName(context.RunId));
var filesRoot = Path.Combine(destinationRoot, "files");
var expressionsRoot = Path.Combine(destinationRoot, "expressions");
Directory.CreateDirectory(destinationRoot);
var manifest = new ArtifactManifest(
context.RunId,
timeProvider.GetUtcNow(),
new List<ArtifactRecord>(outputs.Count));
foreach (var output in outputs)
{
cancellationToken.ThrowIfCancellationRequested();
var record = await ProcessOutputAsync(
context,
output,
destinationRoot,
filesRoot,
expressionsRoot,
cancellationToken).ConfigureAwait(false);
manifest.Outputs.Add(record);
}
var manifestPath = Path.Combine(destinationRoot, "artifact-manifest.json");
await using (var stream = File.Open(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(stream, manifest, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
logger.LogInformation(
"Pack run {RunId} artifact manifest written to {Path} with {Count} output entries.",
context.RunId,
manifestPath,
manifest.Outputs.Count);
}
private async Task<ArtifactRecord> ProcessOutputAsync(
PackRunExecutionContext context,
TaskPackPlanOutput output,
string destinationRoot,
string filesRoot,
string expressionsRoot,
CancellationToken cancellationToken)
{
var sourcePath = ResolveString(output.Path);
var expressionNode = ResolveExpression(output.Expression);
var status = "skipped";
string? storedPath = null;
string? notes = null;
if (IsFileOutput(output))
{
if (string.IsNullOrWhiteSpace(sourcePath))
{
status = "unresolved";
notes = "Output path requires runtime value.";
}
else if (!File.Exists(sourcePath))
{
status = "missing";
notes = $"Source file '{sourcePath}' not found.";
logger.LogWarning(
"Pack run {RunId} output {Output} referenced missing file {Path}.",
context.RunId,
output.Name,
sourcePath);
}
else
{
Directory.CreateDirectory(filesRoot);
var destinationPath = Path.Combine(filesRoot, DetermineDestinationFileName(output, sourcePath));
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
await CopyFileAsync(sourcePath, destinationPath, cancellationToken).ConfigureAwait(false);
storedPath = GetRelativePath(destinationPath, destinationRoot);
status = "copied";
logger.LogInformation(
"Pack run {RunId} output {Output} copied to {Destination}.",
context.RunId,
output.Name,
destinationPath);
}
}
if (expressionNode is not null)
{
Directory.CreateDirectory(expressionsRoot);
var expressionPath = Path.Combine(
expressionsRoot,
$"{SanitizeFileName(output.Name)}.json");
var json = expressionNode.ToJsonString(SerializerOptions);
await File.WriteAllTextAsync(expressionPath, json, cancellationToken).ConfigureAwait(false);
storedPath ??= GetRelativePath(expressionPath, destinationRoot);
status = status == "copied" ? "copied" : "materialized";
}
return new ArtifactRecord(
output.Name,
output.Type,
sourcePath,
storedPath,
status,
notes);
}
private static async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken)
{
await using var source = File.Open(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var destination = File.Open(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None);
await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
}
private static bool IsFileOutput(TaskPackPlanOutput output)
=> string.Equals(output.Type, "file", StringComparison.OrdinalIgnoreCase);
private static string DetermineDestinationFileName(TaskPackPlanOutput output, string sourcePath)
{
var extension = Path.GetExtension(sourcePath);
var baseName = SanitizeFileName(output.Name);
if (!string.IsNullOrWhiteSpace(extension) &&
!baseName.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
{
return baseName + extension;
}
return baseName;
}
private static string? ResolveString(TaskPackPlanParameterValue? parameter)
{
if (parameter is null || parameter.RequiresRuntimeValue || parameter.Value is null)
{
return null;
}
if (parameter.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var value))
{
return value;
}
return null;
}
private static JsonNode? ResolveExpression(TaskPackPlanParameterValue? parameter)
{
if (parameter is null || parameter.RequiresRuntimeValue)
{
return null;
}
return parameter.Value;
}
private static string SanitizeFileName(string value)
{
var result = value;
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
return string.IsNullOrWhiteSpace(result) ? "output" : result;
}
private static string GetRelativePath(string path, string root)
=> Path.GetRelativePath(root, path)
.Replace('\\', '/');
private sealed record ArtifactManifest(string RunId, DateTimeOffset UploadedAt, List<ArtifactRecord> Outputs);
private sealed record ArtifactRecord(
string Name,
string Type,
string? SourcePath,
string? StoredPath,
string Status,
string? Notes);
}

View File

@@ -0,0 +1,148 @@
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRunJobScheduler
{
private readonly string queuePath;
private readonly string archivePath;
private readonly TaskPackManifestLoader manifestLoader = new();
private readonly TaskPackPlanner planner;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public FilesystemPackRunDispatcher(string queuePath, string archivePath, IEgressPolicy? egressPolicy = null)
{
this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath));
this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath));
planner = new TaskPackPlanner(egressPolicy);
Directory.CreateDirectory(queuePath);
Directory.CreateDirectory(archivePath);
}
public string QueuePath => queuePath;
public async Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
{
var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
var job = JsonSerializer.Deserialize<JobEnvelope>(jobJson, serializerOptions);
if (job is null)
{
continue;
}
TaskPackPlan? plan = job.Plan;
if (plan is null)
{
if (string.IsNullOrWhiteSpace(job.ManifestPath))
{
continue;
}
var manifestPath = ResolvePath(queuePath, job.ManifestPath);
var inputsPath = job.InputsPath is null ? null : ResolvePath(queuePath, job.InputsPath);
var manifest = manifestLoader.Load(manifestPath);
var inputs = await LoadInputsAsync(inputsPath, cancellationToken).ConfigureAwait(false);
var planResult = planner.Plan(manifest, inputs);
if (!planResult.Success || planResult.Plan is null)
{
throw new InvalidOperationException($"Failed to plan pack for run {job.RunId}: {string.Join(';', planResult.Errors.Select(e => e.Message))}");
}
plan = planResult.Plan;
}
var archiveFile = Path.Combine(archivePath, Path.GetFileName(file));
File.Move(file, archiveFile, overwrite: true);
var requestedAt = job.RequestedAt ?? DateTimeOffset.UtcNow;
var runId = string.IsNullOrWhiteSpace(job.RunId) ? Guid.NewGuid().ToString("n") : job.RunId;
return new PackRunExecutionContext(runId, plan, requestedAt);
}
catch (Exception ex)
{
var failedPath = file + ".failed";
File.Move(file, failedPath, overwrite: true);
Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}");
}
}
return null;
}
public async Task ScheduleAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var envelope = new JobEnvelope(
context.RunId,
ManifestPath: null,
InputsPath: null,
context.RequestedAt,
context.Plan);
Directory.CreateDirectory(queuePath);
var safeRunId = string.IsNullOrWhiteSpace(context.RunId) ? Guid.NewGuid().ToString("n") : SanitizeFileName(context.RunId);
var fileName = $"{safeRunId}-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}.json";
var path = Path.Combine(queuePath, fileName);
var json = JsonSerializer.Serialize(envelope, serializerOptions);
await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
}
private static string ResolvePath(string root, string relative)
=> Path.IsPathRooted(relative) ? relative : Path.Combine(root, relative);
private static async Task<IDictionary<string, JsonNode?>> LoadInputsAsync(string? path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var node = JsonNode.Parse(json) as JsonObject;
if (node is null)
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
return node.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.Ordinal);
}
private sealed record JobEnvelope(
string? RunId,
string? ManifestPath,
string? InputsPath,
DateTimeOffset? RequestedAt,
TaskPackPlan? Plan);
private static string SanitizeFileName(string value)
{
var safe = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
safe = safe.Replace(invalid, '_');
}
return safe;
}
}

View File

@@ -0,0 +1,57 @@
using StellaOps.TaskRunner.Core.Execution;
using System.Text.Json;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilesystemPackRunProvenanceWriter : IPackRunProvenanceWriter
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly string rootPath;
private readonly TimeProvider timeProvider;
public FilesystemPackRunProvenanceWriter(string rootPath, TimeProvider? timeProvider = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task WriteAsync(PackRunExecutionContext context, PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(state);
var completedAt = timeProvider.GetUtcNow();
var manifest = ProvenanceManifestFactory.Create(context, state, completedAt);
var manifestPath = GetPath(context.RunId);
Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
await using var stream = File.Open(manifestPath, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, manifest, SerializerOptions, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private string GetPath(string runId)
{
var safe = Sanitize(runId);
return Path.Combine(rootPath, "provenance", $"{safe}.json");
}
private static string Sanitize(string value)
{
var result = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
return result;
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Execution;
using System.Net.Http.Json;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class HttpPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly IHttpClientFactory httpClientFactory;
private readonly NotificationOptions options;
private readonly ILogger<HttpPackRunNotificationPublisher> logger;
public HttpPackRunNotificationPublisher(
IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options,
ILogger<HttpPackRunNotificationPublisher> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
if (options.ApprovalEndpoint is null)
{
logger.LogWarning("Approval endpoint not configured; skipping approval notification for run {RunId}.", runId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.ApprovalId,
notification.RequiredGrants,
notification.Messages,
notification.StepIds,
notification.ReasonTemplate
};
var response = await client.PostAsJsonAsync(options.ApprovalEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
if (options.PolicyEndpoint is null)
{
logger.LogDebug("Policy endpoint not configured; skipping policy notification for run {RunId} step {StepId}.", runId, notification.StepId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.StepId,
notification.Message,
Parameters = notification.Parameters.Select(parameter => new
{
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error
})
};
var response = await client.PostAsJsonAsync(options.PolicyEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -0,0 +1,67 @@
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// In-memory approval state store for explicit non-production TaskRunner profiles.
/// </summary>
public sealed class InMemoryPackRunApprovalStore : IPackRunApprovalStore
{
private readonly Dictionary<string, List<PackRunApprovalState>> _approvals = new(StringComparer.Ordinal);
private readonly object _gate = new();
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approvals);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
_approvals[runId] = approvals.ToList();
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
if (!_approvals.TryGetValue(runId, out var values))
{
return Task.FromResult<IReadOnlyList<PackRunApprovalState>>(Array.Empty<PackRunApprovalState>());
}
return Task.FromResult<IReadOnlyList<PackRunApprovalState>>(values.ToList());
}
}
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(approval);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
if (!_approvals.TryGetValue(runId, out var values))
{
throw new InvalidOperationException($"No approvals found for run '{runId}'.");
}
var index = values.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal));
if (index < 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
values[index] = approval;
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,71 @@
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// In-memory append-only log store for explicit non-production TaskRunner profiles.
/// </summary>
public sealed class InMemoryPackRunLogStore : IPackRunLogStore
{
private readonly Dictionary<string, List<PackRunLogEntry>> _logs = new(StringComparer.Ordinal);
private readonly object _gate = new();
public Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
if (!_logs.TryGetValue(runId, out var entries))
{
entries = new List<PackRunLogEntry>();
_logs[runId] = entries;
}
entries.Add(entry);
}
return Task.CompletedTask;
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
PackRunLogEntry[] entries;
lock (_gate)
{
if (!_logs.TryGetValue(runId, out var values) || values.Count == 0)
{
yield break;
}
entries = values
.OrderBy(entry => entry.Timestamp)
.ThenBy(entry => entry.EventType, StringComparer.Ordinal)
.ToArray();
}
foreach (var entry in entries)
{
cancellationToken.ThrowIfCancellationRequested();
yield return entry;
await Task.Yield();
}
}
public Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
return Task.FromResult(_logs.TryGetValue(runId, out var entries) && entries.Count > 0);
}
}
}

View File

@@ -0,0 +1,51 @@
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// In-memory state store for explicit non-production TaskRunner profiles.
/// </summary>
public sealed class InMemoryPackRunStateStore : IPackRunStateStore
{
private readonly Dictionary<string, PackRunState> _states = new(StringComparer.Ordinal);
private readonly object _gate = new();
public Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
_states.TryGetValue(runId, out var state);
return Task.FromResult(state);
}
}
public Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
_states[state.RunId] = state;
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
var ordered = _states.Values
.OrderBy(state => state.UpdatedAt)
.ThenBy(state => state.RunId, StringComparer.Ordinal)
.ToArray();
return Task.FromResult<IReadOnlyList<PackRunState>>(ordered);
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class LoggingPackRunArtifactUploader : IPackRunArtifactUploader
{
private readonly ILogger<LoggingPackRunArtifactUploader> _logger;
public LoggingPackRunArtifactUploader(ILogger<LoggingPackRunArtifactUploader> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task UploadAsync(
PackRunExecutionContext context,
PackRunState state,
IReadOnlyList<TaskPackPlanOutput> outputs,
CancellationToken cancellationToken)
{
if (outputs.Count == 0)
{
return Task.CompletedTask;
}
foreach (var output in outputs)
{
var path = output.Path?.Value?.ToString() ?? "(dynamic)";
_logger.LogInformation(
"Pack run {RunId} scheduled artifact upload for output {Output} (type={Type}, path={Path}).",
context.RunId,
output.Name,
output.Type,
path);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class LoggingPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly ILogger<LoggingPackRunNotificationPublisher> logger;
public LoggingPackRunNotificationPublisher(ILogger<LoggingPackRunNotificationPublisher> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
logger.LogInformation(
"Run {RunId}: approval {ApprovalId} requires grants {Grants}.",
runId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
logger.LogDebug(
"Run {RunId}: policy gate {StepId} pending (parameters: {Parameters}).",
runId,
notification.StepId,
string.Join(",", notification.Parameters.Select(p => p.Name)));
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunJobDispatcher : IPackRunJobDispatcher
{
public Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
=> Task.FromResult<PackRunExecutionContext?>(null);
}

View File

@@ -0,0 +1,25 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunStepExecutor : IPackRunStepExecutor
{
public Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken)
{
if (parameters.TryGetValue("simulateFailure", out var value) &&
value.Value is JsonValue jsonValue &&
jsonValue.TryGetValue<bool>(out var failure) &&
failure)
{
return Task.FromResult(new PackRunStepExecutionResult(false, "Simulated failure requested."));
}
return Task.FromResult(new PackRunStepExecutionResult(true));
}
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NotificationOptions
{
public Uri? ApprovalEndpoint { get; set; }
public Uri? PolicyEndpoint { get; set; }
}

View File

@@ -0,0 +1,146 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class PackRunApprovalDecisionService
{
private readonly IPackRunApprovalStore _approvalStore;
private readonly IPackRunStateStore _stateStore;
private readonly IPackRunJobScheduler _scheduler;
private readonly ILogger<PackRunApprovalDecisionService> _logger;
public PackRunApprovalDecisionService(
IPackRunApprovalStore approvalStore,
IPackRunStateStore stateStore,
IPackRunJobScheduler scheduler,
ILogger<PackRunApprovalDecisionService> logger)
{
_approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore));
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PackRunApprovalDecisionResult> ApplyAsync(
PackRunApprovalDecisionRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.RunId);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ApprovalId);
var runId = request.RunId.Trim();
var approvalId = request.ApprovalId.Trim();
if (!IsSha256Digest(request.PlanHash))
{
_logger.LogWarning(
"Approval decision for run {RunId} rejected plan hash format invalid (expected sha256:<64-hex>).",
runId);
return PackRunApprovalDecisionResult.PlanHashMismatch;
}
var state = await _stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
_logger.LogWarning("Approval decision for run {RunId} rejected run state not found.", runId);
return PackRunApprovalDecisionResult.NotFound;
}
var approvals = await _approvalStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (approvals.Count == 0)
{
_logger.LogWarning("Approval decision for run {RunId} rejected approval state not found.", runId);
return PackRunApprovalDecisionResult.NotFound;
}
if (!string.Equals(state.PlanHash, request.PlanHash, StringComparison.Ordinal))
{
_logger.LogWarning(
"Approval decision for run {RunId} rejected plan hash mismatch (expected {Expected}, got {Actual}).",
runId,
state.PlanHash,
request.PlanHash);
return PackRunApprovalDecisionResult.PlanHashMismatch;
}
var requestedAt = state.RequestedAt != default ? state.RequestedAt : state.CreatedAt;
var coordinator = PackRunApprovalCoordinator.Restore(state.Plan, approvals, requestedAt);
ApprovalActionResult actionResult;
var now = DateTimeOffset.UtcNow;
switch (request.Decision)
{
case PackRunApprovalDecisionType.Approved:
actionResult = coordinator.Approve(approvalId, request.ActorId ?? "system", now, request.Summary);
break;
case PackRunApprovalDecisionType.Rejected:
actionResult = coordinator.Reject(approvalId, request.ActorId ?? "system", now, request.Summary);
break;
case PackRunApprovalDecisionType.Expired:
actionResult = coordinator.Expire(approvalId, now, request.Summary);
break;
default:
throw new ArgumentOutOfRangeException(nameof(request.Decision), request.Decision, "Unsupported approval decision.");
}
await _approvalStore.UpdateAsync(runId, actionResult.State, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Applied approval decision {Decision} for run {RunId} (approval {ApprovalId}, actor={ActorId}).",
request.Decision,
runId,
approvalId,
request.ActorId ?? "(system)");
if (actionResult.ShouldResumeRun && request.Decision == PackRunApprovalDecisionType.Approved)
{
var context = new PackRunExecutionContext(runId, state.Plan, requestedAt);
await _scheduler.ScheduleAsync(context, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Scheduled run {RunId} for resume after approvals completed.", runId);
return PackRunApprovalDecisionResult.Resumed;
}
return PackRunApprovalDecisionResult.Applied;
}
private static bool IsSha256Digest(string value)
=> !string.IsNullOrWhiteSpace(value)
&& Sha256Pattern.IsMatch(value);
private static readonly Regex Sha256Pattern = new(
"^sha256:[0-9a-f]{64}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
public sealed record PackRunApprovalDecisionRequest(
string RunId,
string ApprovalId,
string PlanHash,
PackRunApprovalDecisionType Decision,
string? ActorId,
string? Summary);
public enum PackRunApprovalDecisionType
{
Approved,
Rejected,
Expired
}
public sealed record PackRunApprovalDecisionResult(string Status)
{
public static PackRunApprovalDecisionResult NotFound { get; } = new("not_found");
public static PackRunApprovalDecisionResult PlanHashMismatch { get; } = new("plan_hash_mismatch");
public static PackRunApprovalDecisionResult Applied { get; } = new("applied");
public static PackRunApprovalDecisionResult Resumed { get; } = new("resumed");
public bool ShouldResume => ReferenceEquals(this, Resumed);
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,9 @@
# StellaOps.TaskRunner.Infrastructure Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/StellaOps.TaskRunner.Infrastructure.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-004 | DONE | Added explicit in-memory store implementations to keep non-production fallback deterministic and explicit. |

View File

@@ -0,0 +1,242 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Tenancy;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace StellaOps.TaskRunner.Infrastructure.Tenancy;
/// <summary>
/// Tenant-scoped pack run log store per TASKRUN-TEN-48-001.
/// Persists logs as NDJSON under tenant-prefixed paths with tenant context propagation.
/// </summary>
public sealed class TenantScopedPackRunLogStore : IPackRunLogStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenantContext _tenant;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _fileLocks = new(StringComparer.Ordinal);
private readonly ILogger<TenantScopedPackRunLogStore> _logger;
public TenantScopedPackRunLogStore(
ITenantScopedStoragePathResolver pathResolver,
TenantContext tenant,
ILogger<TenantScopedPackRunLogStore> logger)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task AppendAsync(string runId, PackRunLogEntry entry, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
ArgumentNullException.ThrowIfNull(entry);
var path = GetLogsPath(runId);
var directory = Path.GetDirectoryName(path);
if (directory is not null)
{
Directory.CreateDirectory(directory);
}
var gate = _fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Enrich entry with tenant context
var enrichedEntry = EnrichWithTenantContext(entry);
var document = PackRunLogEntryDocument.FromDomain(enrichedEntry);
var json = JsonSerializer.Serialize(document, SerializerOptions);
await File.AppendAllTextAsync(path, json + Environment.NewLine, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Appended log entry for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
}
finally
{
gate.Release();
}
}
public async IAsyncEnumerable<PackRunLogEntry> ReadAsync(
string runId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetLogsPath(runId);
if (!File.Exists(path))
{
_logger.LogDebug(
"No logs found for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
yield break;
}
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
yield break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
PackRunLogEntryDocument? document = null;
try
{
document = JsonSerializer.Deserialize<PackRunLogEntryDocument>(line, SerializerOptions);
}
catch
{
// Skip malformed entries
_logger.LogWarning("Skipping malformed log entry in run {RunId}.", runId);
}
if (document is null)
{
continue;
}
var entry = document.ToDomain();
// Verify tenant ownership from metadata
var tenantId = entry.Metadata?.GetValueOrDefault("TenantId");
if (tenantId is not null && !string.Equals(tenantId, _tenant.TenantId, StringComparison.Ordinal))
{
_logger.LogWarning(
"Log entry tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} in run {RunId}.",
_tenant.TenantId,
tenantId,
runId);
continue;
}
yield return entry;
}
}
public Task<bool> ExistsAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetLogsPath(runId);
return Task.FromResult(File.Exists(path));
}
private string GetLogsPath(string runId)
{
var logsPath = _pathResolver.GetLogsPath(_tenant, runId);
return $"{logsPath}.ndjson";
}
private PackRunLogEntry EnrichWithTenantContext(PackRunLogEntry entry)
{
// Add tenant context to metadata
var metadata = entry.Metadata is not null
? new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal)
: new Dictionary<string, string>(StringComparer.Ordinal);
metadata["TenantId"] = _tenant.TenantId;
metadata["ProjectId"] = _tenant.ProjectId;
return new PackRunLogEntry(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
private sealed record PackRunLogEntryDocument(
DateTimeOffset Timestamp,
string Level,
string EventType,
string Message,
string? StepId,
Dictionary<string, string>? Metadata)
{
public static PackRunLogEntryDocument FromDomain(PackRunLogEntry entry)
{
var metadata = entry.Metadata is null
? null
: new Dictionary<string, string>(entry.Metadata, StringComparer.Ordinal);
return new PackRunLogEntryDocument(
entry.Timestamp,
entry.Level,
entry.EventType,
entry.Message,
entry.StepId,
metadata);
}
public PackRunLogEntry ToDomain()
{
IReadOnlyDictionary<string, string>? metadata = Metadata is null
? null
: new Dictionary<string, string>(Metadata, StringComparer.Ordinal);
return new PackRunLogEntry(
Timestamp,
Level,
EventType,
Message,
StepId,
metadata);
}
}
}
/// <summary>
/// Factory for creating tenant-scoped log stores.
/// </summary>
public interface ITenantScopedLogStoreFactory
{
IPackRunLogStore Create(TenantContext tenant);
}
/// <summary>
/// Default implementation of tenant-scoped log store factory.
/// </summary>
public sealed class TenantScopedLogStoreFactory : ITenantScopedLogStoreFactory
{
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly ILoggerFactory _loggerFactory;
public TenantScopedLogStoreFactory(
ITenantScopedStoragePathResolver pathResolver,
ILoggerFactory loggerFactory)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public IPackRunLogStore Create(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
var logger = _loggerFactory.CreateLogger<TenantScopedPackRunLogStore>();
return new TenantScopedPackRunLogStore(_pathResolver, tenant, logger);
}
}

View File

@@ -0,0 +1,283 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.Tenancy;
using System.Text.Json;
namespace StellaOps.TaskRunner.Infrastructure.Tenancy;
/// <summary>
/// Tenant-scoped pack run state store per TASKRUN-TEN-48-001.
/// Ensures all state is stored under tenant-prefixed paths.
/// </summary>
public sealed class TenantScopedPackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly TenantContext _tenant;
private readonly SemaphoreSlim _mutex = new(1, 1);
private readonly ILogger<TenantScopedPackRunStateStore> _logger;
private readonly string _basePath;
public TenantScopedPackRunStateStore(
ITenantScopedStoragePathResolver pathResolver,
TenantContext tenant,
ILogger<TenantScopedPackRunStateStore> logger)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Use the tenant base path for listing operations
_basePath = _pathResolver.GetTenantBasePath(tenant);
Directory.CreateDirectory(_basePath);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetStatePath(runId);
if (!File.Exists(path))
{
_logger.LogDebug(
"State not found for run {RunId} in tenant {TenantId}.",
runId,
_tenant.TenantId);
return null;
}
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
var state = document?.ToDomain();
// Validate tenant ownership
if (state is not null && !string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
_logger.LogWarning(
"State tenant mismatch: expected {ExpectedTenantId}, found {ActualTenantId} for run {RunId}.",
_tenant.TenantId,
state.TenantId,
runId);
return null;
}
return state;
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
// Enforce tenant ownership
if (!string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Cannot save state for tenant {state.TenantId} in store scoped to tenant {_tenant.TenantId}.");
}
var path = GetStatePath(state.RunId);
var directory = Path.GetDirectoryName(path);
if (directory is not null)
{
Directory.CreateDirectory(directory);
}
var document = StateDocument.FromDomain(state);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Saved state for run {RunId} in tenant {TenantId}.",
state.RunId,
_tenant.TenantId);
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
var stateBasePath = Path.Combine(_basePath, "state");
if (!Directory.Exists(stateBasePath))
{
return Array.Empty<PackRunState>();
}
var states = new List<PackRunState>();
// Search recursively for state files in tenant-scoped directory
var files = Directory.EnumerateFiles(stateBasePath, "*.json", SearchOption.AllDirectories)
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is not null)
{
var state = document.ToDomain();
// Only include states that belong to this tenant
if (string.Equals(state.TenantId, _tenant.TenantId, StringComparison.Ordinal))
{
states.Add(state);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read state file {File}.", file);
}
}
return states;
}
private string GetStatePath(string runId)
{
var statePath = _pathResolver.GetStatePath(_tenant, runId);
return $"{statePath}.json";
}
private sealed record StateDocument(
string RunId,
string PlanHash,
TaskPackPlan Plan,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset RequestedAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyList<StepDocument> Steps,
string? TenantId)
{
public static StateDocument FromDomain(PackRunState state)
{
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new StepDocument(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
return new StateDocument(
state.RunId,
state.PlanHash,
state.Plan,
state.FailurePolicy,
state.RequestedAt,
state.CreatedAt,
state.UpdatedAt,
steps,
state.TenantId);
}
public PackRunState ToDomain()
{
var steps = Steps.ToDictionary(
step => step.StepId,
step => new PackRunStepStateRecord(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason),
StringComparer.Ordinal);
return new PackRunState(
RunId,
PlanHash,
Plan,
FailurePolicy,
RequestedAt,
CreatedAt,
UpdatedAt,
steps,
TenantId);
}
}
private sealed record StepDocument(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
}
/// <summary>
/// Factory for creating tenant-scoped state stores.
/// </summary>
public interface ITenantScopedStateStoreFactory
{
IPackRunStateStore Create(TenantContext tenant);
}
/// <summary>
/// Default implementation of tenant-scoped state store factory.
/// </summary>
public sealed class TenantScopedStateStoreFactory : ITenantScopedStateStoreFactory
{
private readonly ITenantScopedStoragePathResolver _pathResolver;
private readonly ILoggerFactory _loggerFactory;
public TenantScopedStateStoreFactory(
ITenantScopedStoragePathResolver pathResolver,
ILoggerFactory loggerFactory)
{
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public IPackRunStateStore Create(TenantContext tenant)
{
ArgumentNullException.ThrowIfNull(tenant);
var logger = _loggerFactory.CreateLogger<TenantScopedPackRunStateStore>();
return new TenantScopedPackRunStateStore(_pathResolver, tenant, logger);
}
}

View File

@@ -0,0 +1,183 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.WebService.Deprecation;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class ApiDeprecationTests
{
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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, CancellationToken.None);
Assert.Single(upcoming);
Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath);
}
[Trait("Category", TestCategories.Unit)]
[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, CancellationToken.None);
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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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);
}
[Trait("Category", TestCategories.Unit)]
[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");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PathPattern_DoubleWildcard_MatchesMultipleSegments()
{
var pattern = "^" + Regex.Escape("/v1/legacy/**")
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
Assert.Matches(pattern, "/v1/legacy/foo");
Assert.Matches(pattern, "/v1/legacy/foo/bar");
Assert.Matches(pattern, "/v1/legacy/foo/bar/baz");
Assert.DoesNotMatch(pattern, "/v2/legacy/foo");
}
private sealed class OptionsMonitor : IOptionsMonitor<ApiDeprecationOptions>
{
public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value;
public ApiDeprecationOptions CurrentValue { get; }
public ApiDeprecationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<ApiDeprecationOptions, string?> listener) => null;
}
}

View File

@@ -0,0 +1,358 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Evidence;
using StellaOps.TaskRunner.Core.Events;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class BundleImportEvidenceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportHashChain_Compute_CreatesDeterministicHash()
{
var input = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "test-bundle",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"),
CreatedBy: "test@example.com",
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:abc123",
Signature: null,
SignatureValid: null);
var outputs = new List<BundleImportOutputFile>
{
new("file1.json", "sha256:aaa", 100, "application/json", DateTimeOffset.UtcNow, "item1"),
new("file2.json", "sha256:bbb", 200, "application/json", DateTimeOffset.UtcNow, "item2")
};
var transcript = new List<BundleImportTranscriptEntry>
{
new(DateTimeOffset.UtcNow, "info", "import.started", "Import started", null)
};
var chain1 = BundleImportHashChain.Compute(input, outputs, transcript);
var chain2 = BundleImportHashChain.Compute(input, outputs, transcript);
Assert.Equal(chain1.RootHash, chain2.RootHash);
Assert.Equal(chain1.InputsHash, chain2.InputsHash);
Assert.Equal(chain1.OutputsHash, chain2.OutputsHash);
Assert.StartsWith("sha256:", chain1.RootHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportHashChain_Compute_DifferentInputsProduceDifferentHashes()
{
var input1 = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "bundle-1",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.UtcNow,
CreatedBy: null,
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:abc123",
Signature: null,
SignatureValid: null);
var input2 = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "bundle-2",
BundleVersion: "2025.10.0",
CreatedAt: DateTimeOffset.UtcNow,
CreatedBy: null,
TotalSizeBytes: 1024,
ItemCount: 5,
ManifestSha256: "sha256:def456",
Signature: null,
SignatureValid: null);
var outputs = new List<BundleImportOutputFile>();
var transcript = new List<BundleImportTranscriptEntry>();
var chain1 = BundleImportHashChain.Compute(input1, outputs, transcript);
var chain2 = BundleImportHashChain.Compute(input2, outputs, transcript);
Assert.NotEqual(chain1.RootHash, chain2.RootHash);
Assert.NotEqual(chain1.InputsHash, chain2.InputsHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_StoresEvidence()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
Assert.NotNull(result.Snapshot);
Assert.NotNull(result.EvidencePointer);
Assert.Single(store.GetAll());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_CreatesCorrectMaterials()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
var snapshot = result.Snapshot!;
// Should have: input manifest, 2 outputs, transcript, validation, hashchain = 6 materials
Assert.Equal(6, snapshot.Materials.Count);
Assert.Contains(snapshot.Materials, m => m.Section == "input");
Assert.Contains(snapshot.Materials, m => m.Section == "output");
Assert.Contains(snapshot.Materials, m => m.Section == "transcript");
Assert.Contains(snapshot.Materials, m => m.Section == "validation");
Assert.Contains(snapshot.Materials, m => m.Section == "hashchain");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_SetsCorrectMetadata()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
var snapshot = result.Snapshot!;
Assert.Equal(evidence.JobId, snapshot.Metadata!["jobId"]);
Assert.Equal(evidence.Status.ToString(), snapshot.Metadata["status"]);
Assert.Equal(evidence.SourcePath, snapshot.Metadata["sourcePath"]);
Assert.Equal("2", snapshot.Metadata["outputCount"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_CaptureAsync_EmitsTimelineEvent()
{
var store = new InMemoryPackRunEvidenceStore();
var timelineSink = new InMemoryPackRunTimelineEventSink();
var emitter = new PackRunTimelineEventEmitter(
timelineSink,
TimeProvider.System,
NullLogger<PackRunTimelineEventEmitter>.Instance);
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance,
emitter);
var evidence = CreateTestEvidence();
var result = await service.CaptureAsync(evidence, CancellationToken.None);
Assert.True(result.Success);
Assert.Single(timelineSink.GetEvents());
var evt = timelineSink.GetEvents()[0];
Assert.Equal("bundle.import.evidence_captured", evt.EventType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_GetAsync_ReturnsEvidence()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
await service.CaptureAsync(evidence, CancellationToken.None);
var retrieved = await service.GetAsync(evidence.JobId, CancellationToken.None);
Assert.NotNull(retrieved);
Assert.Equal(evidence.JobId, retrieved.JobId);
Assert.Equal(evidence.TenantId, retrieved.TenantId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_GetAsync_ReturnsNullForMissingJob()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var retrieved = await service.GetAsync("non-existent-job", CancellationToken.None);
Assert.Null(retrieved);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_CreatesFile()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var evidence = CreateTestEvidence();
await service.CaptureAsync(evidence, CancellationToken.None);
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
try
{
var result = await service.ExportToPortableBundleAsync(
evidence.JobId,
outputPath,
CancellationToken.None);
Assert.True(result.Success);
Assert.Equal(outputPath, result.OutputPath);
Assert.True(File.Exists(outputPath));
Assert.True(result.SizeBytes > 0);
Assert.StartsWith("sha256:", result.BundleSha256);
}
finally
{
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BundleImportEvidenceService_ExportToPortableBundleAsync_FailsForMissingJob()
{
var store = new InMemoryPackRunEvidenceStore();
var service = new BundleImportEvidenceService(
store,
NullLogger<BundleImportEvidenceService>.Instance);
var outputPath = Path.Combine(Path.GetTempPath(), $"evidence-{Guid.NewGuid():N}.json");
var result = await service.ExportToPortableBundleAsync(
"non-existent-job",
outputPath,
CancellationToken.None);
Assert.False(result.Success);
Assert.Contains("No evidence found", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportEvidence_RecordProperties_AreAccessible()
{
var evidence = CreateTestEvidence();
Assert.Equal("test-job-123", evidence.JobId);
Assert.Equal("tenant-1", evidence.TenantId);
Assert.Equal("/path/to/bundle.tar.gz", evidence.SourcePath);
Assert.Equal(BundleImportStatus.Completed, evidence.Status);
Assert.NotNull(evidence.InputManifest);
Assert.Equal(2, evidence.OutputFiles.Count);
Assert.Equal(2, evidence.Transcript.Count);
Assert.NotNull(evidence.ValidationResult);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleImportValidationResult_RecordProperties_AreAccessible()
{
var result = new BundleImportValidationResult(
Valid: true,
ChecksumValid: true,
SignatureValid: true,
FormatValid: true,
Errors: null,
Warnings: ["Advisory data may be stale"]);
Assert.True(result.Valid);
Assert.True(result.ChecksumValid);
Assert.True(result.SignatureValid);
Assert.True(result.FormatValid);
Assert.Null(result.Errors);
Assert.Single(result.Warnings!);
}
private static BundleImportEvidence CreateTestEvidence()
{
var now = DateTimeOffset.UtcNow;
var input = new BundleImportInputManifest(
FormatVersion: "1.0.0",
BundleId: "test-bundle-001",
BundleVersion: "2025.10.0",
CreatedAt: now.AddHours(-1),
CreatedBy: "bundle-builder@example.com",
TotalSizeBytes: 10240,
ItemCount: 5,
ManifestSha256: "sha256:abcdef1234567890",
Signature: "base64sig...",
SignatureValid: true);
var outputs = new List<BundleImportOutputFile>
{
new("advisories/CVE-2025-0001.json", "sha256:output1hash", 512, "application/json", now, "item1"),
new("advisories/CVE-2025-0002.json", "sha256:output2hash", 1024, "application/json", now, "item2")
};
var transcript = new List<BundleImportTranscriptEntry>
{
new(now.AddMinutes(-5), "info", "import.started", "Bundle import started", new Dictionary<string, string>
{
["sourcePath"] = "/path/to/bundle.tar.gz"
}),
new(now, "info", "import.completed", "Bundle import completed successfully", new Dictionary<string, string>
{
["itemsImported"] = "5"
})
};
var validation = new BundleImportValidationResult(
Valid: true,
ChecksumValid: true,
SignatureValid: true,
FormatValid: true,
Errors: null,
Warnings: null);
var hashChain = BundleImportHashChain.Compute(input, outputs, transcript);
return new BundleImportEvidence(
JobId: "test-job-123",
TenantId: "tenant-1",
SourcePath: "/path/to/bundle.tar.gz",
StartedAt: now.AddMinutes(-5),
CompletedAt: now,
Status: BundleImportStatus.Completed,
ErrorMessage: null,
InitiatedBy: "admin@example.com",
InputManifest: input,
OutputFiles: outputs,
Transcript: transcript,
ValidationResult: validation,
HashChain: hashChain);
}
}

View File

@@ -0,0 +1,144 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class BundleIngestionStepExecutorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_ValidBundle_CopiesAndSucceeds()
{
using var temp = new TempDirectory();
var source = Path.Combine(temp.Path, "bundle.tgz");
var ct = CancellationToken.None;
await File.WriteAllTextAsync(source, "bundle-data", ct);
var checksum = "b9c72134b48cdc15e7a47f2476a41612d2084b763bea0575f5600b22041db7dc"; // sha256 of "bundle-data"
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
{
["path"] = Value(source),
["checksum"] = Value(checksum)
});
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.True(result.Succeeded);
var staged = Path.Combine(temp.Path, "bundles", checksum, "bundle.tgz");
Assert.True(File.Exists(staged));
Assert.Equal(await File.ReadAllBytesAsync(source, ct), await File.ReadAllBytesAsync(staged, ct));
var metadataPath = Path.Combine(temp.Path, "bundles", checksum, "metadata.json");
Assert.True(File.Exists(metadataPath));
var metadata = await File.ReadAllTextAsync(metadataPath, ct);
Assert.Contains(checksum, metadata, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_ChecksumMismatch_Fails()
{
using var temp = new TempDirectory();
var source = Path.Combine(temp.Path, "bundle.tgz");
var ct = CancellationToken.None;
await File.WriteAllTextAsync(source, "bundle-data", ct);
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
{
["path"] = Value(source),
["checksum"] = Value("deadbeef")
});
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.False(result.Succeeded);
Assert.Contains("Checksum mismatch", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_MissingChecksum_Fails()
{
using var temp = new TempDirectory();
var source = Path.Combine(temp.Path, "bundle.tgz");
var ct = CancellationToken.None;
await File.WriteAllTextAsync(source, "bundle-data", ct);
var options = Options.Create(new PackRunWorkerOptions { ArtifactsPath = temp.Path });
var executor = new BundleIngestionStepExecutor(options, NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:bundle.ingest", new Dictionary<string, TaskPackPlanParameterValue>
{
["path"] = Value(source)
});
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.False(result.Succeeded);
Assert.Contains("Checksum is required", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_UnknownUses_NoOpSuccess()
{
var ct = CancellationToken.None;
var executor = new BundleIngestionStepExecutor(
Options.Create(new PackRunWorkerOptions { ArtifactsPath = Path.GetTempPath() }),
NullLogger<BundleIngestionStepExecutor>.Instance);
var step = CreateStep("builtin:noop", new Dictionary<string, TaskPackPlanParameterValue>());
var result = await executor.ExecuteAsync(step, step.Parameters, ct);
Assert.True(result.Succeeded);
}
private static TaskPackPlanParameterValue Value(string literal)
=> new(JsonValue.Create(literal), null, null, false);
private static PackRunExecutionStep CreateStep(string uses, IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters)
=> new(
id: "ingest",
templateId: "ingest",
kind: PackRunStepKind.Run,
enabled: true,
uses: uses,
parameters: parameters,
approvalId: null,
gateMessage: null,
maxParallel: null,
continueOnError: false,
children: PackRunExecutionStep.EmptyChildren);
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,91 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TestKit;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilePackRunLogStoreTests : IDisposable
{
private readonly string rootPath;
public FilePackRunLogStoreTests()
{
rootPath = Path.Combine(Path.GetTempPath(), "StellaOps_TaskRunnerTests", Guid.NewGuid().ToString("n"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AppendAndReadAsync_RoundTripsEntriesInOrder()
{
var store = new FilePackRunLogStore(rootPath);
var runId = "run-append-test";
var first = new PackRunLogEntry(
DateTimeOffset.UtcNow,
"info",
"run.created",
"Run created.",
StepId: null,
Metadata: new Dictionary<string, string>(StringComparer.Ordinal)
{
["planHash"] = "hash-1"
});
var second = new PackRunLogEntry(
DateTimeOffset.UtcNow.AddSeconds(1),
"info",
"step.started",
"Step started.",
StepId: "build",
Metadata: null);
await store.AppendAsync(runId, first, CancellationToken.None);
await store.AppendAsync(runId, second, CancellationToken.None);
var reloaded = new List<PackRunLogEntry>();
await foreach (var entry in store.ReadAsync(runId, CancellationToken.None))
{
reloaded.Add(entry);
}
Assert.Collection(
reloaded,
entry =>
{
Assert.Equal("run.created", entry.EventType);
Assert.NotNull(entry.Metadata);
Assert.Equal("hash-1", entry.Metadata!["planHash"]);
},
entry =>
{
Assert.Equal("step.started", entry.EventType);
Assert.Equal("build", entry.StepId);
});
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExistsAsync_ReturnsFalseWhenNoLogPresent()
{
var store = new FilePackRunLogStore(rootPath);
var exists = await store.ExistsAsync("missing-run", CancellationToken.None);
Assert.False(exists);
}
public void Dispose()
{
try
{
if (Directory.Exists(rootPath))
{
Directory.Delete(rootPath, recursive: true);
}
}
catch
{
// Ignore cleanup failures to keep tests deterministic.
}
}
}

Some files were not shown because too many files have changed in this diff Show More