feat: Add Go module and workspace test fixtures

- Created expected JSON files for Go modules and workspaces.
- Added go.mod and go.sum files for example projects.
- Implemented private module structure with expected JSON output.
- Introduced vendored dependencies with corresponding expected JSON.
- Developed PostgresGraphJobStore for managing graph jobs.
- Established SQL migration scripts for graph jobs schema.
- Implemented GraphJobRepository for CRUD operations on graph jobs.
- Created IGraphJobRepository interface for repository abstraction.
- Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
StellaOps Bot
2025-12-06 20:04:03 +02:00
parent a6f1406509
commit 05597616d6
178 changed files with 12022 additions and 4545 deletions

View File

@@ -25,7 +25,9 @@ public enum PackRunStepKind
GateApproval,
GatePolicy,
Parallel,
Map
Map,
Loop,
Conditional
}
public sealed class PackRunExecutionStep
@@ -41,7 +43,10 @@ public sealed class PackRunExecutionStep
string? gateMessage,
int? maxParallel,
bool continueOnError,
IReadOnlyList<PackRunExecutionStep> children)
IReadOnlyList<PackRunExecutionStep> children,
PackRunLoopConfig? loopConfig = null,
PackRunConditionalConfig? conditionalConfig = null,
PackRunPolicyGateConfig? policyGateConfig = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
@@ -54,6 +59,9 @@ public sealed class PackRunExecutionStep
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopConfig = loopConfig;
ConditionalConfig = conditionalConfig;
PolicyGateConfig = policyGateConfig;
}
public string Id { get; }
@@ -78,9 +86,155 @@ public sealed class PackRunExecutionStep
public IReadOnlyList<PackRunExecutionStep> Children { get; }
/// <summary>Loop step configuration (when Kind == Loop).</summary>
public PackRunLoopConfig? LoopConfig { get; }
/// <summary>Conditional step configuration (when Kind == Conditional).</summary>
public PackRunConditionalConfig? ConditionalConfig { get; }
/// <summary>Policy gate configuration (when Kind == GatePolicy).</summary>
public PackRunPolicyGateConfig? PolicyGateConfig { get; }
public static IReadOnlyDictionary<string, TaskPackPlanParameterValue> EmptyParameters { get; } =
new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal));
public static IReadOnlyList<PackRunExecutionStep> EmptyChildren { get; } =
Array.Empty<PackRunExecutionStep>();
}
/// <summary>
/// Configuration for loop steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunLoopConfig(
/// <summary>Expression yielding items to iterate over.</summary>
string? ItemsExpression,
/// <summary>Static items array (alternative to expression).</summary>
IReadOnlyList<object>? StaticItems,
/// <summary>Range specification (alternative to expression).</summary>
PackRunLoopRange? Range,
/// <summary>Variable name bound to current item (default: "item").</summary>
string Iterator,
/// <summary>Variable name bound to current index (default: "index").</summary>
string Index,
/// <summary>Maximum iterations (safety limit).</summary>
int MaxIterations,
/// <summary>Aggregation mode for loop outputs.</summary>
PackRunLoopAggregationMode AggregationMode,
/// <summary>JMESPath to extract from each iteration result.</summary>
string? OutputPath)
{
public static PackRunLoopConfig Default => new(
null, null, null, "item", "index", 1000, PackRunLoopAggregationMode.Collect, null);
}
/// <summary>Range specification for loop iteration.</summary>
public sealed record PackRunLoopRange(int Start, int End, int Step = 1);
/// <summary>Loop output aggregation modes.</summary>
public enum PackRunLoopAggregationMode
{
/// <summary>Collect outputs into array.</summary>
Collect = 0,
/// <summary>Deep merge objects.</summary>
Merge,
/// <summary>Keep only last output.</summary>
Last,
/// <summary>Keep only first output.</summary>
First,
/// <summary>Discard outputs.</summary>
None
}
/// <summary>
/// Configuration for conditional steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunConditionalConfig(
/// <summary>Ordered branches (first matching executes).</summary>
IReadOnlyList<PackRunConditionalBranch> Branches,
/// <summary>Steps to execute if no branch matches.</summary>
IReadOnlyList<PackRunExecutionStep>? ElseBranch,
/// <summary>Whether to union outputs from all branches.</summary>
bool OutputUnion);
/// <summary>A conditional branch with condition and body.</summary>
public sealed record PackRunConditionalBranch(
/// <summary>Condition expression (JMESPath or operator-based).</summary>
string ConditionExpression,
/// <summary>Steps to execute if condition matches.</summary>
IReadOnlyList<PackRunExecutionStep> Body);
/// <summary>
/// Configuration for policy gate steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunPolicyGateConfig(
/// <summary>Policy identifier in the registry.</summary>
string PolicyId,
/// <summary>Specific policy version (semver).</summary>
string? PolicyVersion,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest,
/// <summary>JMESPath expression to construct policy input.</summary>
string? InputExpression,
/// <summary>Timeout for policy evaluation.</summary>
TimeSpan Timeout,
/// <summary>What to do on policy failure.</summary>
PackRunPolicyFailureAction FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount,
/// <summary>Delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Override approvers (if action is RequestOverride).</summary>
IReadOnlyList<string>? OverrideApprovers,
/// <summary>Step ID to branch to (if action is Branch).</summary>
string? BranchTo,
/// <summary>Whether to record decision in evidence locker.</summary>
bool RecordDecision,
/// <summary>Whether to record policy input.</summary>
bool RecordInput,
/// <summary>Whether to record rationale.</summary>
bool RecordRationale,
/// <summary>Whether to create DSSE attestation.</summary>
bool CreateAttestation)
{
public static PackRunPolicyGateConfig Default(string policyId) => new(
policyId, null, null, null,
TimeSpan.FromMinutes(5),
PackRunPolicyFailureAction.Abort, 0, TimeSpan.FromSeconds(10),
null, null, true, false, true, false);
}
/// <summary>Policy gate failure actions.</summary>
public enum PackRunPolicyFailureAction
{
/// <summary>Abort the run.</summary>
Abort = 0,
/// <summary>Log warning and continue.</summary>
Warn,
/// <summary>Request override approval.</summary>
RequestOverride,
/// <summary>Branch to specified step.</summary>
Branch
}

View File

@@ -30,6 +30,11 @@ public sealed class PackRunExecutionGraphBuilder
var maxParallel = TryGetInt(parameters, "maxParallel");
var continueOnError = TryGetBool(parameters, "continueOnError");
// Extract type-specific configurations
var loopConfig = kind == PackRunStepKind.Loop ? ExtractLoopConfig(parameters, children) : null;
var conditionalConfig = kind == PackRunStepKind.Conditional ? ExtractConditionalConfig(parameters, children) : null;
var policyGateConfig = kind == PackRunStepKind.GatePolicy ? ExtractPolicyGateConfig(parameters, step) : null;
return new PackRunExecutionStep(
step.Id,
step.TemplateId,
@@ -41,7 +46,10 @@ public sealed class PackRunExecutionGraphBuilder
step.GateMessage,
maxParallel,
continueOnError,
children);
children,
loopConfig,
conditionalConfig,
policyGateConfig);
}
private static PackRunStepKind DetermineKind(string? type)
@@ -52,9 +60,153 @@ public sealed class PackRunExecutionGraphBuilder
"gate.policy" => PackRunStepKind.GatePolicy,
"parallel" => PackRunStepKind.Parallel,
"map" => PackRunStepKind.Map,
"loop" => PackRunStepKind.Loop,
"conditional" => PackRunStepKind.Conditional,
_ => PackRunStepKind.Unknown
};
private static PackRunLoopConfig ExtractLoopConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var itemsExpression = TryGetString(parameters, "items");
var iterator = TryGetString(parameters, "iterator") ?? "item";
var index = TryGetString(parameters, "index") ?? "index";
var maxIterations = TryGetInt(parameters, "maxIterations") ?? 1000;
var aggregationMode = ParseAggregationMode(TryGetString(parameters, "aggregation"));
var outputPath = TryGetString(parameters, "outputPath");
// Parse range if present
PackRunLoopRange? range = null;
if (parameters.TryGetValue("range", out var rangeValue) && rangeValue.Value is JsonObject rangeObj)
{
var start = rangeObj["start"]?.GetValue<int>() ?? 0;
var end = rangeObj["end"]?.GetValue<int>() ?? 0;
var step = rangeObj["step"]?.GetValue<int>() ?? 1;
range = new PackRunLoopRange(start, end, step);
}
// Parse static items if present
IReadOnlyList<object>? staticItems = null;
if (parameters.TryGetValue("staticItems", out var staticValue) && staticValue.Value is JsonArray arr)
{
staticItems = arr.Select(n => (object)(n?.ToString() ?? "")).ToList();
}
return new PackRunLoopConfig(
itemsExpression, staticItems, range, iterator, index,
maxIterations, aggregationMode, outputPath);
}
private static PackRunConditionalConfig ExtractConditionalConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var branches = new List<PackRunConditionalBranch>();
IReadOnlyList<PackRunExecutionStep>? elseBranch = null;
var outputUnion = TryGetBool(parameters, "outputUnion");
// Parse branches from parameters
if (parameters.TryGetValue("branches", out var branchesValue) && branchesValue.Value is JsonArray branchArray)
{
foreach (var branchNode in branchArray)
{
if (branchNode is not JsonObject branchObj) continue;
var condition = branchObj["condition"]?.ToString() ?? "true";
var bodySteps = new List<PackRunExecutionStep>();
// Body would be parsed from the plan's children structure
// For now, use empty body - actual body comes from step children
branches.Add(new PackRunConditionalBranch(condition, bodySteps));
}
}
// If no explicit branches parsed, treat children as the primary branch body
if (branches.Count == 0 && children.Count > 0)
{
branches.Add(new PackRunConditionalBranch("true", children));
}
return new PackRunConditionalConfig(branches, elseBranch, outputUnion);
}
private static PackRunPolicyGateConfig? ExtractPolicyGateConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
TaskPackPlanStep step)
{
var policyId = TryGetString(parameters, "policyId") ?? TryGetString(parameters, "policy");
if (string.IsNullOrEmpty(policyId)) return null;
var policyVersion = TryGetString(parameters, "policyVersion");
var policyDigest = TryGetString(parameters, "policyDigest");
var inputExpression = TryGetString(parameters, "inputExpression");
var timeout = ParseTimeSpan(TryGetString(parameters, "timeout"), TimeSpan.FromMinutes(5));
var failureAction = ParsePolicyFailureAction(TryGetString(parameters, "failureAction"));
var retryCount = TryGetInt(parameters, "retryCount") ?? 0;
var retryDelay = ParseTimeSpan(TryGetString(parameters, "retryDelay"), TimeSpan.FromSeconds(10));
var recordDecision = TryGetBool(parameters, "recordDecision") || !parameters.ContainsKey("recordDecision");
var recordInput = TryGetBool(parameters, "recordInput");
var recordRationale = TryGetBool(parameters, "recordRationale") || !parameters.ContainsKey("recordRationale");
var createAttestation = TryGetBool(parameters, "attestation");
// Parse override approvers
IReadOnlyList<string>? overrideApprovers = null;
if (parameters.TryGetValue("overrideApprovers", out var approversValue) && approversValue.Value is JsonArray arr)
{
overrideApprovers = arr.Select(n => n?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList();
}
var branchTo = TryGetString(parameters, "branchTo");
return new PackRunPolicyGateConfig(
policyId, policyVersion, policyDigest, inputExpression,
timeout, failureAction, retryCount, retryDelay,
overrideApprovers, branchTo,
recordDecision, recordInput, recordRationale, createAttestation);
}
private static PackRunLoopAggregationMode ParseAggregationMode(string? mode)
=> mode?.ToLowerInvariant() switch
{
"collect" => PackRunLoopAggregationMode.Collect,
"merge" => PackRunLoopAggregationMode.Merge,
"last" => PackRunLoopAggregationMode.Last,
"first" => PackRunLoopAggregationMode.First,
"none" => PackRunLoopAggregationMode.None,
_ => PackRunLoopAggregationMode.Collect
};
private static PackRunPolicyFailureAction ParsePolicyFailureAction(string? action)
=> action?.ToLowerInvariant() switch
{
"abort" => PackRunPolicyFailureAction.Abort,
"warn" => PackRunPolicyFailureAction.Warn,
"requestoverride" => PackRunPolicyFailureAction.RequestOverride,
"branch" => PackRunPolicyFailureAction.Branch,
_ => PackRunPolicyFailureAction.Abort
};
private static TimeSpan ParseTimeSpan(string? value, TimeSpan defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
// Parse formats like "30s", "5m", "1h"
if (value.Length < 2) return defaultValue;
var unit = value[^1];
if (!int.TryParse(value[..^1], out var number)) return defaultValue;
return unit switch
{
's' => TimeSpan.FromSeconds(number),
'm' => TimeSpan.FromMinutes(number),
'h' => TimeSpan.FromHours(number),
'd' => TimeSpan.FromDays(number),
_ => defaultValue
};
}
private static int? TryGetInt(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
@@ -74,4 +226,18 @@ public sealed class PackRunExecutionGraphBuilder
return jsonValue.TryGetValue<bool>(out var result) && result;
}
private static string? TryGetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value))
{
return null;
}
return value.Value switch
{
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var str) => str,
_ => value.Value?.ToString()
};
}
}

View File

@@ -30,6 +30,32 @@ public sealed class PackRunSimulationEngine
? PackRunSimulationNode.Empty
: new ReadOnlyCollection<PackRunSimulationNode>(step.Children.Select(ConvertStep).ToList());
// Extract loop/conditional specific details
var loopInfo = step.Kind == PackRunStepKind.Loop && step.LoopConfig is not null
? new PackRunSimulationLoopInfo(
step.LoopConfig.ItemsExpression,
step.LoopConfig.Iterator,
step.LoopConfig.Index,
step.LoopConfig.MaxIterations,
step.LoopConfig.AggregationMode.ToString().ToLowerInvariant())
: null;
var conditionalInfo = step.Kind == PackRunStepKind.Conditional && step.ConditionalConfig is not null
? new PackRunSimulationConditionalInfo(
step.ConditionalConfig.Branches.Select(b =>
new PackRunSimulationBranch(b.ConditionExpression, b.Body.Count)).ToList(),
step.ConditionalConfig.ElseBranch?.Count ?? 0,
step.ConditionalConfig.OutputUnion)
: null;
var policyInfo = step.Kind == PackRunStepKind.GatePolicy && step.PolicyGateConfig is not null
? new PackRunSimulationPolicyInfo(
step.PolicyGateConfig.PolicyId,
step.PolicyGateConfig.PolicyVersion,
step.PolicyGateConfig.FailureAction.ToString().ToLowerInvariant(),
step.PolicyGateConfig.RetryCount)
: null;
return new PackRunSimulationNode(
step.Id,
step.TemplateId,
@@ -42,7 +68,10 @@ public sealed class PackRunSimulationEngine
step.MaxParallel,
step.ContinueOnError,
status,
children);
children,
loopInfo,
conditionalInfo,
policyInfo);
}
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
@@ -56,6 +85,8 @@ public sealed class PackRunSimulationEngine
{
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate,
PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch,
_ => PackRunSimulationStatus.Pending
};
}

View File

@@ -48,7 +48,10 @@ public sealed class PackRunSimulationNode
int? maxParallel,
bool continueOnError,
PackRunSimulationStatus status,
IReadOnlyList<PackRunSimulationNode> children)
IReadOnlyList<PackRunSimulationNode> children,
PackRunSimulationLoopInfo? loopInfo = null,
PackRunSimulationConditionalInfo? conditionalInfo = null,
PackRunSimulationPolicyInfo? policyInfo = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
@@ -62,6 +65,9 @@ public sealed class PackRunSimulationNode
ContinueOnError = continueOnError;
Status = status;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopInfo = loopInfo;
ConditionalInfo = conditionalInfo;
PolicyInfo = policyInfo;
}
public string Id { get; }
@@ -88,6 +94,15 @@ public sealed class PackRunSimulationNode
public IReadOnlyList<PackRunSimulationNode> Children { get; }
/// <summary>Loop step simulation info (when Kind == Loop).</summary>
public PackRunSimulationLoopInfo? LoopInfo { get; }
/// <summary>Conditional step simulation info (when Kind == Conditional).</summary>
public PackRunSimulationConditionalInfo? ConditionalInfo { get; }
/// <summary>Policy gate simulation info (when Kind == GatePolicy).</summary>
public PackRunSimulationPolicyInfo? PolicyInfo { get; }
public static IReadOnlyList<PackRunSimulationNode> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationNode>(Array.Empty<PackRunSimulationNode>());
}
@@ -97,9 +112,53 @@ public enum PackRunSimulationStatus
Pending = 0,
Skipped,
RequiresApproval,
RequiresPolicy
RequiresPolicy,
/// <summary>Loop step will iterate over items.</summary>
WillIterate,
/// <summary>Conditional step will branch based on conditions.</summary>
WillBranch
}
/// <summary>Loop step simulation details.</summary>
public sealed record PackRunSimulationLoopInfo(
/// <summary>Items expression to iterate over.</summary>
string? ItemsExpression,
/// <summary>Iterator variable name.</summary>
string Iterator,
/// <summary>Index variable name.</summary>
string Index,
/// <summary>Maximum iterations allowed.</summary>
int MaxIterations,
/// <summary>Aggregation mode for outputs.</summary>
string AggregationMode);
/// <summary>Conditional step simulation details.</summary>
public sealed record PackRunSimulationConditionalInfo(
/// <summary>Branch conditions and body step counts.</summary>
IReadOnlyList<PackRunSimulationBranch> Branches,
/// <summary>Number of steps in else branch.</summary>
int ElseStepCount,
/// <summary>Whether outputs are unioned.</summary>
bool OutputUnion);
/// <summary>A conditional branch summary.</summary>
public sealed record PackRunSimulationBranch(
/// <summary>Condition expression.</summary>
string Condition,
/// <summary>Number of steps in body.</summary>
int StepCount);
/// <summary>Policy gate simulation details.</summary>
public sealed record PackRunSimulationPolicyInfo(
/// <summary>Policy identifier.</summary>
string PolicyId,
/// <summary>Policy version (if specified).</summary>
string? PolicyVersion,
/// <summary>Failure action.</summary>
string FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount);
public sealed class PackRunSimulationOutput
{
public PackRunSimulationOutput(

View File

@@ -54,11 +54,11 @@ public sealed class TaskPackMaintainer
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
@@ -72,17 +72,17 @@ public sealed class TaskPackSpec
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
}
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
}
public sealed class TaskPackInput
{
@@ -154,35 +154,41 @@ public sealed class TaskPackStep
[JsonPropertyName("map")]
public TaskPackMapStep? Map { get; init; }
[JsonPropertyName("loop")]
public TaskPackLoopStep? Loop { get; init; }
[JsonPropertyName("conditional")]
public TaskPackConditionalStep? Conditional { get; init; }
}
public sealed class TaskPackRunStep
{
[JsonPropertyName("uses")]
public required string Uses { get; init; }
[JsonPropertyName("with")]
public IDictionary<string, JsonNode?>? With { get; init; }
[JsonPropertyName("egress")]
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
}
public sealed class TaskPackRunEgress
{
[JsonPropertyName("url")]
public required string Url { get; init; }
[JsonPropertyName("intent")]
public string? Intent { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackGateStep
{
[JsonPropertyName("approval")]
public sealed class TaskPackRunStep
{
[JsonPropertyName("uses")]
public required string Uses { get; init; }
[JsonPropertyName("with")]
public IDictionary<string, JsonNode?>? With { get; init; }
[JsonPropertyName("egress")]
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
}
public sealed class TaskPackRunEgress
{
[JsonPropertyName("url")]
public required string Url { get; init; }
[JsonPropertyName("intent")]
public string? Intent { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackGateStep
{
[JsonPropertyName("approval")]
public TaskPackApprovalGate? Approval { get; init; }
[JsonPropertyName("policy")]
@@ -231,6 +237,69 @@ public sealed class TaskPackMapStep
public required TaskPackStep Step { get; init; }
}
public sealed class TaskPackLoopStep
{
[JsonPropertyName("items")]
public string? Items { get; init; }
[JsonPropertyName("range")]
public TaskPackLoopRange? Range { get; init; }
[JsonPropertyName("staticItems")]
public IReadOnlyList<object>? StaticItems { get; init; }
[JsonPropertyName("iterator")]
public string Iterator { get; init; } = "item";
[JsonPropertyName("index")]
public string Index { get; init; } = "index";
[JsonPropertyName("maxIterations")]
public int MaxIterations { get; init; } = 1000;
[JsonPropertyName("aggregation")]
public string? Aggregation { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackLoopRange
{
[JsonPropertyName("start")]
public int Start { get; init; }
[JsonPropertyName("end")]
public int End { get; init; }
[JsonPropertyName("step")]
public int Step { get; init; } = 1;
}
public sealed class TaskPackConditionalStep
{
[JsonPropertyName("branches")]
public IReadOnlyList<TaskPackConditionalBranch> Branches { get; init; } = Array.Empty<TaskPackConditionalBranch>();
[JsonPropertyName("else")]
public IReadOnlyList<TaskPackStep>? Else { get; init; }
[JsonPropertyName("outputUnion")]
public bool OutputUnion { get; init; }
}
public sealed class TaskPackConditionalBranch
{
[JsonPropertyName("condition")]
public required string Condition { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackOutput
{
[JsonPropertyName("name")]
@@ -261,41 +330,41 @@ public sealed class TaskPackFailure
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}

View File

@@ -1,5 +1,5 @@
using System;
using System.Collections.Immutable;
using System;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using System.Linq;
@@ -124,21 +124,23 @@ public sealed class TaskPackManifestValidator
var typeCount = (step.Run is not null ? 1 : 0)
+ (step.Gate is not null ? 1 : 0)
+ (step.Parallel is not null ? 1 : 0)
+ (step.Map is not null ? 1 : 0);
+ (step.Map is not null ? 1 : 0)
+ (step.Loop is not null ? 1 : 0)
+ (step.Conditional is not null ? 1 : 0);
if (typeCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, or map."));
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, map, loop, or conditional."));
}
else if (typeCount > 1)
{
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, or map."));
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, map, loop, or conditional."));
}
if (step.Run is not null)
{
ValidateRunStep(step.Run, $"{path}.run", errors);
}
if (step.Run is not null)
{
ValidateRunStep(step.Run, $"{path}.run", errors);
}
if (step.Gate is not null)
{
@@ -154,47 +156,57 @@ public sealed class TaskPackManifestValidator
{
ValidateMapStep(step.Map, $"{path}.map", stepIds, approvalIds, errors);
}
if (step.Loop is not null)
{
ValidateLoopStep(step.Loop, $"{path}.loop", stepIds, approvalIds, errors);
}
if (step.Conditional is not null)
{
ValidateConditionalStep(step.Conditional, $"{path}.conditional", stepIds, approvalIds, errors);
}
}
}
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(run.Uses))
{
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
continue;
}
if (string.IsNullOrWhiteSpace(entry.Url))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
}
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
}
}
}
}
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(run.Uses))
{
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
continue;
}
if (string.IsNullOrWhiteSpace(entry.Url))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
}
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
}
}
}
}
private static void ValidateGateStep(TaskPackGateStep gate, HashSet<string> approvalIds, string path, ICollection<TaskPackManifestValidationError> errors)
{
@@ -250,6 +262,77 @@ public sealed class TaskPackManifestValidator
ValidateSteps(new[] { map.Step }, $"{path}.step", stepIds, approvalIds, errors);
}
}
private static void ValidateLoopStep(
TaskPackLoopStep loop,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
// Loop must have one of: items expression, range, or staticItems
var sourceCount = (string.IsNullOrWhiteSpace(loop.Items) ? 0 : 1)
+ (loop.Range is not null ? 1 : 0)
+ (loop.StaticItems is not null ? 1 : 0);
if (sourceCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Loop step requires 'items', 'range', or 'staticItems'."));
}
if (loop.MaxIterations <= 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.maxIterations", "maxIterations must be greater than 0."));
}
if (loop.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Loop step requires nested steps."));
}
else
{
ValidateSteps(loop.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
}
private static void ValidateConditionalStep(
TaskPackConditionalStep conditional,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (conditional.Branches.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.branches", "Conditional step requires at least one branch."));
return;
}
for (var i = 0; i < conditional.Branches.Count; i++)
{
var branch = conditional.Branches[i];
var branchPath = $"{path}.branches[{i}]";
if (string.IsNullOrWhiteSpace(branch.Condition))
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.condition", "Branch requires a condition expression."));
}
if (branch.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.steps", "Branch requires nested steps."));
}
else
{
ValidateSteps(branch.Steps, $"{branchPath}.steps", stepIds, approvalIds, errors);
}
}
if (conditional.Else is not null && conditional.Else.Count > 0)
{
ValidateSteps(conditional.Else, $"{path}.else", stepIds, approvalIds, errors);
}
}
}
public sealed record TaskPackManifestValidationError(string Path, string Message);

View File

@@ -72,4 +72,71 @@ public sealed class PackRunSimulationEngineTests
Assert.True(evidence.RequiresRuntimeValue);
});
}
[Fact]
public void Simulate_LoopStep_SetsWillIterateStatus()
{
var manifest = TestManifests.Load(TestManifests.Loop);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray { "a", "b", "c" }
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
Assert.Equal("process-loop", loopStep.Id);
Assert.NotNull(loopStep.LoopInfo);
Assert.Equal("target", loopStep.LoopInfo.Iterator);
Assert.Equal("idx", loopStep.LoopInfo.Index);
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
}
[Fact]
public void Simulate_ConditionalStep_SetsWillBranchStatus()
{
var manifest = TestManifests.Load(TestManifests.Conditional);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["environment"] = JsonValue.Create("production")
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
Assert.Equal("env-branch", conditionalStep.Id);
Assert.NotNull(conditionalStep.ConditionalInfo);
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
}
[Fact]
public void Simulate_PolicyGateStep_HasPolicyInfo()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
Assert.NotNull(policyStep.PolicyInfo);
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
}
}

View File

@@ -1,8 +1,8 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Tests;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.TaskPacks;
namespace StellaOps.TaskRunner.Tests;
internal static partial class TestManifests
{
public static TaskPackManifest Load(string yaml)
@@ -10,15 +10,15 @@ internal static partial class TestManifests
var loader = new TaskPackManifestLoader();
return loader.Deserialize(yaml);
}
public const string Sample = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
version: 1.0.0
description: Sample pack for planner tests
tags: [tests]
public const string Sample = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
version: 1.0.0
description: Sample pack for planner tests
tags: [tests]
spec:
inputs:
- name: dryRun
@@ -40,23 +40,23 @@ spec:
grants: ["packs.approve"]
steps:
- id: plan-step
name: Plan
run:
uses: builtin:plan
with:
dryRun: "{{ inputs.dryRun }}"
- id: approval
gate:
approval:
id: security-review
message: "Security approval required."
- id: apply-step
when: "{{ not inputs.dryRun }}"
run:
uses: builtin:apply
""";
public const string RequiredInput = """
name: Plan
run:
uses: builtin:plan
with:
dryRun: "{{ inputs.dryRun }}"
- id: approval
gate:
approval:
id: security-review
message: "Security approval required."
- id: apply-step
when: "{{ not inputs.dryRun }}"
run:
uses: builtin:apply
""";
public const string RequiredInput = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
@@ -86,11 +86,11 @@ spec:
""";
public const string StepReference = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: step-ref-pack
version: 1.0.0
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: step-ref-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
@@ -107,18 +107,18 @@ spec:
run:
uses: builtin:prepare
- id: consume
run:
uses: builtin:consume
with:
sourceSummary: "{{ steps.prepare.outputs.summary }}"
""";
public const string Map = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: map-pack
version: 1.0.0
run:
uses: builtin:consume
with:
sourceSummary: "{{ steps.prepare.outputs.summary }}"
""";
public const string Map = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: map-pack
version: 1.0.0
spec:
inputs:
- name: targets
@@ -139,19 +139,19 @@ spec:
map:
items: "{{ inputs.targets }}"
step:
id: echo-step
run:
uses: builtin:echo
with:
target: "{{ item }}"
""";
public const string Secret = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: secret-pack
version: 1.0.0
id: echo-step
run:
uses: builtin:echo
with:
target: "{{ item }}"
""";
public const string Secret = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: secret-pack
version: 1.0.0
spec:
secrets:
- name: apiKey
@@ -172,15 +172,15 @@ spec:
run:
uses: builtin:http
with:
token: "{{ secrets.apiKey }}"
""";
public const string Output = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: output-pack
version: 1.0.0
token: "{{ secrets.apiKey }}"
""";
public const string Output = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: output-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
@@ -197,9 +197,9 @@ spec:
run:
uses: builtin:generate
outputs:
- name: bundlePath
type: file
path: artifacts/report.txt
- name: bundlePath
type: file
path: artifacts/report.txt
- name: evidenceModel
type: object
expression: "{{ steps.generate.outputs.evidence }}"
@@ -379,4 +379,87 @@ spec:
with:
url: "{{ inputs.targetUrl }}"
""";
public const string Loop = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: loop-pack
version: 1.0.0
spec:
inputs:
- name: targets
type: array
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: process-loop
loop:
items: "{{ inputs.targets }}"
iterator: target
index: idx
maxIterations: 100
aggregation: collect
steps:
- id: process-item
run:
uses: builtin:process
""";
public const string Conditional = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: conditional-pack
version: 1.0.0
spec:
inputs:
- name: environment
type: string
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: env-branch
conditional:
branches:
- condition: "{{ inputs.environment == 'production' }}"
steps:
- id: deploy-prod
run:
uses: builtin:deploy
with:
target: production
- condition: "{{ inputs.environment == 'staging' }}"
steps:
- id: deploy-staging
run:
uses: builtin:deploy
with:
target: staging
else:
- id: deploy-dev
run:
uses: builtin:deploy
with:
target: development
outputUnion: true
""";
}