Files
git.stella-ops.org/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilesystemPackRunDispatcher.cs
StellaOps Bot 564df71bfb
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
up
2025-12-13 00:20:26 +02:00

148 lines
5.7 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
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;
}
}