223 lines
8.8 KiB
C#
223 lines
8.8 KiB
C#
using System.Text.RegularExpressions;
|
|
using w4c_workflows.Models;
|
|
|
|
namespace w4c_workflows.Services;
|
|
|
|
/// <summary>
|
|
/// Validates a parsed <see cref="WorkflowDefinition"/>. Pure and side-effect
|
|
/// free: it returns a list of human-readable errors (empty = valid). The
|
|
/// compiler calls this before building the EF entities.
|
|
/// </summary>
|
|
public partial class WorkflowValidator
|
|
{
|
|
private static readonly string[] Modes = { WorkflowMode.Function, WorkflowMode.Durable, WorkflowMode.Handler };
|
|
private static readonly string[] TriggerTypes = { TriggerType.Cron, TriggerType.Interval, TriggerType.Webhook, TriggerType.Event, TriggerType.Queue };
|
|
|
|
[GeneratedRegex(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled)]
|
|
private static partial Regex IdentifierRegex();
|
|
|
|
private readonly LanguageRegistry _languages;
|
|
|
|
public WorkflowValidator(LanguageRegistry languages)
|
|
{
|
|
_languages = languages;
|
|
}
|
|
|
|
public IReadOnlyList<string> Validate(WorkflowDefinition def)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
ValidateHeader(def, errors);
|
|
ValidateTrigger(def, errors);
|
|
|
|
var tasks = def.Tasks ?? new List<TaskDefinition>();
|
|
ValidateTasks(def, tasks, errors);
|
|
ValidateFlow(tasks, errors);
|
|
|
|
return errors;
|
|
}
|
|
|
|
private void ValidateHeader(WorkflowDefinition def, List<string> errors)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(def.Name))
|
|
{
|
|
errors.Add("name is required");
|
|
}
|
|
else if (def.Name.Length > 200 || !IdentifierRegex().IsMatch(def.Name))
|
|
{
|
|
errors.Add($"name '{def.Name}' is invalid: use 1-200 chars of letters, digits, '.', '_', '-'");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(def.Mode))
|
|
errors.Add("mode is required (function | durable | handler)");
|
|
else if (!Modes.Contains(def.Mode))
|
|
errors.Add($"mode '{def.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
|
|
|
|
if (string.IsNullOrWhiteSpace(def.Language))
|
|
errors.Add("language is required (shell | javascript | typescript | csharp | python)");
|
|
else if (!_languages.IsKnown(def.Language))
|
|
errors.Add($"language '{def.Language}' is not supported");
|
|
|
|
if (def.Entry == null || string.IsNullOrWhiteSpace(def.Entry.File))
|
|
errors.Add("entry.file is required");
|
|
}
|
|
|
|
private void ValidateTrigger(WorkflowDefinition def, List<string> errors)
|
|
{
|
|
var trigger = def.Trigger;
|
|
if (trigger == null)
|
|
return; // no auto-trigger → manual (event) only; valid
|
|
|
|
if (string.IsNullOrWhiteSpace(trigger.Type) || !TriggerTypes.Contains(trigger.Type))
|
|
{
|
|
errors.Add($"trigger.type is invalid: expected one of {string.Join(", ", TriggerTypes)}");
|
|
return;
|
|
}
|
|
|
|
if (def.Mode == WorkflowMode.Handler)
|
|
{
|
|
// Handler mode is a stream subscription — its trigger must be a queue.
|
|
if (trigger.Type != TriggerType.Queue)
|
|
errors.Add("handler mode requires trigger.type = 'queue'");
|
|
return;
|
|
}
|
|
|
|
switch (trigger.Type)
|
|
{
|
|
case TriggerType.Cron:
|
|
if (string.IsNullOrWhiteSpace(trigger.Cron) || trigger.Cron.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length != 5)
|
|
errors.Add("trigger.cron is required and must be a 5-field cron expression");
|
|
break;
|
|
case TriggerType.Interval:
|
|
if (!DurationParser.TryParse(trigger.Interval, out _))
|
|
errors.Add("trigger.interval is required and must be a duration like '30m' or '2h'");
|
|
break;
|
|
case TriggerType.Webhook:
|
|
if (string.IsNullOrWhiteSpace(trigger.WebhookPath) || !trigger.WebhookPath.StartsWith('/'))
|
|
errors.Add("trigger.webhookPath is required and must start with '/'");
|
|
break;
|
|
case TriggerType.Queue:
|
|
errors.Add("queue trigger is only valid for handler mode");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void ValidateTasks(WorkflowDefinition def, IReadOnlyList<TaskDefinition> tasks, List<string> errors)
|
|
{
|
|
var ids = new HashSet<string>(StringComparer.Ordinal);
|
|
for (var i = 0; i < tasks.Count; i++)
|
|
{
|
|
var task = tasks[i];
|
|
var label = $"tasks[{i}]";
|
|
|
|
if (string.IsNullOrWhiteSpace(task.Id))
|
|
{
|
|
errors.Add($"{label}.id is required");
|
|
continue;
|
|
}
|
|
if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
errors.Add($"{label}.id 'root' is reserved");
|
|
continue;
|
|
}
|
|
if (!IdentifierRegex().IsMatch(task.Id) || task.Id.Length > 100)
|
|
{
|
|
errors.Add($"{label}.id '{task.Id}' is invalid");
|
|
continue;
|
|
}
|
|
if (!ids.Add(task.Id))
|
|
{
|
|
errors.Add($"{label}.id '{task.Id}' is duplicated");
|
|
continue;
|
|
}
|
|
|
|
if (task.Language != null && !_languages.IsKnown(task.Language))
|
|
errors.Add($"{label}.language '{task.Language}' is not supported");
|
|
|
|
if (task.Mode != null && !Modes.Contains(task.Mode))
|
|
errors.Add($"{label}.mode '{task.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
|
|
|
|
if (task.Entry == null || string.IsNullOrWhiteSpace(task.Entry.File))
|
|
errors.Add($"{label}.entry.file is required");
|
|
}
|
|
|
|
// Reference resolution: parent/next/onError must point at an existing
|
|
// task (or "root" for parent).
|
|
var knownIds = ids;
|
|
foreach (var task in tasks)
|
|
{
|
|
if (task.Id == null) continue;
|
|
var label = $"task '{task.Id}'";
|
|
|
|
var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent;
|
|
if (!string.Equals(parent, "root", StringComparison.Ordinal) && !knownIds.Contains(parent))
|
|
errors.Add($"{label}.parent '{parent}' does not reference a known task or 'root'");
|
|
|
|
if (!string.IsNullOrEmpty(task.Next))
|
|
{
|
|
if (!knownIds.Contains(task.Next))
|
|
errors.Add($"{label}.next '{task.Next}' does not reference a known task");
|
|
else if (string.Equals(task.Next, task.Id, StringComparison.Ordinal))
|
|
errors.Add($"{label}.next must not reference itself");
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(task.OnError))
|
|
{
|
|
if (!knownIds.Contains(task.OnError))
|
|
errors.Add($"{label}.onError '{task.OnError}' does not reference a known task");
|
|
else if (string.Equals(task.OnError, task.Id, StringComparison.Ordinal))
|
|
errors.Add($"{label}.onError must not reference itself");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ValidateFlow(IReadOnlyList<TaskDefinition> tasks, List<string> errors)
|
|
{
|
|
if (tasks.Count == 0)
|
|
return; // root-only workflow
|
|
|
|
// Skip flow validation if the reference/structural checks already failed —
|
|
// the graph would be incomplete and produce misleading errors.
|
|
if (errors.Count > 0)
|
|
return;
|
|
|
|
var graph = WorkflowGraph.Compute(new WorkflowDefinition { Tasks = tasks.ToList() });
|
|
|
|
// Linear chain: each task is the `next` target of at most one other task.
|
|
foreach (var (taskId, incoming) in graph.IncomingNext)
|
|
{
|
|
if (incoming > 1)
|
|
errors.Add($"task '{taskId}' has {incoming} incoming 'next' edges; the success chain must be linear");
|
|
}
|
|
|
|
if (graph.HeadId == null)
|
|
{
|
|
errors.Add("no chain head: expected a top-level task (parent: root) that starts the success chain");
|
|
return;
|
|
}
|
|
|
|
// Cycle detection on the success chain.
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
var cursor = graph.HeadId;
|
|
while (cursor != null)
|
|
{
|
|
if (!seen.Add(cursor))
|
|
{
|
|
errors.Add($"cycle detected in the success chain at task '{cursor}'");
|
|
return;
|
|
}
|
|
graph.ById.TryGetValue(cursor, out var task);
|
|
cursor = string.IsNullOrEmpty(task?.Next) ? null : task.Next;
|
|
}
|
|
|
|
// Every task must be either on the success chain or compensation-only.
|
|
var chained = new HashSet<string>(graph.Chain, StringComparer.Ordinal);
|
|
foreach (var task in tasks)
|
|
{
|
|
if (task.Id == null) continue;
|
|
if (chained.Contains(task.Id) || graph.CompensationOnly.Contains(task.Id)) continue;
|
|
errors.Add($"task '{task.Id}' is unreachable: it is not on the success chain and not an onError target");
|
|
}
|
|
}
|
|
}
|