Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
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
|
||||
{
|
||||
private readonly string queuePath;
|
||||
private readonly string archivePath;
|
||||
private readonly TaskPackManifestLoader manifestLoader = new();
|
||||
private readonly TaskPackPlanner planner = new();
|
||||
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public FilesystemPackRunDispatcher(string queuePath, string archivePath)
|
||||
{
|
||||
this.queuePath = queuePath ?? throw new ArgumentNullException(nameof(queuePath));
|
||||
this.archivePath = archivePath ?? throw new ArgumentNullException(nameof(archivePath));
|
||||
Directory.CreateDirectory(queuePath);
|
||||
Directory.CreateDirectory(archivePath);
|
||||
}
|
||||
|
||||
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) ?? 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))}");
|
||||
}
|
||||
|
||||
var archiveFile = Path.Combine(archivePath, Path.GetFileName(file));
|
||||
File.Move(file, archiveFile, overwrite: true);
|
||||
|
||||
var requestedAt = job.RequestedAt ?? DateTimeOffset.UtcNow;
|
||||
return new PackRunExecutionContext(job.RunId ?? Guid.NewGuid().ToString("n"), planResult.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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
public sealed class NotificationOptions
|
||||
{
|
||||
public Uri? ApprovalEndpoint { get; set; }
|
||||
|
||||
public Uri? PolicyEndpoint { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<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>
|
||||
Reference in New Issue
Block a user