Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobDispatcher
{
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.TaskRunner.Core.Execution;
public enum PackRunApprovalStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Expired = 3
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunProcessorResult(
PackRunApprovalCoordinator ApprovalCoordinator,
bool ShouldResumeImmediately);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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)
{
}
}

View File

@@ -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;
}