feat: Implement Filesystem and MongoDB provenance writers for PackRun execution context
Some checks failed
Airgap Sealed CI Smoke / sealed-smoke (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled

- Added `FilesystemPackRunProvenanceWriter` to write provenance manifests to the filesystem.
- Introduced `MongoPackRunArtifactReader` to read artifacts from MongoDB.
- Created `MongoPackRunProvenanceWriter` to store provenance manifests in MongoDB.
- Developed unit tests for filesystem and MongoDB provenance writers.
- Established `ITimelineEventStore` and `ITimelineIngestionService` interfaces for timeline event handling.
- Implemented `TimelineIngestionService` to validate and persist timeline events with hashing.
- Created PostgreSQL schema and migration scripts for timeline indexing.
- Added dependency injection support for timeline indexer services.
- Developed tests for timeline ingestion and schema validation.
This commit is contained in:
StellaOps Bot
2025-11-30 15:38:14 +02:00
parent 8f54ffa203
commit 17d45a6d30
276 changed files with 8618 additions and 688 deletions

View File

@@ -1,12 +1,15 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using MongoDB.Driver;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
@@ -22,6 +25,7 @@ builder.Services.AddSingleton<TaskPackManifestLoader>();
builder.Services.AddSingleton<TaskPackPlanner>();
builder.Services.AddSingleton<PackRunSimulationEngine>();
builder.Services.AddSingleton<PackRunExecutionGraphBuilder>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddStellaOpsTelemetry(
builder.Configuration,
serviceName: "StellaOps.TaskRunner.WebService",
@@ -52,6 +56,7 @@ if (string.Equals(storageOptions.Mode, TaskRunnerStorageModes.Mongo, StringCompa
builder.Services.AddSingleton<IPackRunStateStore, MongoPackRunStateStore>();
builder.Services.AddSingleton<IPackRunLogStore, MongoPackRunLogStore>();
builder.Services.AddSingleton<IPackRunApprovalStore, MongoPackRunApprovalStore>();
builder.Services.AddSingleton<IPackRunArtifactReader, MongoPackRunArtifactReader>();
}
else
{
@@ -70,6 +75,11 @@ else
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunLogStore(options.LogsPath);
});
builder.Services.AddSingleton<IPackRunArtifactReader>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilesystemPackRunArtifactReader(options.ArtifactsPath);
});
}
builder.Services.AddSingleton(sp =>
@@ -83,10 +93,7 @@ builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapOpenApi("/openapi");
app.MapPost("/v1/task-runner/simulations", async (
[FromBody] SimulationRequest request,
@@ -126,7 +133,35 @@ app.MapPost("/v1/task-runner/simulations", async (
return Results.Ok(response);
}).WithName("SimulateTaskPack");
app.MapPost("/v1/task-runner/runs", async (
app.MapPost("/v1/task-runner/runs", HandleCreateRun).WithName("CreatePackRun");
app.MapPost("/api/runs", HandleCreateRun).WithName("CreatePackRunApi");
app.MapGet("/v1/task-runner/runs/{runId}", HandleGetRunState).WithName("GetRunState");
app.MapGet("/api/runs/{runId}", HandleGetRunState).WithName("GetRunStateApi");
app.MapGet("/v1/task-runner/runs/{runId}/logs", HandleStreamRunLogs).WithName("StreamRunLogs");
app.MapGet("/api/runs/{runId}/logs", HandleStreamRunLogs).WithName("StreamRunLogsApi");
app.MapGet("/v1/task-runner/runs/{runId}/artifacts", HandleListArtifacts).WithName("ListRunArtifacts");
app.MapGet("/api/runs/{runId}/artifacts", HandleListArtifacts).WithName("ListRunArtifactsApi");
app.MapPost("/v1/task-runner/runs/{runId}/approvals/{approvalId}", HandleApplyApprovalDecision).WithName("ApplyApprovalDecision");
app.MapPost("/api/runs/{runId}/approvals/{approvalId}", HandleApplyApprovalDecision).WithName("ApplyApprovalDecisionApi");
app.MapPost("/v1/task-runner/runs/{runId}/cancel", HandleCancelRun).WithName("CancelRun");
app.MapPost("/api/runs/{runId}/cancel", HandleCancelRun).WithName("CancelRunApi");
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
{
var metadata = OpenApiMetadataFactory.Create("/openapi");
response.Headers.ETag = metadata.ETag;
response.Headers.Append("X-Signature", metadata.Signature);
return Results.Ok(metadata);
}).WithName("GetOpenApiMetadata");
app.MapGet("/", () => Results.Redirect("/openapi"));
async Task<IResult> HandleCreateRun(
[FromBody] CreateRunRequest request,
TaskPackManifestLoader loader,
TaskPackPlanner planner,
@@ -135,7 +170,7 @@ app.MapPost("/v1/task-runner/runs", async (
IPackRunStateStore stateStore,
IPackRunLogStore logStore,
IPackRunJobScheduler scheduler,
CancellationToken cancellationToken) =>
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Manifest))
{
@@ -174,7 +209,7 @@ app.MapPost("/v1/task-runner/runs", async (
}
var requestedAt = DateTimeOffset.UtcNow;
var context = new PackRunExecutionContext(runId, plan, requestedAt);
var context = new PackRunExecutionContext(runId, plan, requestedAt, request.TenantId);
var graph = executionGraphBuilder.Build(plan);
var state = PackRunStateFactory.CreateInitialState(context, graph, simulationEngine, requestedAt);
@@ -194,9 +229,15 @@ app.MapPost("/v1/task-runner/runs", async (
return Results.StatusCode(StatusCodes.Status500InternalServerError);
}
var metadata = new Dictionary<string, string>(StringComparer.Ordinal);
metadata["planHash"] = plan.Hash;
metadata["requestedAt"] = requestedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["planHash"] = plan.Hash,
["requestedAt"] = requestedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(context.TenantId))
{
metadata["tenantId"] = context.TenantId!;
}
await logStore.AppendAsync(
runId,
@@ -205,31 +246,31 @@ app.MapPost("/v1/task-runner/runs", async (
var response = RunStateMapper.ToResponse(state);
return Results.Created($"/v1/task-runner/runs/{runId}", response);
}).WithName("CreatePackRun");
}
app.MapGet("/v1/task-runner/runs/{runId}", async (
async Task<IResult> HandleGetRunState(
string runId,
IPackRunStateStore stateStore,
CancellationToken cancellationToken) =>
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(runId))
{
return Results.BadRequest(new { error = "runId is required." });
}
var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(runId))
{
return Results.BadRequest(new { error = "runId is required." });
}
var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return Results.NotFound();
}
return Results.Ok(RunStateMapper.ToResponse(state));
}).WithName("GetRunState");
}
app.MapGet("/v1/task-runner/runs/{runId}/logs", async (
async Task<IResult> HandleStreamRunLogs(
string runId,
IPackRunLogStore logStore,
CancellationToken cancellationToken) =>
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(runId))
{
@@ -248,14 +289,14 @@ app.MapGet("/v1/task-runner/runs/{runId}/logs", async (
await RunLogMapper.WriteAsync(stream, entry, ct).ConfigureAwait(false);
}
}, "application/x-ndjson");
}).WithName("StreamRunLogs");
}
app.MapPost("/v1/task-runner/runs/{runId}/approvals/{approvalId}", async (
async Task<IResult> HandleApplyApprovalDecision(
string runId,
string approvalId,
[FromBody] ApprovalDecisionDto request,
PackRunApprovalDecisionService decisionService,
CancellationToken cancellationToken) =>
CancellationToken cancellationToken)
{
if (request is null)
{
@@ -267,8 +308,13 @@ app.MapPost("/v1/task-runner/runs/{runId}/approvals/{approvalId}", async (
return Results.BadRequest(new { error = "Invalid decision. Expected approved, rejected, or expired." });
}
if (string.IsNullOrWhiteSpace(request.PlanHash))
{
return Results.BadRequest(new { error = "planHash is required." });
}
var result = await decisionService.ApplyAsync(
new PackRunApprovalDecisionRequest(runId, approvalId, decisionType, request.ActorId, request.Summary),
new PackRunApprovalDecisionRequest(runId, approvalId, request.PlanHash, decisionType, request.ActorId, request.Summary),
cancellationToken).ConfigureAwait(false);
if (ReferenceEquals(result, PackRunApprovalDecisionResult.NotFound))
@@ -276,18 +322,105 @@ app.MapPost("/v1/task-runner/runs/{runId}/approvals/{approvalId}", async (
return Results.NotFound();
}
if (ReferenceEquals(result, PackRunApprovalDecisionResult.PlanHashMismatch))
{
return Results.Conflict(new { error = "Plan hash mismatch." });
}
return Results.Ok(new
{
status = result.Status,
resumed = result.ShouldResume
});
}).WithName("ApplyApprovalDecision");
}
app.MapGet("/", () => Results.Redirect("/openapi"));
app.Run();
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
async Task<IResult> HandleListArtifacts(
string runId,
IPackRunStateStore stateStore,
IPackRunArtifactReader artifactReader,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(runId))
{
return Results.BadRequest(new { error = "runId is required." });
}
var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return Results.NotFound();
}
var artifacts = await artifactReader.ListAsync(runId, cancellationToken).ConfigureAwait(false);
var response = artifacts
.Select(artifact => new
{
artifact.Name,
artifact.Type,
artifact.SourcePath,
artifact.StoredPath,
artifact.Status,
artifact.Notes,
artifact.CapturedAt,
artifact.ExpressionJson
})
.ToList();
return Results.Ok(response);
}
async Task<IResult> HandleCancelRun(
string runId,
IPackRunStateStore stateStore,
IPackRunLogStore logStore,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(runId))
{
return Results.BadRequest(new { error = "runId is required." });
}
var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return Results.NotFound();
}
var now = DateTimeOffset.UtcNow;
var updatedSteps = state.Steps.Values
.Select(step => step.Status is PackRunStepExecutionStatus.Succeeded or PackRunStepExecutionStatus.Skipped
? step
: step with
{
Status = PackRunStepExecutionStatus.Skipped,
StatusReason = "cancelled",
LastTransitionAt = now,
NextAttemptAt = null
})
.ToDictionary(step => step.StepId, step => step, StringComparer.Ordinal);
var updatedState = state with
{
UpdatedAt = now,
Steps = new ReadOnlyDictionary<string, PackRunStepStateRecord>(updatedSteps)
};
await stateStore.SaveAsync(updatedState, cancellationToken).ConfigureAwait(false);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["planHash"] = state.PlanHash
};
await logStore.AppendAsync(runId, new PackRunLogEntry(now, "warn", "run.cancel-requested", "Run cancellation requested.", null, metadata), cancellationToken).ConfigureAwait(false);
await logStore.AppendAsync(runId, new PackRunLogEntry(DateTimeOffset.UtcNow, "info", "run.cancelled", "Run cancelled; remaining steps marked as skipped.", null, metadata), cancellationToken).ConfigureAwait(false);
return Results.Accepted($"/v1/task-runner/runs/{runId}", new { status = "cancelled" });
}
app.Run();
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
{
if (node is null)
{
@@ -303,7 +436,7 @@ static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
return dictionary;
}
internal sealed record CreateRunRequest(string? RunId, string Manifest, JsonObject? Inputs);
internal sealed record CreateRunRequest(string? RunId, string Manifest, JsonObject? Inputs, string? TenantId);
internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs);
@@ -359,7 +492,7 @@ internal sealed record RunStateStepResponse(
DateTimeOffset? NextAttemptAt,
string? StatusReason);
internal sealed record ApprovalDecisionDto(string Decision, string? ActorId, string? Summary);
internal sealed record ApprovalDecisionDto(string Decision, string PlanHash, string? ActorId, string? Summary);
internal sealed record RunLogEntryResponse(
DateTimeOffset Timestamp,

View File

@@ -33,7 +33,7 @@
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj"/>
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj"/>
<ProjectReference Include="..\..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj"/>
</ItemGroup>

View File

@@ -9,6 +9,7 @@ public sealed class TaskRunnerServiceOptions
public string QueuePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue");
public string ArchivePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "queue", "archive");
public string LogsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "logs", "runs");
public string ArtifactsPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "artifacts");
public TaskRunnerStorageOptions Storage { get; set; } = new();
}