up
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
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
This commit is contained in:
@@ -1,10 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunJobDispatcher
|
||||
{
|
||||
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunJobDispatcher
|
||||
{
|
||||
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,8 +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);
|
||||
}
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunNotificationPublisher
|
||||
{
|
||||
Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken);
|
||||
|
||||
Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunStepExecutor
|
||||
{
|
||||
Task<PackRunStepExecutionResult> ExecuteAsync(
|
||||
|
||||
@@ -1,177 +1,177 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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);
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,84 +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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public enum PackRunApprovalStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2,
|
||||
Expired = 3
|
||||
}
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public enum PackRunApprovalStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2,
|
||||
Expired = 3
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,240 +1,240 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,243 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +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);
|
||||
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);
|
||||
|
||||
@@ -1,84 +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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed record PackRunProcessorResult(
|
||||
PackRunApprovalCoordinator ApprovalCoordinator,
|
||||
bool ShouldResumeImmediately);
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed record PackRunProcessorResult(
|
||||
PackRunApprovalCoordinator ApprovalCoordinator,
|
||||
bool ShouldResumeImmediately);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed record PackRunState(
|
||||
string RunId,
|
||||
string PlanHash,
|
||||
@@ -34,26 +34,26 @@ public sealed record PackRunState(
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,121 +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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,109 +1,109 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +1,190 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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>());
|
||||
}
|
||||
using System.Collections.ObjectModel;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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>());
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Expressions;
|
||||
|
||||
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,
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Expressions;
|
||||
|
||||
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)
|
||||
@@ -29,12 +29,12 @@ public sealed class TaskPackPlan
|
||||
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<TaskPackPlanStep> Steps { get; }
|
||||
|
||||
public string Hash { get; }
|
||||
|
||||
public IReadOnlyList<TaskPackPlanApproval> Approvals { get; }
|
||||
|
||||
public IReadOnlyList<TaskPackPlanSecret> Secrets { get; }
|
||||
|
||||
@@ -46,35 +46,35 @@ public sealed class TaskPackPlan
|
||||
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);
|
||||
|
||||
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,
|
||||
@@ -85,20 +85,20 @@ 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);
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Serialization;
|
||||
|
||||
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,
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Serialization;
|
||||
|
||||
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)
|
||||
@@ -21,13 +21,13 @@ internal static class TaskPackPlanHasher
|
||||
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))
|
||||
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)
|
||||
@@ -41,35 +41,35 @@ internal static class TaskPackPlanHasher
|
||||
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 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,
|
||||
@@ -78,25 +78,25 @@ internal static class TaskPackPlanHasher
|
||||
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 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(
|
||||
@@ -106,14 +106,14 @@ internal static class TaskPackPlanHasher
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,185 +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);
|
||||
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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,383 +1,383 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
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; }
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,350 +1,350 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
|
||||
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;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,118 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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,
|
||||
@@ -125,21 +125,21 @@ public sealed class FilePackRunStateStore : IPackRunStateStore
|
||||
{
|
||||
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();
|
||||
|
||||
.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,
|
||||
@@ -154,23 +154,23 @@ public sealed class FilePackRunStateStore : IPackRunStateStore
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
@@ -183,18 +183,18 @@ public sealed class FilePackRunStateStore : IPackRunStateStore
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -25,26 +25,26 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRu
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -76,12 +76,12 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRu
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failedPath = file + ".failed";
|
||||
File.Move(file, failedPath, overwrite: true);
|
||||
Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var failedPath = file + ".failed";
|
||||
File.Move(file, failedPath, overwrite: true);
|
||||
Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -108,23 +108,23 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRu
|
||||
=> 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);
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -1,73 +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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
public sealed class NotificationOptions
|
||||
{
|
||||
public Uri? ApprovalEndpoint { get; set; }
|
||||
|
||||
public Uri? PolicyEndpoint { get; set; }
|
||||
}
|
||||
namespace StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
public sealed class NotificationOptions
|
||||
{
|
||||
public Uri? ApprovalEndpoint { get; set; }
|
||||
|
||||
public Uri? PolicyEndpoint { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class FilePackRunStateStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAndGetAsync_RoundTripsState()
|
||||
{
|
||||
var directory = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var store = new FilePackRunStateStore(directory);
|
||||
var original = CreateState("run:primary");
|
||||
|
||||
await store.SaveAsync(original, CancellationToken.None);
|
||||
|
||||
var reloaded = await store.GetAsync("run:primary", CancellationToken.None);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal(original.RunId, reloaded!.RunId);
|
||||
Assert.Equal(original.PlanHash, reloaded.PlanHash);
|
||||
Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy);
|
||||
Assert.Equal(original.Steps.Count, reloaded.Steps.Count);
|
||||
var step = Assert.Single(reloaded.Steps);
|
||||
Assert.Equal("step-a", step.Key);
|
||||
Assert.Equal(original.Steps["step-a"], step.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsStatesInDeterministicOrder()
|
||||
{
|
||||
var directory = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var store = new FilePackRunStateStore(directory);
|
||||
var stateB = CreateState("run-b");
|
||||
var stateA = CreateState("run-a");
|
||||
|
||||
await store.SaveAsync(stateB, CancellationToken.None);
|
||||
await store.SaveAsync(stateA, CancellationToken.None);
|
||||
|
||||
var states = await store.ListAsync(CancellationToken.None);
|
||||
|
||||
Assert.Collection(states,
|
||||
first => Assert.Equal("run-a", first.RunId),
|
||||
second => Assert.Equal("run-b", second.RunId));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static PackRunState CreateState(string runId)
|
||||
{
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class FilePackRunStateStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAndGetAsync_RoundTripsState()
|
||||
{
|
||||
var directory = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var store = new FilePackRunStateStore(directory);
|
||||
var original = CreateState("run:primary");
|
||||
|
||||
await store.SaveAsync(original, CancellationToken.None);
|
||||
|
||||
var reloaded = await store.GetAsync("run:primary", CancellationToken.None);
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal(original.RunId, reloaded!.RunId);
|
||||
Assert.Equal(original.PlanHash, reloaded.PlanHash);
|
||||
Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy);
|
||||
Assert.Equal(original.Steps.Count, reloaded.Steps.Count);
|
||||
var step = Assert.Single(reloaded.Steps);
|
||||
Assert.Equal("step-a", step.Key);
|
||||
Assert.Equal(original.Steps["step-a"], step.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsStatesInDeterministicOrder()
|
||||
{
|
||||
var directory = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var store = new FilePackRunStateStore(directory);
|
||||
var stateB = CreateState("run-b");
|
||||
var stateA = CreateState("run-a");
|
||||
|
||||
await store.SaveAsync(stateB, CancellationToken.None);
|
||||
await store.SaveAsync(stateA, CancellationToken.None);
|
||||
|
||||
var states = await store.ListAsync(CancellationToken.None);
|
||||
|
||||
Assert.Collection(states,
|
||||
first => Assert.Equal("run-a", first.RunId),
|
||||
second => Assert.Equal("run-b", second.RunId));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static PackRunState CreateState(string runId)
|
||||
{
|
||||
var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false);
|
||||
var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty<string>());
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
@@ -89,41 +89,41 @@ public sealed class FilePackRunStateStoreTests
|
||||
["step-a"] = new PackRunStepStateRecord(
|
||||
StepId: "step-a",
|
||||
Kind: PackRunStepKind.Run,
|
||||
Enabled: true,
|
||||
ContinueOnError: false,
|
||||
MaxParallel: null,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Status: PackRunStepExecutionStatus.Pending,
|
||||
Attempts: 1,
|
||||
LastTransitionAt: DateTimeOffset.UtcNow,
|
||||
NextAttemptAt: null,
|
||||
Enabled: true,
|
||||
ContinueOnError: false,
|
||||
MaxParallel: null,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Status: PackRunStepExecutionStatus.Pending,
|
||||
Attempts: 1,
|
||||
LastTransitionAt: DateTimeOffset.UtcNow,
|
||||
NextAttemptAt: null,
|
||||
StatusReason: null)
|
||||
};
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
return PackRunState.Create(runId, "hash-123", plan, failurePolicy, timestamp, steps, timestamp);
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void TryDelete(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow cleanup errors to avoid masking test assertions.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void TryDelete(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow cleanup errors to avoid masking test assertions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunApprovalCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_FromPlan_PopulatesApprovals()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var approvals = coordinator.GetApprovals();
|
||||
Assert.Single(approvals);
|
||||
Assert.Equal("security-review", approvals[0].ApprovalId);
|
||||
Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_AllowsResumeWhenLastApprovalCompletes()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.True(result.ShouldResumeRun);
|
||||
Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status);
|
||||
Assert.Equal("approver-1", result.State.ActorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_DoesNotResumeAndMarksState()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe");
|
||||
|
||||
Assert.False(result.ShouldResumeRun);
|
||||
Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status);
|
||||
Assert.Equal("Not safe", result.State.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNotifications_UsesRequirements()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var notifications = coordinator.BuildNotifications(plan);
|
||||
Assert.Single(notifications);
|
||||
var notification = notifications[0];
|
||||
Assert.Equal("security-review", notification.ApprovalId);
|
||||
Assert.Contains("Packs.Approve", notification.RequiredGrants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPolicyNotifications_ProducesGateMetadata()
|
||||
{
|
||||
var plan = BuildPolicyPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var notifications = coordinator.BuildPolicyNotifications(plan);
|
||||
Assert.Single(notifications);
|
||||
var hint = notifications[0];
|
||||
Assert.Equal("policy-check", hint.StepId);
|
||||
var parameter = hint.Parameters.Single(p => p.Name == "threshold");
|
||||
Assert.False(parameter.RequiresRuntimeValue);
|
||||
var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef");
|
||||
Assert.True(runtimeParam.RequiresRuntimeValue);
|
||||
Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression);
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(false)
|
||||
};
|
||||
|
||||
return planner.Plan(manifest, inputs).Plan!;
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildPolicyPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
return planner.Plan(manifest).Plan!;
|
||||
}
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunApprovalCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_FromPlan_PopulatesApprovals()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var approvals = coordinator.GetApprovals();
|
||||
Assert.Single(approvals);
|
||||
Assert.Equal("security-review", approvals[0].ApprovalId);
|
||||
Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_AllowsResumeWhenLastApprovalCompletes()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.True(result.ShouldResumeRun);
|
||||
Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status);
|
||||
Assert.Equal("approver-1", result.State.ActorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reject_DoesNotResumeAndMarksState()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe");
|
||||
|
||||
Assert.False(result.ShouldResumeRun);
|
||||
Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status);
|
||||
Assert.Equal("Not safe", result.State.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNotifications_UsesRequirements()
|
||||
{
|
||||
var plan = BuildPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var notifications = coordinator.BuildNotifications(plan);
|
||||
Assert.Single(notifications);
|
||||
var notification = notifications[0];
|
||||
Assert.Equal("security-review", notification.ApprovalId);
|
||||
Assert.Contains("Packs.Approve", notification.RequiredGrants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPolicyNotifications_ProducesGateMetadata()
|
||||
{
|
||||
var plan = BuildPolicyPlan();
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var notifications = coordinator.BuildPolicyNotifications(plan);
|
||||
Assert.Single(notifications);
|
||||
var hint = notifications[0];
|
||||
Assert.Equal("policy-check", hint.StepId);
|
||||
var parameter = hint.Parameters.Single(p => p.Name == "threshold");
|
||||
Assert.False(parameter.RequiresRuntimeValue);
|
||||
var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef");
|
||||
Assert.True(runtimeParam.RequiresRuntimeValue);
|
||||
Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression);
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(false)
|
||||
};
|
||||
|
||||
return planner.Plan(manifest, inputs).Plan!;
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildPolicyPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
return planner.Plan(manifest).Plan!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunExecutionGraphBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_GeneratesParallelMetadata()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Parallel);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
|
||||
var builder = new PackRunExecutionGraphBuilder();
|
||||
var graph = builder.Build(plan);
|
||||
|
||||
Assert.Equal(2, graph.FailurePolicy.MaxAttempts);
|
||||
Assert.Equal(10, graph.FailurePolicy.BackoffSeconds);
|
||||
|
||||
var parallel = Assert.Single(graph.Steps);
|
||||
Assert.Equal(PackRunStepKind.Parallel, parallel.Kind);
|
||||
Assert.True(parallel.Enabled);
|
||||
Assert.Equal(2, parallel.MaxParallel);
|
||||
Assert.True(parallel.ContinueOnError);
|
||||
Assert.Equal(2, parallel.Children.Count);
|
||||
Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PreservesMapIterationsAndDisabledSteps()
|
||||
{
|
||||
var planner = new TaskPackPlanner();
|
||||
var builder = new PackRunExecutionGraphBuilder();
|
||||
|
||||
// Map iterations
|
||||
var mapManifest = TestManifests.Load(TestManifests.Map);
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray("alpha", "beta", "gamma")
|
||||
};
|
||||
|
||||
var mapPlan = planner.Plan(mapManifest, inputs).Plan!;
|
||||
var mapGraph = builder.Build(mapPlan);
|
||||
|
||||
var mapStep = Assert.Single(mapGraph.Steps);
|
||||
Assert.Equal(PackRunStepKind.Map, mapStep.Kind);
|
||||
Assert.Equal(3, mapStep.Children.Count);
|
||||
Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
|
||||
|
||||
// Disabled conditional step
|
||||
var conditionalManifest = TestManifests.Load(TestManifests.Sample);
|
||||
var conditionalInputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(true)
|
||||
};
|
||||
|
||||
var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!;
|
||||
var conditionalGraph = builder.Build(conditionalPlan);
|
||||
|
||||
var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step");
|
||||
Assert.False(applyStep.Enabled);
|
||||
}
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunExecutionGraphBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_GeneratesParallelMetadata()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Parallel);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
|
||||
var builder = new PackRunExecutionGraphBuilder();
|
||||
var graph = builder.Build(plan);
|
||||
|
||||
Assert.Equal(2, graph.FailurePolicy.MaxAttempts);
|
||||
Assert.Equal(10, graph.FailurePolicy.BackoffSeconds);
|
||||
|
||||
var parallel = Assert.Single(graph.Steps);
|
||||
Assert.Equal(PackRunStepKind.Parallel, parallel.Kind);
|
||||
Assert.True(parallel.Enabled);
|
||||
Assert.Equal(2, parallel.MaxParallel);
|
||||
Assert.True(parallel.ContinueOnError);
|
||||
Assert.Equal(2, parallel.Children.Count);
|
||||
Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PreservesMapIterationsAndDisabledSteps()
|
||||
{
|
||||
var planner = new TaskPackPlanner();
|
||||
var builder = new PackRunExecutionGraphBuilder();
|
||||
|
||||
// Map iterations
|
||||
var mapManifest = TestManifests.Load(TestManifests.Map);
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray("alpha", "beta", "gamma")
|
||||
};
|
||||
|
||||
var mapPlan = planner.Plan(mapManifest, inputs).Plan!;
|
||||
var mapGraph = builder.Build(mapPlan);
|
||||
|
||||
var mapStep = Assert.Single(mapGraph.Steps);
|
||||
Assert.Equal(PackRunStepKind.Map, mapStep.Kind);
|
||||
Assert.Equal(3, mapStep.Children.Count);
|
||||
Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
|
||||
|
||||
// Disabled conditional step
|
||||
var conditionalManifest = TestManifests.Load(TestManifests.Sample);
|
||||
var conditionalInputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(true)
|
||||
};
|
||||
|
||||
var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!;
|
||||
var conditionalGraph = builder.Build(conditionalPlan);
|
||||
|
||||
var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step");
|
||||
Assert.False(applyStep.Enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunGateStateUpdaterTests
|
||||
{
|
||||
private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch;
|
||||
private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5);
|
||||
|
||||
[Fact]
|
||||
public void Apply_ApprovedGate_ClearsReasonAndSucceeds()
|
||||
{
|
||||
var plan = BuildApprovalPlan();
|
||||
var graph = new PackRunExecutionGraphBuilder().Build(plan);
|
||||
var state = CreateInitialState(plan, graph);
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
|
||||
coordinator.Approve("security-review", "approver-1", UpdateTimestamp);
|
||||
|
||||
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
|
||||
|
||||
Assert.False(result.HasBlockingFailure);
|
||||
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
|
||||
|
||||
var gate = result.State.Steps["approval"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
|
||||
Assert.Null(gate.StatusReason);
|
||||
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_RejectedGate_FlagsFailure()
|
||||
{
|
||||
var plan = BuildApprovalPlan();
|
||||
var graph = new PackRunExecutionGraphBuilder().Build(plan);
|
||||
var state = CreateInitialState(plan, graph);
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
|
||||
coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe");
|
||||
|
||||
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
|
||||
|
||||
Assert.True(result.HasBlockingFailure);
|
||||
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
|
||||
|
||||
var gate = result.State.Steps["approval"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status);
|
||||
Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal);
|
||||
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_PolicyGate_ClearsPendingReason()
|
||||
{
|
||||
var plan = BuildPolicyPlan();
|
||||
var graph = new PackRunExecutionGraphBuilder().Build(plan);
|
||||
var state = CreateInitialState(plan, graph);
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
|
||||
|
||||
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
|
||||
|
||||
Assert.False(result.HasBlockingFailure);
|
||||
|
||||
var gate = result.State.Steps["policy-check"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
|
||||
Assert.Null(gate.StatusReason);
|
||||
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
|
||||
|
||||
var prepare = result.State.Steps["prepare"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status);
|
||||
Assert.Null(prepare.StatusReason);
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildApprovalPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, System.Text.Json.Nodes.JsonNode?>
|
||||
{
|
||||
["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false)
|
||||
};
|
||||
|
||||
return planner.Plan(manifest, inputs).Plan!;
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildPolicyPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
return planner.Plan(manifest).Plan!;
|
||||
}
|
||||
|
||||
private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph)
|
||||
{
|
||||
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var step in EnumerateSteps(graph.Steps))
|
||||
{
|
||||
var status = PackRunStepExecutionStatus.Pending;
|
||||
string? reason = null;
|
||||
|
||||
if (!step.Enabled)
|
||||
{
|
||||
status = PackRunStepExecutionStatus.Skipped;
|
||||
reason = "disabled";
|
||||
}
|
||||
else if (step.Kind == PackRunStepKind.GateApproval)
|
||||
{
|
||||
reason = "requires-approval";
|
||||
}
|
||||
else if (step.Kind == PackRunStepKind.GatePolicy)
|
||||
{
|
||||
reason = "requires-policy";
|
||||
}
|
||||
|
||||
steps[step.Id] = new PackRunStepStateRecord(
|
||||
step.Id,
|
||||
step.Kind,
|
||||
step.Enabled,
|
||||
step.ContinueOnError,
|
||||
step.MaxParallel,
|
||||
step.ApprovalId,
|
||||
step.GateMessage,
|
||||
status,
|
||||
Attempts: 0,
|
||||
LastTransitionAt: null,
|
||||
NextAttemptAt: null,
|
||||
StatusReason: reason);
|
||||
}
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunGateStateUpdaterTests
|
||||
{
|
||||
private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch;
|
||||
private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5);
|
||||
|
||||
[Fact]
|
||||
public void Apply_ApprovedGate_ClearsReasonAndSucceeds()
|
||||
{
|
||||
var plan = BuildApprovalPlan();
|
||||
var graph = new PackRunExecutionGraphBuilder().Build(plan);
|
||||
var state = CreateInitialState(plan, graph);
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
|
||||
coordinator.Approve("security-review", "approver-1", UpdateTimestamp);
|
||||
|
||||
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
|
||||
|
||||
Assert.False(result.HasBlockingFailure);
|
||||
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
|
||||
|
||||
var gate = result.State.Steps["approval"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
|
||||
Assert.Null(gate.StatusReason);
|
||||
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_RejectedGate_FlagsFailure()
|
||||
{
|
||||
var plan = BuildApprovalPlan();
|
||||
var graph = new PackRunExecutionGraphBuilder().Build(plan);
|
||||
var state = CreateInitialState(plan, graph);
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
|
||||
coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe");
|
||||
|
||||
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
|
||||
|
||||
Assert.True(result.HasBlockingFailure);
|
||||
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
|
||||
|
||||
var gate = result.State.Steps["approval"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status);
|
||||
Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal);
|
||||
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_PolicyGate_ClearsPendingReason()
|
||||
{
|
||||
var plan = BuildPolicyPlan();
|
||||
var graph = new PackRunExecutionGraphBuilder().Build(plan);
|
||||
var state = CreateInitialState(plan, graph);
|
||||
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
|
||||
|
||||
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
|
||||
|
||||
Assert.False(result.HasBlockingFailure);
|
||||
|
||||
var gate = result.State.Steps["policy-check"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
|
||||
Assert.Null(gate.StatusReason);
|
||||
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
|
||||
|
||||
var prepare = result.State.Steps["prepare"];
|
||||
Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status);
|
||||
Assert.Null(prepare.StatusReason);
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildApprovalPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, System.Text.Json.Nodes.JsonNode?>
|
||||
{
|
||||
["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false)
|
||||
};
|
||||
|
||||
return planner.Plan(manifest, inputs).Plan!;
|
||||
}
|
||||
|
||||
private static TaskPackPlan BuildPolicyPlan()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
return planner.Plan(manifest).Plan!;
|
||||
}
|
||||
|
||||
private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph)
|
||||
{
|
||||
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var step in EnumerateSteps(graph.Steps))
|
||||
{
|
||||
var status = PackRunStepExecutionStatus.Pending;
|
||||
string? reason = null;
|
||||
|
||||
if (!step.Enabled)
|
||||
{
|
||||
status = PackRunStepExecutionStatus.Skipped;
|
||||
reason = "disabled";
|
||||
}
|
||||
else if (step.Kind == PackRunStepKind.GateApproval)
|
||||
{
|
||||
reason = "requires-approval";
|
||||
}
|
||||
else if (step.Kind == PackRunStepKind.GatePolicy)
|
||||
{
|
||||
reason = "requires-policy";
|
||||
}
|
||||
|
||||
steps[step.Id] = new PackRunStepStateRecord(
|
||||
step.Id,
|
||||
step.Kind,
|
||||
step.Enabled,
|
||||
step.ContinueOnError,
|
||||
step.MaxParallel,
|
||||
step.ApprovalId,
|
||||
step.GateMessage,
|
||||
status,
|
||||
Attempts: 0,
|
||||
LastTransitionAt: null,
|
||||
NextAttemptAt: null,
|
||||
StatusReason: reason);
|
||||
}
|
||||
|
||||
return PackRunState.Create("run-1", plan.Hash, plan, graph.FailurePolicy, RequestedAt, steps, RequestedAt);
|
||||
}
|
||||
|
||||
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
|
||||
{
|
||||
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 IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
|
||||
{
|
||||
foreach (var step in steps)
|
||||
{
|
||||
yield return step;
|
||||
|
||||
if (step.Children.Count > 0)
|
||||
{
|
||||
foreach (var child in EnumerateSteps(step.Children))
|
||||
{
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?> { ["dryRun"] = JsonValue.Create(false) }).Plan!;
|
||||
var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var store = new TestApprovalStore();
|
||||
var publisher = new TestNotificationPublisher();
|
||||
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
|
||||
|
||||
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(result.ShouldResumeImmediately);
|
||||
var saved = Assert.Single(store.Saved);
|
||||
Assert.Equal("security-review", saved.ApprovalId);
|
||||
Assert.Single(publisher.Approvals);
|
||||
Assert.Empty(publisher.Policies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Output);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var store = new TestApprovalStore();
|
||||
var publisher = new TestNotificationPublisher();
|
||||
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
|
||||
|
||||
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(result.ShouldResumeImmediately);
|
||||
Assert.Empty(store.Saved);
|
||||
Assert.Empty(publisher.Approvals);
|
||||
}
|
||||
|
||||
private sealed class TestApprovalStore : IPackRunApprovalStore
|
||||
{
|
||||
public List<PackRunApprovalState> Saved { get; } = new();
|
||||
|
||||
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult((IReadOnlyList<PackRunApprovalState>)Saved);
|
||||
|
||||
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
|
||||
{
|
||||
Saved.Clear();
|
||||
Saved.AddRange(approvals);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestNotificationPublisher : IPackRunNotificationPublisher
|
||||
{
|
||||
public List<ApprovalNotification> Approvals { get; } = new();
|
||||
public List<PolicyGateNotification> Policies { get; } = new();
|
||||
|
||||
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Approvals.Add(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Policies.Add(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?> { ["dryRun"] = JsonValue.Create(false) }).Plan!;
|
||||
var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var store = new TestApprovalStore();
|
||||
var publisher = new TestNotificationPublisher();
|
||||
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
|
||||
|
||||
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(result.ShouldResumeImmediately);
|
||||
var saved = Assert.Single(store.Saved);
|
||||
Assert.Equal("security-review", saved.ApprovalId);
|
||||
Assert.Single(publisher.Approvals);
|
||||
Assert.Empty(publisher.Policies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Output);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow);
|
||||
|
||||
var store = new TestApprovalStore();
|
||||
var publisher = new TestNotificationPublisher();
|
||||
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
|
||||
|
||||
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(result.ShouldResumeImmediately);
|
||||
Assert.Empty(store.Saved);
|
||||
Assert.Empty(publisher.Approvals);
|
||||
}
|
||||
|
||||
private sealed class TestApprovalStore : IPackRunApprovalStore
|
||||
{
|
||||
public List<PackRunApprovalState> Saved { get; } = new();
|
||||
|
||||
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult((IReadOnlyList<PackRunApprovalState>)Saved);
|
||||
|
||||
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
|
||||
{
|
||||
Saved.Clear();
|
||||
Saved.AddRange(approvals);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestNotificationPublisher : IPackRunNotificationPublisher
|
||||
{
|
||||
public List<ApprovalNotification> Approvals { get; } = new();
|
||||
public List<PolicyGateNotification> Policies { get; } = new();
|
||||
|
||||
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Approvals.Add(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
Policies.Add(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +1,142 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunSimulationEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Simulate_IdentifiesGateStatuses()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy);
|
||||
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status);
|
||||
|
||||
var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run);
|
||||
Assert.Equal(PackRunSimulationStatus.Pending, run.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_MarksDisabledStepsAndOutputs()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(true)
|
||||
};
|
||||
|
||||
var plan = planner.Plan(manifest, inputs).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var applyStep = result.Steps.Single(step => step.Id == "apply-step");
|
||||
Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status);
|
||||
|
||||
Assert.Empty(result.Outputs);
|
||||
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts);
|
||||
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_ProjectsOutputsAndRuntimeFlags()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Output);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var step = Assert.Single(result.Steps);
|
||||
Assert.Equal(PackRunStepKind.Run, step.Kind);
|
||||
|
||||
Assert.Collection(result.Outputs,
|
||||
bundle =>
|
||||
{
|
||||
Assert.Equal("bundlePath", bundle.Name);
|
||||
Assert.False(bundle.RequiresRuntimeValue);
|
||||
},
|
||||
evidence =>
|
||||
{
|
||||
Assert.Equal("evidenceModel", evidence.Name);
|
||||
Assert.True(evidence.RequiresRuntimeValue);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_LoopStep_SetsWillIterateStatus()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Loop);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray { "a", "b", "c" }
|
||||
};
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.NotNull(result.Plan);
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var simResult = engine.Simulate(result.Plan);
|
||||
|
||||
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
|
||||
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
|
||||
Assert.Equal("process-loop", loopStep.Id);
|
||||
Assert.NotNull(loopStep.LoopInfo);
|
||||
Assert.Equal("target", loopStep.LoopInfo.Iterator);
|
||||
Assert.Equal("idx", loopStep.LoopInfo.Index);
|
||||
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
|
||||
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_ConditionalStep_SetsWillBranchStatus()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Conditional);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["environment"] = JsonValue.Create("production")
|
||||
};
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.NotNull(result.Plan);
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var simResult = engine.Simulate(result.Plan);
|
||||
|
||||
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
|
||||
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
|
||||
Assert.Equal("env-branch", conditionalStep.Id);
|
||||
Assert.NotNull(conditionalStep.ConditionalInfo);
|
||||
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
|
||||
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_PolicyGateStep_HasPolicyInfo()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
|
||||
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
|
||||
Assert.NotNull(policyStep.PolicyInfo);
|
||||
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
|
||||
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
|
||||
}
|
||||
}
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunSimulationEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Simulate_IdentifiesGateStatuses()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy);
|
||||
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status);
|
||||
|
||||
var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run);
|
||||
Assert.Equal(PackRunSimulationStatus.Pending, run.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_MarksDisabledStepsAndOutputs()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(true)
|
||||
};
|
||||
|
||||
var plan = planner.Plan(manifest, inputs).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var applyStep = result.Steps.Single(step => step.Id == "apply-step");
|
||||
Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status);
|
||||
|
||||
Assert.Empty(result.Outputs);
|
||||
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts);
|
||||
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_ProjectsOutputsAndRuntimeFlags()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Output);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var step = Assert.Single(result.Steps);
|
||||
Assert.Equal(PackRunStepKind.Run, step.Kind);
|
||||
|
||||
Assert.Collection(result.Outputs,
|
||||
bundle =>
|
||||
{
|
||||
Assert.Equal("bundlePath", bundle.Name);
|
||||
Assert.False(bundle.RequiresRuntimeValue);
|
||||
},
|
||||
evidence =>
|
||||
{
|
||||
Assert.Equal("evidenceModel", evidence.Name);
|
||||
Assert.True(evidence.RequiresRuntimeValue);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_LoopStep_SetsWillIterateStatus()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Loop);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray { "a", "b", "c" }
|
||||
};
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.NotNull(result.Plan);
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var simResult = engine.Simulate(result.Plan);
|
||||
|
||||
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
|
||||
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
|
||||
Assert.Equal("process-loop", loopStep.Id);
|
||||
Assert.NotNull(loopStep.LoopInfo);
|
||||
Assert.Equal("target", loopStep.LoopInfo.Iterator);
|
||||
Assert.Equal("idx", loopStep.LoopInfo.Index);
|
||||
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
|
||||
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_ConditionalStep_SetsWillBranchStatus()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Conditional);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["environment"] = JsonValue.Create("production")
|
||||
};
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.NotNull(result.Plan);
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var simResult = engine.Simulate(result.Plan);
|
||||
|
||||
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
|
||||
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
|
||||
Assert.Equal("env-branch", conditionalStep.Id);
|
||||
Assert.NotNull(conditionalStep.ConditionalInfo);
|
||||
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
|
||||
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_PolicyGateStep_HasPolicyInfo()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
|
||||
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
|
||||
Assert.NotNull(policyStep.PolicyInfo);
|
||||
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
|
||||
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunStepStateMachineTests
|
||||
{
|
||||
private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false);
|
||||
|
||||
[Fact]
|
||||
public void Start_FromPending_SetsRunning()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.Equal(PackRunStepExecutionStatus.Running, started.Status);
|
||||
Assert.Equal(0, started.Attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteSuccess_IncrementsAttempts()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
|
||||
var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1));
|
||||
|
||||
Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status);
|
||||
Assert.Equal(1, completed.Attempts);
|
||||
Assert.Null(completed.NextAttemptAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterFailure_SchedulesRetryUntilMaxAttempts()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
|
||||
|
||||
var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy);
|
||||
Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome);
|
||||
Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status);
|
||||
Assert.Equal(1, firstFailure.State.Attempts);
|
||||
Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt);
|
||||
|
||||
var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7));
|
||||
var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy);
|
||||
Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome);
|
||||
Assert.Equal(2, secondFailure.State.Attempts);
|
||||
|
||||
var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds));
|
||||
var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy);
|
||||
Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome);
|
||||
Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status);
|
||||
Assert.Equal(3, terminalFailure.State.Attempts);
|
||||
Assert.Null(terminalFailure.State.NextAttemptAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skip_FromPending_SetsSkipped()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1));
|
||||
|
||||
Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status);
|
||||
Assert.Equal(0, skipped.Attempts);
|
||||
}
|
||||
}
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class PackRunStepStateMachineTests
|
||||
{
|
||||
private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false);
|
||||
|
||||
[Fact]
|
||||
public void Start_FromPending_SetsRunning()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
|
||||
|
||||
Assert.Equal(PackRunStepExecutionStatus.Running, started.Status);
|
||||
Assert.Equal(0, started.Attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteSuccess_IncrementsAttempts()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
|
||||
var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1));
|
||||
|
||||
Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status);
|
||||
Assert.Equal(1, completed.Attempts);
|
||||
Assert.Null(completed.NextAttemptAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterFailure_SchedulesRetryUntilMaxAttempts()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
|
||||
|
||||
var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy);
|
||||
Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome);
|
||||
Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status);
|
||||
Assert.Equal(1, firstFailure.State.Attempts);
|
||||
Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt);
|
||||
|
||||
var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7));
|
||||
var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy);
|
||||
Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome);
|
||||
Assert.Equal(2, secondFailure.State.Attempts);
|
||||
|
||||
var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds));
|
||||
var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy);
|
||||
Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome);
|
||||
Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status);
|
||||
Assert.Equal(3, terminalFailure.State.Attempts);
|
||||
Assert.Null(terminalFailure.State.NextAttemptAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skip_FromPending_SetsSkipped()
|
||||
{
|
||||
var state = PackRunStepStateMachine.Create();
|
||||
var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1));
|
||||
|
||||
Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status);
|
||||
Assert.Equal(0, skipped.Attempts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,39 +3,39 @@ using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class TaskPackPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Plan_WithSequentialSteps_ComputesDeterministicHash()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(false)
|
||||
};
|
||||
|
||||
var resultA = planner.Plan(manifest, inputs);
|
||||
Assert.True(resultA.Success);
|
||||
var plan = resultA.Plan!;
|
||||
Assert.Equal(3, plan.Steps.Count);
|
||||
Assert.Equal("plan-step", plan.Steps[0].Id);
|
||||
Assert.Equal("plan-step", plan.Steps[0].TemplateId);
|
||||
Assert.Equal("run", plan.Steps[0].Type);
|
||||
Assert.Equal("gate.approval", plan.Steps[1].Type);
|
||||
Assert.Equal("security-review", plan.Steps[1].ApprovalId);
|
||||
Assert.Equal("run", plan.Steps[2].Type);
|
||||
Assert.True(plan.Steps[2].Enabled);
|
||||
Assert.Single(plan.Approvals);
|
||||
Assert.Equal("security-review", plan.Approvals[0].Id);
|
||||
Assert.False(string.IsNullOrWhiteSpace(plan.Hash));
|
||||
|
||||
var resultB = planner.Plan(manifest, inputs);
|
||||
Assert.True(resultB.Success);
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class TaskPackPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Plan_WithSequentialSteps_ComputesDeterministicHash()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(false)
|
||||
};
|
||||
|
||||
var resultA = planner.Plan(manifest, inputs);
|
||||
Assert.True(resultA.Success);
|
||||
var plan = resultA.Plan!;
|
||||
Assert.Equal(3, plan.Steps.Count);
|
||||
Assert.Equal("plan-step", plan.Steps[0].Id);
|
||||
Assert.Equal("plan-step", plan.Steps[0].TemplateId);
|
||||
Assert.Equal("run", plan.Steps[0].Type);
|
||||
Assert.Equal("gate.approval", plan.Steps[1].Type);
|
||||
Assert.Equal("security-review", plan.Steps[1].ApprovalId);
|
||||
Assert.Equal("run", plan.Steps[2].Type);
|
||||
Assert.True(plan.Steps[2].Enabled);
|
||||
Assert.Single(plan.Approvals);
|
||||
Assert.Equal("security-review", plan.Approvals[0].Id);
|
||||
Assert.False(string.IsNullOrWhiteSpace(plan.Hash));
|
||||
|
||||
var resultB = planner.Plan(manifest, inputs);
|
||||
Assert.True(resultB.Success);
|
||||
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
|
||||
}
|
||||
|
||||
@@ -54,108 +54,108 @@ public sealed class TaskPackPlannerTests
|
||||
Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(true)
|
||||
};
|
||||
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Plan!.Steps[2].Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithStepReferences_MarksParametersAsRuntime()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.StepReference);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
Assert.Equal(2, plan.Steps.Count);
|
||||
var referenceParameters = plan.Steps[1].Parameters!;
|
||||
Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue);
|
||||
Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithMapStep_ExpandsIterations()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Map);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray("alpha", "beta", "gamma")
|
||||
};
|
||||
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
var mapStep = plan.Steps.Single(s => s.Type == "map");
|
||||
Assert.Equal(3, mapStep.Children!.Count);
|
||||
Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId));
|
||||
Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue<int>());
|
||||
Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectApprovalRequirements_GroupsGates()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan);
|
||||
Assert.Single(requirements);
|
||||
var requirement = requirements[0];
|
||||
Assert.Equal("security-review", requirement.ApprovalId);
|
||||
Assert.Contains("Packs.Approve", requirement.Grants);
|
||||
Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single());
|
||||
|
||||
var notifications = TaskPackPlanInsights.CollectNotificationHints(plan);
|
||||
Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithSecretReference_RecordsSecretMetadata()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Secret);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
Assert.Single(plan.Secrets);
|
||||
Assert.Equal("apiKey", plan.Secrets[0].Name);
|
||||
var param = plan.Steps[0].Parameters!["token"];
|
||||
Assert.True(param.RequiresRuntimeValue);
|
||||
Assert.Equal("secrets.apiKey", param.Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["dryRun"] = JsonValue.Create(true)
|
||||
};
|
||||
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.Plan!.Steps[2].Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithStepReferences_MarksParametersAsRuntime()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.StepReference);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
Assert.Equal(2, plan.Steps.Count);
|
||||
var referenceParameters = plan.Steps[1].Parameters!;
|
||||
Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue);
|
||||
Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithMapStep_ExpandsIterations()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Map);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray("alpha", "beta", "gamma")
|
||||
};
|
||||
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
var mapStep = plan.Steps.Single(s => s.Type == "map");
|
||||
Assert.Equal(3, mapStep.Children!.Count);
|
||||
Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId));
|
||||
Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue<int>());
|
||||
Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectApprovalRequirements_GroupsGates()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan);
|
||||
Assert.Single(requirements);
|
||||
var requirement = requirements[0];
|
||||
Assert.Equal("security-review", requirement.ApprovalId);
|
||||
Assert.Contains("Packs.Approve", requirement.Grants);
|
||||
Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single());
|
||||
|
||||
var notifications = TaskPackPlanInsights.CollectNotificationHints(plan);
|
||||
Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithSecretReference_RecordsSecretMetadata()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Secret);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
Assert.Single(plan.Secrets);
|
||||
Assert.Equal("apiKey", plan.Secrets[0].Name);
|
||||
var param = plan.Steps[0].Parameters!["token"];
|
||||
Assert.True(param.RequiresRuntimeValue);
|
||||
Assert.Equal("secrets.apiKey", param.Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WithOutputs_ProjectsResolvedValues()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Output);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
Assert.Equal(2, plan.Outputs.Count);
|
||||
|
||||
var bundle = plan.Outputs.First(o => o.Name == "bundlePath");
|
||||
Assert.NotNull(bundle.Path);
|
||||
Assert.False(bundle.Path!.RequiresRuntimeValue);
|
||||
Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue<string>());
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var plan = result.Plan!;
|
||||
Assert.Equal(2, plan.Outputs.Count);
|
||||
|
||||
var bundle = plan.Outputs.First(o => o.Name == "bundlePath");
|
||||
Assert.NotNull(bundle.Path);
|
||||
Assert.False(bundle.Path!.RequiresRuntimeValue);
|
||||
Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue<string>());
|
||||
|
||||
var evidence = plan.Outputs.First(o => o.Name == "evidenceModel");
|
||||
Assert.NotNull(evidence.Expression);
|
||||
Assert.True(evidence.Expression!.RequiresRuntimeValue);
|
||||
@@ -211,8 +211,8 @@ public sealed class TaskPackPlannerTests
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, error => error.Message.Contains("example.com", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
[Fact]
|
||||
public void Plan_WhenRequiredInputMissing_ReturnsError()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.RequiredInput);
|
||||
|
||||
@@ -9,16 +9,16 @@ using StellaOps.TaskRunner.Core.Configuration;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Worker.Services;
|
||||
|
||||
public sealed class PackRunWorkerService : BackgroundService
|
||||
{
|
||||
private const string ChildFailureReason = "child-failure";
|
||||
private const string AwaitingRetryReason = "awaiting-retry";
|
||||
|
||||
private readonly IPackRunJobDispatcher dispatcher;
|
||||
private readonly PackRunProcessor processor;
|
||||
|
||||
namespace StellaOps.TaskRunner.Worker.Services;
|
||||
|
||||
public sealed class PackRunWorkerService : BackgroundService
|
||||
{
|
||||
private const string ChildFailureReason = "child-failure";
|
||||
private const string AwaitingRetryReason = "awaiting-retry";
|
||||
|
||||
private readonly IPackRunJobDispatcher dispatcher;
|
||||
private readonly PackRunProcessor processor;
|
||||
private readonly PackRunWorkerOptions options;
|
||||
private readonly IPackRunStateStore stateStore;
|
||||
private readonly PackRunExecutionGraphBuilder graphBuilder;
|
||||
@@ -66,25 +66,25 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
: 0));
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (context is null)
|
||||
{
|
||||
await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (context is null)
|
||||
{
|
||||
await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -103,7 +103,7 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task ProcessRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("Processing pack run {RunId}.", context.RunId);
|
||||
@@ -206,55 +206,55 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
var entry = new PackRunLogEntry(DateTimeOffset.UtcNow, level, eventType, message, stepId, metadata);
|
||||
return logStore.AppendAsync(runId, entry, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PackRunState> ExecuteGraphAsync(
|
||||
PackRunExecutionContext context,
|
||||
PackRunExecutionGraph graph,
|
||||
PackRunState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mutable = new ConcurrentDictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
|
||||
var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
|
||||
var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken);
|
||||
|
||||
foreach (var step in graph.Steps)
|
||||
{
|
||||
var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var updated = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable);
|
||||
return state with
|
||||
{
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Steps = updated
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
{
|
||||
executionContext.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
if (!record.Enabled)
|
||||
{
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped)
|
||||
{
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
|
||||
private async Task<PackRunState> ExecuteGraphAsync(
|
||||
PackRunExecutionContext context,
|
||||
PackRunExecutionGraph graph,
|
||||
PackRunState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mutable = new ConcurrentDictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
|
||||
var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
|
||||
var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken);
|
||||
|
||||
foreach (var step in graph.Steps)
|
||||
{
|
||||
var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var updated = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable);
|
||||
return state with
|
||||
{
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Steps = updated
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
{
|
||||
executionContext.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
if (!record.Enabled)
|
||||
{
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped)
|
||||
{
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
if (record.NextAttemptAt is { } scheduled && scheduled > DateTimeOffset.UtcNow)
|
||||
{
|
||||
logger.LogInformation(
|
||||
@@ -285,7 +285,7 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Succeeded,
|
||||
StatusReason = null,
|
||||
StatusReason = null,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow,
|
||||
NextAttemptAt = null
|
||||
};
|
||||
@@ -302,11 +302,11 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
return await ExecuteParallelStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
|
||||
case PackRunStepKind.Map:
|
||||
return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
|
||||
case PackRunStepKind.Run:
|
||||
return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
|
||||
return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
|
||||
case PackRunStepKind.Run:
|
||||
return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false);
|
||||
|
||||
default:
|
||||
logger.LogWarning("Run {RunId} encountered unsupported step kind '{Kind}' for step {StepId}. Marking as skipped.",
|
||||
executionContext.RunId,
|
||||
@@ -332,7 +332,7 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteRunStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
@@ -468,185 +468,185 @@ public sealed class PackRunWorkerService : BackgroundService
|
||||
{
|
||||
PackRunStepFailureOutcome.Retry => StepExecutionOutcome.Defer,
|
||||
PackRunStepFailureOutcome.Abort when step.ContinueOnError => StepExecutionOutcome.Continue,
|
||||
PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun,
|
||||
_ => StepExecutionOutcome.AbortRun
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteParallelStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
{
|
||||
var children = step.Children;
|
||||
if (children.Count == 0)
|
||||
{
|
||||
MarkContainerSucceeded(step, executionContext);
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count;
|
||||
var queue = new Queue<PackRunExecutionStep>(children);
|
||||
var running = new List<Task<StepExecutionOutcome>>(maxParallel);
|
||||
var outcome = StepExecutionOutcome.Continue;
|
||||
var childFailureDetected = false;
|
||||
|
||||
while (queue.Count > 0 || running.Count > 0)
|
||||
{
|
||||
while (queue.Count > 0 && running.Count < maxParallel)
|
||||
{
|
||||
var child = queue.Dequeue();
|
||||
running.Add(ExecuteStepAsync(child, executionContext));
|
||||
}
|
||||
|
||||
var completed = await Task.WhenAny(running).ConfigureAwait(false);
|
||||
running.Remove(completed);
|
||||
var childOutcome = await completed.ConfigureAwait(false);
|
||||
|
||||
switch (childOutcome)
|
||||
{
|
||||
case StepExecutionOutcome.AbortRun:
|
||||
if (step.ContinueOnError)
|
||||
{
|
||||
childFailureDetected = true;
|
||||
outcome = StepExecutionOutcome.Continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = StepExecutionOutcome.AbortRun;
|
||||
running.Clear();
|
||||
queue.Clear();
|
||||
}
|
||||
break;
|
||||
|
||||
case StepExecutionOutcome.Defer:
|
||||
outcome = StepExecutionOutcome.Defer;
|
||||
running.Clear();
|
||||
queue.Clear();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome == StepExecutionOutcome.Continue)
|
||||
{
|
||||
if (childFailureDetected)
|
||||
{
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkContainerSucceeded(step, executionContext);
|
||||
}
|
||||
}
|
||||
else if (outcome == StepExecutionOutcome.AbortRun)
|
||||
{
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
}
|
||||
else if (outcome == StepExecutionOutcome.Defer)
|
||||
{
|
||||
MarkContainerPending(step, executionContext, AwaitingRetryReason);
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteMapStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
{
|
||||
foreach (var child in step.Children)
|
||||
{
|
||||
var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false);
|
||||
if (outcome != StepExecutionOutcome.Continue)
|
||||
{
|
||||
if (outcome == StepExecutionOutcome.Defer)
|
||||
{
|
||||
MarkContainerPending(step, executionContext, AwaitingRetryReason);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
if (!step.ContinueOnError)
|
||||
{
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
}
|
||||
}
|
||||
|
||||
MarkContainerSucceeded(step, executionContext);
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext)
|
||||
{
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.Status == PackRunStepExecutionStatus.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Succeeded,
|
||||
StatusReason = null,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow,
|
||||
NextAttemptAt = null
|
||||
};
|
||||
}
|
||||
|
||||
private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
|
||||
{
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Failed,
|
||||
StatusReason = reason,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
|
||||
{
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Pending,
|
||||
StatusReason = reason,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun,
|
||||
_ => StepExecutionOutcome.AbortRun
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteParallelStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
{
|
||||
var children = step.Children;
|
||||
if (children.Count == 0)
|
||||
{
|
||||
MarkContainerSucceeded(step, executionContext);
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count;
|
||||
var queue = new Queue<PackRunExecutionStep>(children);
|
||||
var running = new List<Task<StepExecutionOutcome>>(maxParallel);
|
||||
var outcome = StepExecutionOutcome.Continue;
|
||||
var childFailureDetected = false;
|
||||
|
||||
while (queue.Count > 0 || running.Count > 0)
|
||||
{
|
||||
while (queue.Count > 0 && running.Count < maxParallel)
|
||||
{
|
||||
var child = queue.Dequeue();
|
||||
running.Add(ExecuteStepAsync(child, executionContext));
|
||||
}
|
||||
|
||||
var completed = await Task.WhenAny(running).ConfigureAwait(false);
|
||||
running.Remove(completed);
|
||||
var childOutcome = await completed.ConfigureAwait(false);
|
||||
|
||||
switch (childOutcome)
|
||||
{
|
||||
case StepExecutionOutcome.AbortRun:
|
||||
if (step.ContinueOnError)
|
||||
{
|
||||
childFailureDetected = true;
|
||||
outcome = StepExecutionOutcome.Continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = StepExecutionOutcome.AbortRun;
|
||||
running.Clear();
|
||||
queue.Clear();
|
||||
}
|
||||
break;
|
||||
|
||||
case StepExecutionOutcome.Defer:
|
||||
outcome = StepExecutionOutcome.Defer;
|
||||
running.Clear();
|
||||
queue.Clear();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome == StepExecutionOutcome.Continue)
|
||||
{
|
||||
if (childFailureDetected)
|
||||
{
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkContainerSucceeded(step, executionContext);
|
||||
}
|
||||
}
|
||||
else if (outcome == StepExecutionOutcome.AbortRun)
|
||||
{
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
}
|
||||
else if (outcome == StepExecutionOutcome.Defer)
|
||||
{
|
||||
MarkContainerPending(step, executionContext, AwaitingRetryReason);
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task<StepExecutionOutcome> ExecuteMapStepAsync(
|
||||
PackRunExecutionStep step,
|
||||
ExecutionContext executionContext)
|
||||
{
|
||||
foreach (var child in step.Children)
|
||||
{
|
||||
var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false);
|
||||
if (outcome != StepExecutionOutcome.Continue)
|
||||
{
|
||||
if (outcome == StepExecutionOutcome.Defer)
|
||||
{
|
||||
MarkContainerPending(step, executionContext, AwaitingRetryReason);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
if (!step.ContinueOnError)
|
||||
{
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
MarkContainerFailure(step, executionContext, ChildFailureReason);
|
||||
}
|
||||
}
|
||||
|
||||
MarkContainerSucceeded(step, executionContext);
|
||||
return StepExecutionOutcome.Continue;
|
||||
}
|
||||
|
||||
private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext)
|
||||
{
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.Status == PackRunStepExecutionStatus.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Succeeded,
|
||||
StatusReason = null,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow,
|
||||
NextAttemptAt = null
|
||||
};
|
||||
}
|
||||
|
||||
private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
|
||||
{
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Failed,
|
||||
StatusReason = reason,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
|
||||
{
|
||||
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
executionContext.Steps[step.Id] = record with
|
||||
{
|
||||
Status = PackRunStepExecutionStatus.Pending,
|
||||
StatusReason = reason,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record ExecutionContext(
|
||||
string RunId,
|
||||
TaskPackPlanFailurePolicy FailurePolicy,
|
||||
ConcurrentDictionary<string, PackRunStepStateRecord> Steps,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private enum StepExecutionOutcome
|
||||
{
|
||||
Continue,
|
||||
Defer,
|
||||
AbortRun
|
||||
}
|
||||
}
|
||||
|
||||
private enum StepExecutionOutcome
|
||||
{
|
||||
Continue,
|
||||
Defer,
|
||||
AbortRun
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user