Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunApprovalStore
|
||||
{
|
||||
Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunJobDispatcher
|
||||
{
|
||||
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public interface IPackRunNotificationPublisher
|
||||
{
|
||||
Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken);
|
||||
|
||||
Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed class PackRunApprovalState
|
||||
{
|
||||
public PackRunApprovalState(
|
||||
string approvalId,
|
||||
IReadOnlyList<string> requiredGrants,
|
||||
IReadOnlyList<string> stepIds,
|
||||
IReadOnlyList<string> messages,
|
||||
string? reasonTemplate,
|
||||
DateTimeOffset requestedAt,
|
||||
PackRunApprovalStatus status,
|
||||
string? actorId = null,
|
||||
DateTimeOffset? completedAt = null,
|
||||
string? summary = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(approvalId))
|
||||
{
|
||||
throw new ArgumentException("Approval id must not be empty.", nameof(approvalId));
|
||||
}
|
||||
|
||||
ApprovalId = approvalId;
|
||||
RequiredGrants = requiredGrants.ToImmutableArray();
|
||||
StepIds = stepIds.ToImmutableArray();
|
||||
Messages = messages.ToImmutableArray();
|
||||
ReasonTemplate = reasonTemplate;
|
||||
RequestedAt = requestedAt;
|
||||
Status = status;
|
||||
ActorId = actorId;
|
||||
CompletedAt = completedAt;
|
||||
Summary = summary;
|
||||
}
|
||||
|
||||
public string ApprovalId { get; }
|
||||
|
||||
public IReadOnlyList<string> RequiredGrants { get; }
|
||||
|
||||
public IReadOnlyList<string> StepIds { get; }
|
||||
|
||||
public IReadOnlyList<string> Messages { get; }
|
||||
|
||||
public string? ReasonTemplate { get; }
|
||||
|
||||
public DateTimeOffset RequestedAt { get; }
|
||||
|
||||
public PackRunApprovalStatus Status { get; }
|
||||
|
||||
public string? ActorId { get; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; }
|
||||
|
||||
public string? Summary { get; }
|
||||
|
||||
public PackRunApprovalState Approve(string actorId, DateTimeOffset completedAt, string? summary = null)
|
||||
=> Transition(PackRunApprovalStatus.Approved, actorId, completedAt, summary);
|
||||
|
||||
public PackRunApprovalState Reject(string actorId, DateTimeOffset completedAt, string? summary = null)
|
||||
=> Transition(PackRunApprovalStatus.Rejected, actorId, completedAt, summary);
|
||||
|
||||
public PackRunApprovalState Expire(DateTimeOffset expiredAt, string? summary = null)
|
||||
=> Transition(PackRunApprovalStatus.Expired, actorId: null, expiredAt, summary);
|
||||
|
||||
private PackRunApprovalState Transition(PackRunApprovalStatus status, string? actorId, DateTimeOffset completedAt, string? summary)
|
||||
{
|
||||
if (Status != PackRunApprovalStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException($"Approval '{ApprovalId}' is already {Status}.");
|
||||
}
|
||||
|
||||
return new PackRunApprovalState(
|
||||
ApprovalId,
|
||||
RequiredGrants,
|
||||
StepIds,
|
||||
Messages,
|
||||
ReasonTemplate,
|
||||
RequestedAt,
|
||||
status,
|
||||
actorId,
|
||||
completedAt,
|
||||
summary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public enum PackRunApprovalStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2,
|
||||
Expired = 3
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed class PackRunExecutionContext
|
||||
{
|
||||
public PackRunExecutionContext(string runId, TaskPackPlan plan, DateTimeOffset requestedAt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
RunId = runId;
|
||||
Plan = plan;
|
||||
RequestedAt = requestedAt;
|
||||
}
|
||||
|
||||
public string RunId { get; }
|
||||
|
||||
public TaskPackPlan Plan { get; }
|
||||
|
||||
public DateTimeOffset RequestedAt { get; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed class PackRunProcessor
|
||||
{
|
||||
private readonly IPackRunApprovalStore approvalStore;
|
||||
private readonly IPackRunNotificationPublisher notificationPublisher;
|
||||
private readonly ILogger<PackRunProcessor> logger;
|
||||
|
||||
public PackRunProcessor(
|
||||
IPackRunApprovalStore approvalStore,
|
||||
IPackRunNotificationPublisher notificationPublisher,
|
||||
ILogger<PackRunProcessor> logger)
|
||||
{
|
||||
this.approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore));
|
||||
this.notificationPublisher = notificationPublisher ?? throw new ArgumentNullException(nameof(notificationPublisher));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<PackRunProcessorResult> ProcessNewRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var existing = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
|
||||
PackRunApprovalCoordinator coordinator;
|
||||
bool shouldResume;
|
||||
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
coordinator = PackRunApprovalCoordinator.Restore(context.Plan, existing, context.RequestedAt);
|
||||
shouldResume = !coordinator.HasPendingApprovals;
|
||||
logger.LogInformation("Run {RunId} approvals restored (pending: {Pending}).", context.RunId, coordinator.HasPendingApprovals);
|
||||
}
|
||||
else
|
||||
{
|
||||
coordinator = PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
|
||||
await approvalStore.SaveAsync(context.RunId, coordinator.GetApprovals(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var approvalNotifications = coordinator.BuildNotifications(context.Plan);
|
||||
foreach (var notification in approvalNotifications)
|
||||
{
|
||||
await notificationPublisher.PublishApprovalRequestedAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation(
|
||||
"Approval requested for run {RunId} gate {ApprovalId} requiring grants {Grants}.",
|
||||
context.RunId,
|
||||
notification.ApprovalId,
|
||||
string.Join(",", notification.RequiredGrants));
|
||||
}
|
||||
|
||||
var policyNotifications = coordinator.BuildPolicyNotifications(context.Plan);
|
||||
foreach (var notification in policyNotifications)
|
||||
{
|
||||
await notificationPublisher.PublishPolicyGatePendingAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug(
|
||||
"Policy gate pending for run {RunId} step {StepId}.",
|
||||
context.RunId,
|
||||
notification.StepId);
|
||||
}
|
||||
|
||||
shouldResume = !coordinator.HasPendingApprovals;
|
||||
}
|
||||
|
||||
if (shouldResume)
|
||||
{
|
||||
logger.LogInformation("Run {RunId} has no approvals; proceeding immediately.", context.RunId);
|
||||
}
|
||||
|
||||
return new PackRunProcessorResult(coordinator, shouldResume);
|
||||
}
|
||||
|
||||
public async Task<PackRunApprovalCoordinator> RestoreAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var states = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
|
||||
if (states.Count == 0)
|
||||
{
|
||||
return PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
|
||||
}
|
||||
|
||||
return PackRunApprovalCoordinator.Restore(context.Plan, states, context.RequestedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
public sealed record PackRunProcessorResult(
|
||||
PackRunApprovalCoordinator ApprovalCoordinator,
|
||||
bool ShouldResumeImmediately);
|
||||
@@ -0,0 +1,596 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Expressions;
|
||||
|
||||
internal static class TaskPackExpressions
|
||||
{
|
||||
private static readonly Regex ExpressionPattern = new("^\\s*\\{\\{(.+)\\}\\}\\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex ComparisonPattern = new("^(?<left>.+?)\\s*(?<op>==|!=)\\s*(?<right>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex InPattern = new("^(?<left>.+?)\\s+in\\s+(?<right>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public static bool TryEvaluateBoolean(string? candidate, TaskPackExpressionContext context, out bool value, out string? error)
|
||||
{
|
||||
value = false;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryExtractExpression(candidate, out var expression))
|
||||
{
|
||||
return TryParseBooleanLiteral(candidate.Trim(), out value, out error);
|
||||
}
|
||||
|
||||
expression = expression.Trim();
|
||||
return TryEvaluateBooleanInternal(expression, context, out value, out error);
|
||||
}
|
||||
|
||||
public static TaskPackValueResolution EvaluateValue(JsonNode? node, TaskPackExpressionContext context)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return TaskPackValueResolution.FromValue(null);
|
||||
}
|
||||
|
||||
if (node is JsonValue valueNode && valueNode.TryGetValue(out string? stringValue))
|
||||
{
|
||||
if (!TryExtractExpression(stringValue, out var expression))
|
||||
{
|
||||
return TaskPackValueResolution.FromValue(valueNode);
|
||||
}
|
||||
|
||||
var trimmed = expression.Trim();
|
||||
return EvaluateExpression(trimmed, context);
|
||||
}
|
||||
|
||||
return TaskPackValueResolution.FromValue(node);
|
||||
}
|
||||
|
||||
public static TaskPackValueResolution EvaluateString(string value, TaskPackExpressionContext context)
|
||||
{
|
||||
if (!TryExtractExpression(value, out var expression))
|
||||
{
|
||||
return TaskPackValueResolution.FromValue(JsonValue.Create(value));
|
||||
}
|
||||
|
||||
return EvaluateExpression(expression.Trim(), context);
|
||||
}
|
||||
|
||||
private static bool TryEvaluateBooleanInternal(string expression, TaskPackExpressionContext context, out bool result, out string? error)
|
||||
{
|
||||
result = false;
|
||||
error = null;
|
||||
|
||||
if (TrySplitTopLevel(expression, "||", out var left, out var right) ||
|
||||
TrySplitTopLevel(expression, " or ", out left, out right))
|
||||
{
|
||||
if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leftValue)
|
||||
{
|
||||
result = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = rightValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TrySplitTopLevel(expression, "&&", out left, out right) ||
|
||||
TrySplitTopLevel(expression, " and ", out left, out right))
|
||||
{
|
||||
if (!TryEvaluateBooleanInternal(left, context, out var leftValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!leftValue)
|
||||
{
|
||||
result = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryEvaluateBooleanInternal(right, context, out var rightValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = rightValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (expression.StartsWith("not ", StringComparison.Ordinal))
|
||||
{
|
||||
var inner = expression["not ".Length..].Trim();
|
||||
if (!TryEvaluateBooleanInternal(inner, context, out var innerValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = !innerValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryEvaluateComparison(expression, context, out result, out error))
|
||||
{
|
||||
return error is null;
|
||||
}
|
||||
|
||||
var resolution = EvaluateExpression(expression, context);
|
||||
if (!resolution.Resolved)
|
||||
{
|
||||
error = resolution.Error ?? $"Expression '{expression}' requires runtime evaluation.";
|
||||
return false;
|
||||
}
|
||||
|
||||
result = ToBoolean(resolution.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryEvaluateComparison(string expression, TaskPackExpressionContext context, out bool value, out string? error)
|
||||
{
|
||||
value = false;
|
||||
error = null;
|
||||
|
||||
var comparisonMatch = ComparisonPattern.Match(expression);
|
||||
if (comparisonMatch.Success)
|
||||
{
|
||||
var left = comparisonMatch.Groups["left"].Value.Trim();
|
||||
var op = comparisonMatch.Groups["op"].Value;
|
||||
var right = comparisonMatch.Groups["right"].Value.Trim();
|
||||
|
||||
var leftResolution = EvaluateOperand(left, context);
|
||||
if (!leftResolution.IsValid(out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rightResolution = EvaluateOperand(right, context);
|
||||
if (!rightResolution.IsValid(out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!leftResolution.TryGetValue(out var leftValue, out error) ||
|
||||
!rightResolution.TryGetValue(out var rightValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = CompareNodes(leftValue, rightValue, op == "==");
|
||||
return true;
|
||||
}
|
||||
|
||||
var inMatch = InPattern.Match(expression);
|
||||
if (inMatch.Success)
|
||||
{
|
||||
var member = inMatch.Groups["left"].Value.Trim();
|
||||
var collection = inMatch.Groups["right"].Value.Trim();
|
||||
|
||||
var memberResolution = EvaluateOperand(member, context);
|
||||
if (!memberResolution.IsValid(out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var collectionResolution = EvaluateOperand(collection, context);
|
||||
if (!collectionResolution.IsValid(out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!memberResolution.TryGetValue(out var memberValue, out error) ||
|
||||
!collectionResolution.TryGetValue(out var collectionValue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = EvaluateMembership(memberValue, collectionValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static OperandResolution EvaluateOperand(string expression, TaskPackExpressionContext context)
|
||||
{
|
||||
if (TryParseStringLiteral(expression, out var literal))
|
||||
{
|
||||
return OperandResolution.FromValue(JsonValue.Create(literal));
|
||||
}
|
||||
|
||||
if (bool.TryParse(expression, out var boolLiteral))
|
||||
{
|
||||
return OperandResolution.FromValue(JsonValue.Create(boolLiteral));
|
||||
}
|
||||
|
||||
if (double.TryParse(expression, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.AllowThousands, System.Globalization.CultureInfo.InvariantCulture, out var numberLiteral))
|
||||
{
|
||||
return OperandResolution.FromValue(JsonValue.Create(numberLiteral));
|
||||
}
|
||||
|
||||
var resolution = EvaluateExpression(expression, context);
|
||||
if (!resolution.Resolved)
|
||||
{
|
||||
if (resolution.RequiresRuntimeValue && resolution.Error is null)
|
||||
{
|
||||
return OperandResolution.FromRuntime(expression);
|
||||
}
|
||||
|
||||
return OperandResolution.FromError(resolution.Error ?? $"Expression '{expression}' could not be resolved.");
|
||||
}
|
||||
|
||||
return OperandResolution.FromValue(resolution.Value);
|
||||
}
|
||||
|
||||
private static TaskPackValueResolution EvaluateExpression(string expression, TaskPackExpressionContext context)
|
||||
{
|
||||
if (!TryResolvePath(expression, context, out var resolved, out var requiresRuntime, out var error))
|
||||
{
|
||||
return TaskPackValueResolution.FromError(expression, error ?? $"Failed to resolve expression '{expression}'.");
|
||||
}
|
||||
|
||||
if (requiresRuntime)
|
||||
{
|
||||
return TaskPackValueResolution.FromDeferred(expression);
|
||||
}
|
||||
|
||||
return TaskPackValueResolution.FromValue(resolved);
|
||||
}
|
||||
|
||||
private static bool TryResolvePath(string expression, TaskPackExpressionContext context, out JsonNode? value, out bool requiresRuntime, out string? error)
|
||||
{
|
||||
value = null;
|
||||
error = null;
|
||||
requiresRuntime = false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
error = "Expression cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = expression.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
error = $"Expression '{expression}' is invalid.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = segments[0];
|
||||
|
||||
switch (root)
|
||||
{
|
||||
case "inputs":
|
||||
if (segments.Length == 1)
|
||||
{
|
||||
error = "Expression must reference a specific input (e.g., inputs.example).";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!context.Inputs.TryGetValue(segments[1], out var current))
|
||||
{
|
||||
error = $"Input '{segments[1]}' was not supplied.";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = Traverse(current, segments, startIndex: 2);
|
||||
return true;
|
||||
|
||||
case "item":
|
||||
if (context.CurrentItem is null)
|
||||
{
|
||||
error = "Expression references 'item' outside of a map iteration.";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = Traverse(context.CurrentItem, segments, startIndex: 1);
|
||||
return true;
|
||||
|
||||
case "steps":
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
error = "Step expressions must specify a step identifier (e.g., steps.plan.outputs.value).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var stepId = segments[1];
|
||||
if (!context.StepExists(stepId))
|
||||
{
|
||||
error = $"Step '{stepId}' referenced before it is defined.";
|
||||
return false;
|
||||
}
|
||||
|
||||
requiresRuntime = true;
|
||||
value = null;
|
||||
return true;
|
||||
|
||||
case "secrets":
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
error = "Secret expressions must specify a secret name (e.g., secrets.jiraToken).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var secretName = segments[1];
|
||||
if (!context.SecretExists(secretName))
|
||||
{
|
||||
error = $"Secret '{secretName}' is not declared in the manifest.";
|
||||
return false;
|
||||
}
|
||||
|
||||
requiresRuntime = true;
|
||||
value = null;
|
||||
return true;
|
||||
|
||||
default:
|
||||
error = $"Expression '{expression}' references '{root}', supported roots are inputs, item, steps, and secrets.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? Traverse(JsonNode? current, IReadOnlyList<string> segments, int startIndex)
|
||||
{
|
||||
for (var i = startIndex; i < segments.Count && current is not null; i++)
|
||||
{
|
||||
var segment = segments[i];
|
||||
if (current is JsonObject obj)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(segment, out current))
|
||||
{
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
else if (current is JsonArray array)
|
||||
{
|
||||
current = TryGetArrayElement(array, segment);
|
||||
}
|
||||
else
|
||||
{
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static JsonNode? TryGetArrayElement(JsonArray array, string segment)
|
||||
{
|
||||
if (int.TryParse(segment, out var index) && index >= 0 && index < array.Count)
|
||||
{
|
||||
return array[index];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryExtractExpression(string candidate, out string expression)
|
||||
{
|
||||
var match = ExpressionPattern.Match(candidate);
|
||||
if (!match.Success)
|
||||
{
|
||||
expression = candidate;
|
||||
return false;
|
||||
}
|
||||
|
||||
expression = match.Groups[1].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseBooleanLiteral(string value, out bool result, out string? error)
|
||||
{
|
||||
if (bool.TryParse(value, out result))
|
||||
{
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"Unable to parse boolean literal '{value}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TrySplitTopLevel(string expression, string token, out string left, out string right)
|
||||
{
|
||||
var inSingle = false;
|
||||
var inDouble = false;
|
||||
for (var i = 0; i <= expression.Length - token.Length; i++)
|
||||
{
|
||||
var c = expression[i];
|
||||
if (c == '\'' && !inDouble)
|
||||
{
|
||||
inSingle = !inSingle;
|
||||
}
|
||||
else if (c == '"' && !inSingle)
|
||||
{
|
||||
inDouble = !inDouble;
|
||||
}
|
||||
|
||||
if (inSingle || inDouble)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.AsSpan(i, token.Length).SequenceEqual(token))
|
||||
{
|
||||
left = expression[..i].Trim();
|
||||
right = expression[(i + token.Length)..].Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
left = string.Empty;
|
||||
right = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseStringLiteral(string candidate, out string? literal)
|
||||
{
|
||||
literal = null;
|
||||
if (candidate.Length >= 2)
|
||||
{
|
||||
if ((candidate[0] == '"' && candidate[^1] == '"') ||
|
||||
(candidate[0] == '\'' && candidate[^1] == '\''))
|
||||
{
|
||||
literal = candidate[1..^1];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CompareNodes(JsonNode? left, JsonNode? right, bool equality)
|
||||
{
|
||||
if (left is null && right is null)
|
||||
{
|
||||
return equality;
|
||||
}
|
||||
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return !equality;
|
||||
}
|
||||
|
||||
var comparison = JsonNode.DeepEquals(left, right);
|
||||
return equality ? comparison : !comparison;
|
||||
}
|
||||
|
||||
private static bool EvaluateMembership(JsonNode? member, JsonNode? collection)
|
||||
{
|
||||
if (collection is JsonArray array)
|
||||
{
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (JsonNode.DeepEquals(member, element))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (collection is JsonValue value && value.TryGetValue(out string? text) && member is JsonValue memberValue && memberValue.TryGetValue(out string? memberText))
|
||||
{
|
||||
return text?.Contains(memberText, StringComparison.Ordinal) ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ToBoolean(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue<bool>(out var boolValue))
|
||||
{
|
||||
return boolValue;
|
||||
}
|
||||
|
||||
if (value.TryGetValue<string>(out var stringValue))
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(stringValue);
|
||||
}
|
||||
|
||||
if (value.TryGetValue<double>(out var number))
|
||||
{
|
||||
return Math.Abs(number) > double.Epsilon;
|
||||
}
|
||||
}
|
||||
|
||||
if (node is JsonArray array)
|
||||
{
|
||||
return array.Count > 0;
|
||||
}
|
||||
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
return obj.Count > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly record struct OperandResolution(JsonNode? Value, string? Error, bool RequiresRuntime)
|
||||
{
|
||||
public bool IsValid(out string? error)
|
||||
{
|
||||
error = Error;
|
||||
return string.IsNullOrEmpty(Error);
|
||||
}
|
||||
|
||||
public bool TryGetValue(out JsonNode? value, out string? error)
|
||||
{
|
||||
if (RequiresRuntime)
|
||||
{
|
||||
error = "Expression requires runtime evaluation.";
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = Value;
|
||||
error = Error;
|
||||
return error is null;
|
||||
}
|
||||
|
||||
public static OperandResolution FromValue(JsonNode? value)
|
||||
=> new(value, null, false);
|
||||
|
||||
public static OperandResolution FromRuntime(string expression)
|
||||
=> new(null, $"Expression '{expression}' requires runtime evaluation.", true);
|
||||
|
||||
public static OperandResolution FromError(string error)
|
||||
=> new(null, error, false);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct TaskPackExpressionContext(
|
||||
IReadOnlyDictionary<string, JsonNode?> Inputs,
|
||||
ISet<string> KnownSteps,
|
||||
ISet<string> KnownSecrets,
|
||||
JsonNode? CurrentItem)
|
||||
{
|
||||
public static TaskPackExpressionContext Create(
|
||||
IReadOnlyDictionary<string, JsonNode?> inputs,
|
||||
ISet<string> knownSteps,
|
||||
ISet<string> knownSecrets)
|
||||
=> new(inputs, knownSteps, knownSecrets, null);
|
||||
|
||||
public bool StepExists(string stepId) => KnownSteps.Contains(stepId);
|
||||
|
||||
public void RegisterStep(string stepId) => KnownSteps.Add(stepId);
|
||||
|
||||
public bool SecretExists(string secretName) => KnownSecrets.Contains(secretName);
|
||||
|
||||
public TaskPackExpressionContext WithItem(JsonNode? item) => new(Inputs, KnownSteps, KnownSecrets, item);
|
||||
}
|
||||
|
||||
internal readonly record struct TaskPackValueResolution(bool Resolved, JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue)
|
||||
{
|
||||
public static TaskPackValueResolution FromValue(JsonNode? value)
|
||||
=> new(true, value, null, null, false);
|
||||
|
||||
public static TaskPackValueResolution FromDeferred(string expression)
|
||||
=> new(false, null, expression, null, true);
|
||||
|
||||
public static TaskPackValueResolution FromError(string expression, string error)
|
||||
=> new(false, null, expression, error, false);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
{
|
||||
Metadata = metadata;
|
||||
Inputs = inputs;
|
||||
Steps = steps;
|
||||
Hash = hash;
|
||||
Approvals = approvals;
|
||||
Secrets = secrets;
|
||||
Outputs = outputs;
|
||||
}
|
||||
|
||||
public TaskPackPlanMetadata Metadata { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, JsonNode?> Inputs { get; }
|
||||
|
||||
public IReadOnlyList<TaskPackPlanStep> Steps { get; }
|
||||
|
||||
public string Hash { get; }
|
||||
|
||||
public IReadOnlyList<TaskPackPlanApproval> Approvals { get; }
|
||||
|
||||
public IReadOnlyList<TaskPackPlanSecret> Secrets { get; }
|
||||
|
||||
public IReadOnlyList<TaskPackPlanOutput> Outputs { get; }
|
||||
}
|
||||
|
||||
public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed record TaskPackPlanStep(
|
||||
string Id,
|
||||
string TemplateId,
|
||||
string? Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
string? Uses,
|
||||
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? Parameters,
|
||||
string? ApprovalId,
|
||||
string? GateMessage,
|
||||
IReadOnlyList<TaskPackPlanStep>? Children);
|
||||
|
||||
public sealed record TaskPackPlanParameterValue(
|
||||
JsonNode? Value,
|
||||
string? Expression,
|
||||
string? Error,
|
||||
bool RequiresRuntimeValue)
|
||||
{
|
||||
internal static TaskPackPlanParameterValue FromResolution(TaskPackValueResolution resolution)
|
||||
=> new(resolution.Value, resolution.Expression, resolution.Error, resolution.RequiresRuntimeValue);
|
||||
}
|
||||
|
||||
public sealed record TaskPackPlanApproval(
|
||||
string Id,
|
||||
IReadOnlyList<string> Grants,
|
||||
string? ExpiresAfter,
|
||||
string? ReasonTemplate);
|
||||
|
||||
public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description);
|
||||
|
||||
public sealed record TaskPackPlanOutput(
|
||||
string Name,
|
||||
string Type,
|
||||
TaskPackPlanParameterValue? Path,
|
||||
TaskPackPlanParameterValue? Expression);
|
||||
|
||||
public sealed 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);
|
||||
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
{
|
||||
var canonical = new CanonicalPlan(
|
||||
new CanonicalMetadata(metadata.Name, metadata.Version, metadata.Description, metadata.Tags),
|
||||
inputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal),
|
||||
steps.Select(ToCanonicalStep).ToList(),
|
||||
approvals
|
||||
.OrderBy(a => a.Id, StringComparer.Ordinal)
|
||||
.Select(a => new CanonicalApproval(a.Id, a.Grants.OrderBy(g => g, StringComparer.Ordinal).ToList(), a.ExpiresAfter, a.ReasonTemplate))
|
||||
.ToList(),
|
||||
secrets
|
||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Select(s => new CanonicalSecret(s.Name, s.Scope, s.Description))
|
||||
.ToList(),
|
||||
outputs
|
||||
.OrderBy(o => o.Name, StringComparer.Ordinal)
|
||||
.Select(ToCanonicalOutput)
|
||||
.ToList());
|
||||
|
||||
var json = CanonicalJson.Serialize(canonical);
|
||||
using var sha256 = SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
|
||||
return ConvertToHex(hashBytes);
|
||||
}
|
||||
|
||||
private static string ConvertToHex(byte[] hashBytes)
|
||||
{
|
||||
var builder = new StringBuilder(hashBytes.Length * 2);
|
||||
foreach (var b in hashBytes)
|
||||
{
|
||||
builder.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static CanonicalPlanStep ToCanonicalStep(TaskPackPlanStep step)
|
||||
=> new(
|
||||
step.Id,
|
||||
step.TemplateId,
|
||||
step.Name,
|
||||
step.Type,
|
||||
step.Enabled,
|
||||
step.Uses,
|
||||
step.Parameters?.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => new CanonicalParameter(kvp.Value.Value, kvp.Value.Expression, kvp.Value.Error, kvp.Value.RequiresRuntimeValue),
|
||||
StringComparer.Ordinal),
|
||||
step.ApprovalId,
|
||||
step.GateMessage,
|
||||
step.Children?.Select(ToCanonicalStep).ToList());
|
||||
|
||||
private sealed record CanonicalPlan(
|
||||
CanonicalMetadata Metadata,
|
||||
IDictionary<string, JsonNode?> Inputs,
|
||||
IReadOnlyList<CanonicalPlanStep> Steps,
|
||||
IReadOnlyList<CanonicalApproval> Approvals,
|
||||
IReadOnlyList<CanonicalSecret> Secrets,
|
||||
IReadOnlyList<CanonicalOutput> Outputs);
|
||||
|
||||
private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
|
||||
|
||||
private sealed record CanonicalPlanStep(
|
||||
string Id,
|
||||
string TemplateId,
|
||||
string? Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
string? Uses,
|
||||
IDictionary<string, CanonicalParameter>? Parameters,
|
||||
string? ApprovalId,
|
||||
string? GateMessage,
|
||||
IReadOnlyList<CanonicalPlanStep>? Children);
|
||||
|
||||
private sealed record CanonicalApproval(string Id, IReadOnlyList<string> Grants, string? ExpiresAfter, string? ReasonTemplate);
|
||||
|
||||
private sealed record CanonicalSecret(string Name, string Scope, string? Description);
|
||||
|
||||
private sealed record CanonicalParameter(JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue);
|
||||
|
||||
private sealed record CanonicalOutput(
|
||||
string Name,
|
||||
string Type,
|
||||
CanonicalParameter? Path,
|
||||
CanonicalParameter? Expression);
|
||||
|
||||
private 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);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
public static class TaskPackPlanInsights
|
||||
{
|
||||
public static IReadOnlyList<TaskPackPlanApprovalRequirement> CollectApprovalRequirements(TaskPackPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var approvals = plan.Approvals.ToDictionary(approval => approval.Id, StringComparer.Ordinal);
|
||||
var builders = new Dictionary<string, ApprovalRequirementBuilder>(StringComparer.Ordinal);
|
||||
|
||||
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
|
||||
{
|
||||
if (steps is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal) && !string.IsNullOrEmpty(step.ApprovalId))
|
||||
{
|
||||
if (!builders.TryGetValue(step.ApprovalId, out var builder))
|
||||
{
|
||||
builder = new ApprovalRequirementBuilder(step.ApprovalId);
|
||||
builders[step.ApprovalId] = builder;
|
||||
}
|
||||
|
||||
builder.AddStep(step);
|
||||
}
|
||||
|
||||
Visit(step.Children);
|
||||
}
|
||||
}
|
||||
|
||||
Visit(plan.Steps);
|
||||
|
||||
return builders.Values
|
||||
.Select(builder => builder.Build(approvals))
|
||||
.OrderBy(requirement => requirement.ApprovalId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<TaskPackPlanNotificationHint> CollectNotificationHints(TaskPackPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var notifications = new List<TaskPackPlanNotificationHint>();
|
||||
|
||||
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
|
||||
{
|
||||
if (steps is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal))
|
||||
{
|
||||
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "approval-request", step.GateMessage, step.ApprovalId));
|
||||
}
|
||||
else if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
|
||||
{
|
||||
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "policy-gate", step.GateMessage, null));
|
||||
}
|
||||
|
||||
Visit(step.Children);
|
||||
}
|
||||
}
|
||||
|
||||
Visit(plan.Steps);
|
||||
return notifications;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<TaskPackPlanPolicyGateHint> CollectPolicyGateHints(TaskPackPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var hints = new List<TaskPackPlanPolicyGateHint>();
|
||||
|
||||
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
|
||||
{
|
||||
if (steps is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
|
||||
{
|
||||
var parameters = step.Parameters?
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.Select(kvp => new TaskPackPlanPolicyParameter(
|
||||
kvp.Key,
|
||||
kvp.Value.RequiresRuntimeValue,
|
||||
kvp.Value.Expression,
|
||||
kvp.Value.Error))
|
||||
.ToList() ?? new List<TaskPackPlanPolicyParameter>();
|
||||
|
||||
hints.Add(new TaskPackPlanPolicyGateHint(step.Id, step.GateMessage, parameters));
|
||||
}
|
||||
|
||||
Visit(step.Children);
|
||||
}
|
||||
}
|
||||
|
||||
Visit(plan.Steps);
|
||||
return hints;
|
||||
}
|
||||
|
||||
private sealed class ApprovalRequirementBuilder
|
||||
{
|
||||
private readonly HashSet<string> stepIds = new(StringComparer.Ordinal);
|
||||
private readonly List<string> messages = new();
|
||||
|
||||
public ApprovalRequirementBuilder(string approvalId)
|
||||
{
|
||||
ApprovalId = approvalId;
|
||||
}
|
||||
|
||||
public string ApprovalId { get; }
|
||||
|
||||
public void AddStep(TaskPackPlanStep step)
|
||||
{
|
||||
stepIds.Add(step.Id);
|
||||
if (!string.IsNullOrWhiteSpace(step.GateMessage))
|
||||
{
|
||||
messages.Add(step.GateMessage!);
|
||||
}
|
||||
}
|
||||
|
||||
public TaskPackPlanApprovalRequirement Build(IReadOnlyDictionary<string, TaskPackPlanApproval> knownApprovals)
|
||||
{
|
||||
knownApprovals.TryGetValue(ApprovalId, out var approval);
|
||||
|
||||
var orderedSteps = stepIds
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var orderedMessages = messages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new TaskPackPlanApprovalRequirement(
|
||||
ApprovalId,
|
||||
approval?.Grants ?? Array.Empty<string>(),
|
||||
approval?.ExpiresAfter,
|
||||
approval?.ReasonTemplate,
|
||||
orderedSteps,
|
||||
orderedMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TaskPackPlanApprovalRequirement(
|
||||
string ApprovalId,
|
||||
IReadOnlyList<string> Grants,
|
||||
string? ExpiresAfter,
|
||||
string? ReasonTemplate,
|
||||
IReadOnlyList<string> StepIds,
|
||||
IReadOnlyList<string> Messages);
|
||||
|
||||
public sealed record TaskPackPlanNotificationHint(
|
||||
string StepId,
|
||||
string Type,
|
||||
string? Message,
|
||||
string? ApprovalId);
|
||||
|
||||
public sealed record TaskPackPlanPolicyGateHint(
|
||||
string StepId,
|
||||
string? Message,
|
||||
IReadOnlyList<TaskPackPlanPolicyParameter> Parameters);
|
||||
|
||||
public sealed record TaskPackPlanPolicyParameter(
|
||||
string Name,
|
||||
bool RequiresRuntimeValue,
|
||||
string? Expression,
|
||||
string? Error);
|
||||
@@ -0,0 +1,431 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.Expressions;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Planning;
|
||||
|
||||
public sealed class TaskPackPlanner
|
||||
{
|
||||
private readonly TaskPackManifestValidator validator;
|
||||
|
||||
public TaskPackPlanner()
|
||||
{
|
||||
validator = new TaskPackManifestValidator();
|
||||
}
|
||||
|
||||
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
|
||||
|
||||
var validation = validator.Validate(manifest);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
foreach (var error in validation.Errors)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError(error.Path, error.Message));
|
||||
}
|
||||
|
||||
return new TaskPackPlanResult(null, errors.ToImmutable());
|
||||
}
|
||||
|
||||
var effectiveInputs = MaterializeInputs(manifest.Spec.Inputs, providedInputs, errors);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new TaskPackPlanResult(null, errors.ToImmutable());
|
||||
}
|
||||
|
||||
var stepTracker = new HashSet<string>(StringComparer.Ordinal);
|
||||
var secretTracker = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (manifest.Spec.Secrets is not null)
|
||||
{
|
||||
foreach (var secret in manifest.Spec.Secrets)
|
||||
{
|
||||
secretTracker.Add(secret.Name);
|
||||
}
|
||||
}
|
||||
|
||||
var context = TaskPackExpressionContext.Create(effectiveInputs, stepTracker, secretTracker);
|
||||
|
||||
var planSteps = new List<TaskPackPlanStep>();
|
||||
var steps = manifest.Spec.Steps;
|
||||
for (var i = 0; i < steps.Count; i++)
|
||||
{
|
||||
var step = steps[i];
|
||||
var planStep = BuildStep(step, context, $"spec.steps[{i}]", errors);
|
||||
planSteps.Add(planStep);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new TaskPackPlanResult(null, errors.ToImmutable());
|
||||
}
|
||||
|
||||
var metadata = new TaskPackPlanMetadata(
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version,
|
||||
manifest.Metadata.Description,
|
||||
manifest.Metadata.Tags?.ToList() ?? new List<string>());
|
||||
|
||||
var planApprovals = manifest.Spec.Approvals?
|
||||
.Select(approval => new TaskPackPlanApproval(
|
||||
approval.Id,
|
||||
approval.Grants?.ToList() ?? new List<string>(),
|
||||
approval.ExpiresAfter,
|
||||
approval.ReasonTemplate))
|
||||
.ToList() ?? new List<TaskPackPlanApproval>();
|
||||
|
||||
var planSecrets = manifest.Spec.Secrets?
|
||||
.Select(secret => new TaskPackPlanSecret(secret.Name, secret.Scope, secret.Description))
|
||||
.ToList() ?? new List<TaskPackPlanSecret>();
|
||||
|
||||
var planOutputs = MaterializeOutputs(manifest.Spec.Outputs, context, errors);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new TaskPackPlanResult(null, errors.ToImmutable());
|
||||
}
|
||||
|
||||
var hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs);
|
||||
|
||||
var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs);
|
||||
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
|
||||
}
|
||||
|
||||
private Dictionary<string, JsonNode?> MaterializeInputs(
|
||||
IReadOnlyList<TaskPackInput>? definitions,
|
||||
IDictionary<string, JsonNode?>? providedInputs,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var effective = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
|
||||
|
||||
if (definitions is not null)
|
||||
{
|
||||
foreach (var input in definitions)
|
||||
{
|
||||
if ((providedInputs is not null && providedInputs.TryGetValue(input.Name, out var supplied)))
|
||||
{
|
||||
effective[input.Name] = supplied?.DeepClone();
|
||||
}
|
||||
else if (input.Default is not null)
|
||||
{
|
||||
effective[input.Name] = input.Default.DeepClone();
|
||||
}
|
||||
else if (input.Required)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"inputs.{input.Name}", "Input is required but was not supplied."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providedInputs is not null)
|
||||
{
|
||||
foreach (var kvp in providedInputs)
|
||||
{
|
||||
if (!effective.ContainsKey(kvp.Key))
|
||||
{
|
||||
effective[kvp.Key] = kvp.Value?.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildStep(
|
||||
TaskPackStep step,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
if (!TaskPackExpressions.TryEvaluateBoolean(step.When, context, out var enabled, out var whenError))
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"{path}.when", whenError ?? "Failed to evaluate 'when' expression."));
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
TaskPackPlanStep planStep;
|
||||
|
||||
if (step.Run is not null)
|
||||
{
|
||||
planStep = BuildRunStep(step, step.Run, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Gate is not null)
|
||||
{
|
||||
planStep = BuildGateStep(step, step.Gate, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Parallel is not null)
|
||||
{
|
||||
planStep = BuildParallelStep(step, step.Parallel, context, path, enabled, errors);
|
||||
}
|
||||
else if (step.Map is not null)
|
||||
{
|
||||
planStep = BuildMapStep(step, step.Map, context, path, enabled, errors);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new TaskPackPlanError(path, "Step did not specify run, gate, parallel, or map."));
|
||||
planStep = new TaskPackPlanStep(step.Id, step.Id, step.Name, "invalid", enabled, null, null, ApprovalId: null, GateMessage: null, Children: null);
|
||||
}
|
||||
|
||||
context.RegisterStep(step.Id);
|
||||
return planStep;
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildRunStep(
|
||||
TaskPackStep step,
|
||||
TaskPackRunStep run,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var parameters = ResolveParameters(run.With, context, $"{path}.run", errors);
|
||||
|
||||
return new TaskPackPlanStep(
|
||||
step.Id,
|
||||
step.Id,
|
||||
step.Name,
|
||||
"run",
|
||||
enabled,
|
||||
run.Uses,
|
||||
parameters,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Children: null);
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildGateStep(
|
||||
TaskPackStep step,
|
||||
TaskPackGateStep gate,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
string type;
|
||||
string? approvalId = null;
|
||||
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? parameters = null;
|
||||
|
||||
if (gate.Approval is not null)
|
||||
{
|
||||
type = "gate.approval";
|
||||
approvalId = gate.Approval.Id;
|
||||
}
|
||||
else if (gate.Policy is not null)
|
||||
{
|
||||
type = "gate.policy";
|
||||
parameters = ResolveParameters(gate.Policy.Parameters, context, $"{path}.gate.policy", errors);
|
||||
}
|
||||
else
|
||||
{
|
||||
type = "gate";
|
||||
errors.Add(new TaskPackPlanError($"{path}.gate", "Gate must specify approval or policy."));
|
||||
}
|
||||
|
||||
return new TaskPackPlanStep(
|
||||
step.Id,
|
||||
step.Id,
|
||||
step.Name,
|
||||
type,
|
||||
enabled,
|
||||
Uses: null,
|
||||
parameters,
|
||||
ApprovalId: approvalId,
|
||||
GateMessage: gate.Message,
|
||||
Children: null);
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildParallelStep(
|
||||
TaskPackStep step,
|
||||
TaskPackParallelStep parallel,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var children = new List<TaskPackPlanStep>();
|
||||
for (var i = 0; i < parallel.Steps.Count; i++)
|
||||
{
|
||||
var child = BuildStep(parallel.Steps[i], context, $"{path}.parallel.steps[{i}]", errors);
|
||||
children.Add(child);
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
if (parallel.MaxParallel.HasValue)
|
||||
{
|
||||
parameters["maxParallel"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.MaxParallel.Value), null, null, false);
|
||||
}
|
||||
|
||||
parameters["continueOnError"] = new TaskPackPlanParameterValue(JsonValue.Create(parallel.ContinueOnError), null, null, false);
|
||||
|
||||
return new TaskPackPlanStep(
|
||||
step.Id,
|
||||
step.Id,
|
||||
step.Name,
|
||||
"parallel",
|
||||
enabled,
|
||||
Uses: null,
|
||||
parameters,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Children: children);
|
||||
}
|
||||
|
||||
private TaskPackPlanStep BuildMapStep(
|
||||
TaskPackStep step,
|
||||
TaskPackMapStep map,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
bool enabled,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
var itemsResolution = TaskPackExpressions.EvaluateString(map.Items, context);
|
||||
JsonArray? itemsArray = null;
|
||||
|
||||
if (!itemsResolution.Resolved)
|
||||
{
|
||||
if (itemsResolution.Error is not null)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"{path}.map.items", itemsResolution.Error));
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression requires runtime evaluation. Packs must provide deterministic item lists at plan time."));
|
||||
}
|
||||
}
|
||||
else if (itemsResolution.Value is JsonArray array)
|
||||
{
|
||||
itemsArray = (JsonArray?)array.DeepClone();
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"{path}.map.items", "Map items expression must resolve to an array."));
|
||||
}
|
||||
|
||||
if (itemsArray is not null)
|
||||
{
|
||||
parameters["items"] = new TaskPackPlanParameterValue(itemsArray, null, null, false);
|
||||
parameters["iterationCount"] = new TaskPackPlanParameterValue(JsonValue.Create(itemsArray.Count), null, null, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
parameters["items"] = new TaskPackPlanParameterValue(null, map.Items, "Map items expression could not be resolved.", true);
|
||||
}
|
||||
|
||||
var children = new List<TaskPackPlanStep>();
|
||||
if (itemsArray is not null)
|
||||
{
|
||||
for (var i = 0; i < itemsArray.Count; i++)
|
||||
{
|
||||
var item = itemsArray[i];
|
||||
var iterationContext = context.WithItem(item);
|
||||
var iterationPath = $"{path}.map.step[{i}]";
|
||||
var templateStep = BuildStep(map.Step, iterationContext, iterationPath, errors);
|
||||
|
||||
var childId = $"{step.Id}[{i}]::{map.Step.Id}";
|
||||
var iterationParameters = templateStep.Parameters is null
|
||||
? new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal)
|
||||
: new Dictionary<string, TaskPackPlanParameterValue>(templateStep.Parameters);
|
||||
|
||||
iterationParameters["item"] = new TaskPackPlanParameterValue(item?.DeepClone(), null, null, false);
|
||||
|
||||
var iterationStep = templateStep with
|
||||
{
|
||||
Id = childId,
|
||||
TemplateId = map.Step.Id,
|
||||
Parameters = iterationParameters
|
||||
};
|
||||
|
||||
children.Add(iterationStep);
|
||||
}
|
||||
}
|
||||
|
||||
return new TaskPackPlanStep(
|
||||
step.Id,
|
||||
step.Id,
|
||||
step.Name,
|
||||
"map",
|
||||
enabled,
|
||||
Uses: null,
|
||||
parameters,
|
||||
ApprovalId: null,
|
||||
GateMessage: null,
|
||||
Children: children);
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, TaskPackPlanParameterValue>? ResolveParameters(
|
||||
IDictionary<string, JsonNode?>? rawParameters,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
if (rawParameters is null || rawParameters.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolved = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in rawParameters)
|
||||
{
|
||||
var evaluation = TaskPackExpressions.EvaluateValue(value, context);
|
||||
if (!evaluation.Resolved && evaluation.Error is not null)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError($"{path}.with.{key}", evaluation.Error));
|
||||
}
|
||||
|
||||
resolved[key] = TaskPackPlanParameterValue.FromResolution(evaluation);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private IReadOnlyList<TaskPackPlanOutput> MaterializeOutputs(
|
||||
IReadOnlyList<TaskPackOutput>? outputs,
|
||||
TaskPackExpressionContext context,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
if (outputs is null || outputs.Count == 0)
|
||||
{
|
||||
return Array.Empty<TaskPackPlanOutput>();
|
||||
}
|
||||
|
||||
var results = new List<TaskPackPlanOutput>(outputs.Count);
|
||||
foreach (var (output, index) in outputs.Select((output, index) => (output, index)))
|
||||
{
|
||||
var pathValue = ConvertString(output.Path, context, $"spec.outputs[{index}].path", errors);
|
||||
var expressionValue = ConvertString(output.Expression, context, $"spec.outputs[{index}].expression", errors);
|
||||
|
||||
results.Add(new TaskPackPlanOutput(
|
||||
output.Name,
|
||||
output.Type,
|
||||
pathValue,
|
||||
expressionValue));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private TaskPackPlanParameterValue? ConvertString(
|
||||
string? value,
|
||||
TaskPackExpressionContext context,
|
||||
string path,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolution = TaskPackExpressions.EvaluateString(value, context);
|
||||
if (!resolution.Resolved && resolution.Error is not null)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError(path, resolution.Error));
|
||||
}
|
||||
|
||||
return TaskPackPlanParameterValue.FromResolution(resolution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Serialization;
|
||||
|
||||
internal static class CanonicalJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
{
|
||||
var node = JsonSerializer.SerializeToNode(value, SerializerOptions);
|
||||
if (node is null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to serialize value to JSON node.");
|
||||
}
|
||||
|
||||
var canonical = Canonicalize(node);
|
||||
return canonical.ToJsonString(SerializerOptions);
|
||||
}
|
||||
|
||||
public static JsonNode Canonicalize(JsonNode node)
|
||||
{
|
||||
return node switch
|
||||
{
|
||||
JsonObject obj => CanonicalizeObject(obj),
|
||||
JsonArray array => CanonicalizeArray(array),
|
||||
_ => node.DeepClone()
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject CanonicalizeObject(JsonObject obj)
|
||||
{
|
||||
var canonical = new JsonObject();
|
||||
foreach (var property in obj.OrderBy(static p => p.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (property.Value is null)
|
||||
{
|
||||
canonical[property.Key] = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
canonical[property.Key] = Canonicalize(property.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
private static JsonArray CanonicalizeArray(JsonArray array)
|
||||
{
|
||||
var canonical = new JsonArray();
|
||||
foreach (var element in array)
|
||||
{
|
||||
canonical.Add(element is null ? null : Canonicalize(element));
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
public sealed class TaskPackManifest
|
||||
{
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public required string ApiVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public required string Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public required TaskPackMetadata Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("spec")]
|
||||
public required TaskPackSpec Spec { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackMetadata
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantVisibility")]
|
||||
public IReadOnlyList<string>? TenantVisibility { get; init; }
|
||||
|
||||
[JsonPropertyName("maintainers")]
|
||||
public IReadOnlyList<TaskPackMaintainer>? Maintainers { get; init; }
|
||||
|
||||
[JsonPropertyName("license")]
|
||||
public string? License { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackMaintainer
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackSpec
|
||||
{
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("secrets")]
|
||||
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
|
||||
|
||||
[JsonPropertyName("approvals")]
|
||||
public IReadOnlyList<TaskPackApproval>? Approvals { get; init; }
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
|
||||
|
||||
[JsonPropertyName("success")]
|
||||
public TaskPackSuccess? Success { get; init; }
|
||||
|
||||
[JsonPropertyName("failure")]
|
||||
public TaskPackFailure? Failure { get; init; }
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackRunStep
|
||||
{
|
||||
[JsonPropertyName("uses")]
|
||||
public required string Uses { get; init; }
|
||||
|
||||
[JsonPropertyName("with")]
|
||||
public IDictionary<string, JsonNode?>? With { 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 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; }
|
||||
}
|
||||
@@ -0,0 +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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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);
|
||||
|
||||
if (typeCount == 0)
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, or map."));
|
||||
}
|
||||
else if (typeCount > 1)
|
||||
{
|
||||
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, or map."));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'."));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user