workflow development

This commit is contained in:
Vitali sharp8n 2026-09-12 23:29:19 +03:00
parent 3bad99fad3
commit b977619f94
13 changed files with 263 additions and 40 deletions

View file

@ -5,6 +5,7 @@ using w4c_workflows.Filters;
using w4c_workflows.Models; using w4c_workflows.Models;
using w4c_workflows.Models.Credentials; using w4c_workflows.Models.Credentials;
using w4c_workflows.Services.Credentials; using w4c_workflows.Services.Credentials;
using w4c_workflows.Services.Security;
namespace w4c_workflows.Controllers; namespace w4c_workflows.Controllers;
@ -21,6 +22,7 @@ public class CredentialsController : ControllerBase
private readonly CredentialVault _vault; private readonly CredentialVault _vault;
private readonly CredentialTypeCatalog _types; private readonly CredentialTypeCatalog _types;
private readonly IHttpClientFactory _http; private readonly IHttpClientFactory _http;
private readonly EgressGuard _egress;
private readonly ILogger<CredentialsController> _logger; private readonly ILogger<CredentialsController> _logger;
public CredentialsController( public CredentialsController(
@ -28,12 +30,14 @@ public class CredentialsController : ControllerBase
CredentialVault vault, CredentialVault vault,
CredentialTypeCatalog types, CredentialTypeCatalog types,
IHttpClientFactory http, IHttpClientFactory http,
EgressGuard egress,
ILogger<CredentialsController> logger) ILogger<CredentialsController> logger)
{ {
_db = db; _db = db;
_vault = vault; _vault = vault;
_types = types; _types = types;
_http = http; _http = http;
_egress = egress;
_logger = logger; _logger = logger;
} }
@ -136,6 +140,15 @@ public class CredentialsController : ControllerBase
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)) if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
return Ok(new CredentialTestResult(false, null, $"'{request.Url}' is not a valid absolute URL")); return Ok(new CredentialTestResult(false, null, $"'{request.Url}' is not a valid absolute URL"));
// The probe URL is caller-supplied and the request carries a decrypted
// credential, so it must never be allowed to reach a private/loopback/
// metadata address. Vet it exactly like a workflow HTTP node; the named
// client is registered without automatic redirects so a public URL
// cannot bounce the credential to an internal host.
var egress = await _egress.AuthorizeAsync(uri, ct);
if (!egress.Allowed)
return Ok(new CredentialTestResult(false, null, $"probe blocked by egress policy: {egress.Reason}"));
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
var injectionError = CredentialInjector.Apply(new CredentialData(entity.Type, data), _types, httpRequest); var injectionError = CredentialInjector.Apply(new CredentialData(entity.Type, data), _types, httpRequest);
if (injectionError != null) if (injectionError != null)

View file

@ -9,9 +9,11 @@ namespace w4c_workflows.Controllers;
public class HealthController : ControllerBase public class HealthController : ControllerBase
{ {
private readonly WorkflowsDbContext _db; private readonly WorkflowsDbContext _db;
private readonly IConnectionMultiplexer _redis; private readonly IConnectionMultiplexer? _redis;
public HealthController(WorkflowsDbContext db, IConnectionMultiplexer redis) // Redis is absent in Lite mode (in-memory transport). The dependency is
// optional so liveness/readiness still resolve instead of failing DI.
public HealthController(WorkflowsDbContext db, IConnectionMultiplexer? redis = null)
{ {
_db = db; _db = db;
_redis = redis; _redis = redis;
@ -39,14 +41,16 @@ public class HealthController : ControllerBase
try try
{ {
checks["redis"] = _redis.IsConnected ? "ok" : "disconnected"; checks["redis"] = _redis == null
? "not_configured"
: (_redis.IsConnected ? "ok" : "disconnected");
} }
catch (Exception ex) catch (Exception ex)
{ {
checks["redis"] = $"error: {ex.Message}"; checks["redis"] = $"error: {ex.Message}";
} }
var healthy = checks.Values.All(v => v == "ok"); var healthy = checks.Values.All(v => v is "ok" or "not_configured");
return healthy ? Ok(new { status = "ok", checks }) : StatusCode(503, new { status = "degraded", checks }); return healthy ? Ok(new { status = "ok", checks }) : StatusCode(503, new { status = "degraded", checks });
} }
} }

View file

@ -79,6 +79,11 @@ builder.Services.AddHttpClient(HttpRequestNodeExecutor.InsecureClientName)
}, },
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// Credential probe client: caller-supplied URL + decrypted credential, so it
// must not follow redirects (a public host could otherwise bounce the secret to
// an internal address after the egress check).
builder.Services.AddHttpClient("credential-test")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false });
// Credential vault encryption keys (used by ICredentialCipher). // Credential vault encryption keys (used by ICredentialCipher).
builder.Services.AddDataProtection(); builder.Services.AddDataProtection();
@ -449,7 +454,11 @@ try
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Logger.Error(ex, "Failed to apply EF Core migrations on startup"); // Serving traffic with an unusable/partial schema produces silent, hard-to-
// trace failures in every request. Fail fast so the orchestrator restarts or
// surfaces the real error instead.
Log.Logger.Fatal(ex, "Failed to apply EF Core migrations on startup; refusing to start");
throw;
} }
// Scalar API reference + OpenAPI document are exposed under a dedicated // Scalar API reference + OpenAPI document are exposed under a dedicated

View file

@ -81,7 +81,7 @@ public class CSharpScriptExecutor : IScriptExecutor
sw.Stop(); sw.Stop();
return Task.FromResult(new ExecutionResult(true, 0, string.Empty, string.Empty, output, null, sw.Elapsed)); return Task.FromResult(new ExecutionResult(true, 0, string.Empty, string.Empty, output, null, sw.Elapsed));
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
sw.Stop(); sw.Stop();
return Task.FromResult(new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed)); return Task.FromResult(new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed));

View file

@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text;
namespace w4c_workflows.Services.Execution; namespace w4c_workflows.Services.Execution;
@ -8,10 +9,14 @@ public sealed record ProcessOutput(int ExitCode, string Stdout, string Stderr);
/// <summary> /// <summary>
/// Runs a subprocess with stdin/stdout/stderr redirection, an optional JSON /// Runs a subprocess with stdin/stdout/stderr redirection, an optional JSON
/// stdin payload, extra environment variables, and a hard timeout. On timeout /// stdin payload, extra environment variables, and a hard timeout. On timeout
/// the whole process tree is killed. /// or external cancellation the whole process tree is always killed, and the
/// captured output is bounded so a chatty script cannot exhaust memory.
/// </summary> /// </summary>
public static class ProcessRunner public static class ProcessRunner
{ {
/// <summary>Per-stream capture ceiling. Extra output is drained and discarded.</summary>
internal const int MaxCapturedChars = 1024 * 1024;
public static async Task<ProcessOutput> RunAsync( public static async Task<ProcessOutput> RunAsync(
string executable, string executable,
IReadOnlyList<string> args, IReadOnlyList<string> args,
@ -44,9 +49,10 @@ public static class ProcessRunner
throw new InvalidOperationException($"failed to start '{executable}'"); throw new InvalidOperationException($"failed to start '{executable}'");
// Start draining stdout/stderr before writing stdin so a chatty child // Start draining stdout/stderr before writing stdin so a chatty child
// never blocks on a full pipe. // never blocks on a full pipe. Both readers drain without bound but keep
var stdoutTask = process.StandardOutput.ReadToEndAsync(); // only MaxCapturedChars in memory.
var stderrTask = process.StandardError.ReadToEndAsync(); var stdoutTask = ReadCappedAsync(process.StandardOutput, MaxCapturedChars);
var stderrTask = ReadCappedAsync(process.StandardError, MaxCapturedChars);
if (!string.IsNullOrEmpty(stdin)) if (!string.IsNullOrEmpty(stdin))
{ {
@ -80,17 +86,63 @@ public static class ProcessRunner
} }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{ {
try { process.Kill(entireProcessTree: true); } catch { /* already gone */ }
await stdoutTask;
await stderrTask;
throw new TimeoutException($"process exceeded the {timeout.TotalSeconds:0}s time limit"); throw new TimeoutException($"process exceeded the {timeout.TotalSeconds:0}s time limit");
} }
finally
{
// The wait throws on timeout AND on external cancellation; without
// this the child tree survived as an orphan whenever a run was
// cancelled or the host shut down. Reap it unconditionally, then
// drain the readers so they are never left unobserved.
if (!process.HasExited)
{
try { process.Kill(entireProcessTree: true); } catch { /* already gone */ }
// Reap the killed child so it does not linger as a zombie; bounded
// so a pathological kill cannot wedge the runner.
try { await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); }
catch { /* best effort */ }
}
await IgnoreFailureAsync(stdoutTask);
await IgnoreFailureAsync(stderrTask);
}
var stdout = await stdoutTask; var stdout = await stdoutTask;
var stderr = await stderrTask; var stderr = await stderrTask;
return new ProcessOutput(process.ExitCode, stdout, stderr); return new ProcessOutput(process.ExitCode, stdout, stderr);
} }
/// <summary>
/// Reads <paramref name="reader"/> to the end while capturing at most
/// <paramref name="maxChars"/> characters. The remainder is still drained so
/// the child never blocks on a full pipe, but it is not retained.
/// </summary>
public static async Task<string> ReadCappedAsync(TextReader reader, int maxChars, CancellationToken ct = default)
{
var buffer = new char[8192];
var captured = new StringBuilder(Math.Min(maxChars, 64 * 1024));
while (true)
{
var read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
if (read == 0)
break;
if (captured.Length >= maxChars)
continue; // over the cap — keep draining, discard
var take = Math.Min(read, maxChars - captured.Length);
captured.Append(buffer, 0, take);
}
return captured.ToString();
}
private static async Task IgnoreFailureAsync(Task<string> task)
{
try { await task; } catch { /* best-effort drain */ }
}
/// <summary> /// <summary>
/// Resolves an executable name against PATH (or a literal path) without /// Resolves an executable name against PATH (or a literal path) without
/// invoking it — a cheap availability probe. /// invoking it — a cheap availability probe.

View file

@ -46,7 +46,7 @@ public class SubprocessScriptExecutor : IScriptExecutor
return ExecutionHelpers.ToResult(output, sw.Elapsed); return ExecutionHelpers.ToResult(output, sw.Elapsed);
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
sw.Stop(); sw.Stop();
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed); return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);

View file

@ -63,7 +63,7 @@ public class TypeScriptExecutor : IScriptExecutor
return ExecutionHelpers.ToResult(run, sw.Elapsed); return ExecutionHelpers.ToResult(run, sw.Elapsed);
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
sw.Stop(); sw.Stop();
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed); return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);

View file

@ -196,7 +196,7 @@ public class WorkerHostService : BackgroundService
{ {
result = await executor.ExecuteAsync(invocation, ct); result = await executor.ExecuteAsync(invocation, ct);
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
result = new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed); result = new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);
} }

View file

@ -63,7 +63,7 @@ public sealed class CodeNodeExecutor : INodeExecutor
{ {
result = await executor.ExecuteAsync(invocation, ct); result = await executor.ExecuteAsync(invocation, ct);
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
return NodeExecutionOutcome.Failed(ex.Message, "script_failed"); return NodeExecutionOutcome.Failed(ex.Message, "script_failed");
} }

View file

@ -120,7 +120,7 @@ public sealed class NodeWorkflowRunner
result = await _runner.RunAsync( result = await _runner.RunAsync(
build.Graph, FlowItemJson.Parse(run.InputJson), environment, listener, ct); build.Graph, FlowItemJson.Parse(run.InputJson), environment, listener, ct);
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
_logger?.LogError(ex, "Node workflow run {RunId} threw", run.Id); _logger?.LogError(ex, "Node workflow run {RunId} threw", run.Id);
return await FailAsync(db, run, ex.Message, ct); return await FailAsync(db, run, ex.Message, ct);

View file

@ -96,6 +96,12 @@ public class RunLifecycleEngine
if (await DispatchRunAsync(db, dispatcher, run, ct)) if (await DispatchRunAsync(db, dispatcher, run, ct))
dispatched++; dispatched++;
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Shutdown mid-dispatch. A cancelled run did not fail, so do not
// mark it failed; leave it for the timeout sweep / next replica.
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to dispatch run {RunId} (tenant {TenantId})", run.Id, run.TenantId); _logger.LogError(ex, "Failed to dispatch run {RunId} (tenant {TenantId})", run.Id, run.TenantId);
@ -171,11 +177,19 @@ public class RunLifecycleEngine
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var workingDir = await dispatcher.ResolveWorkingDirAsync(run.TenantId, workflow.Path, ct); var workingDir = await dispatcher.ResolveWorkingDirAsync(run.TenantId, workflow.Path, ct);
await _nodeRunner.RunAsync(db, run, workflow, workingDir, ct);
// The runner owns the terminal run status; the dispatch lease is released try
// here for both success and failure so a finished run is immediately claimable. {
await _nodeRunner.RunAsync(db, run, workflow, workingDir, ct);
}
finally
{
// The runner owns the terminal run status; the dispatch lease is
// released here for success, failure AND cancellation so a cancelled
// run does not pin the lease for its whole TTL.
await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner); await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner);
}
return true; return true;
} }
@ -358,18 +372,33 @@ public class RunLifecycleEngine
foreach (var message in messages) foreach (var message in messages)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Ack only when the result was actually applied. Acking in a
// finally block turned a transient failure (e.g. a DB error)
// into permanent data loss: the message was removed while the
// run stayed stuck until its timeout. Leaving it pending lets
// the next tick re-claim and retry it (at-least-once).
var applied = false;
try try
{ {
await ProcessResultAsync(db, dispatcher, store, tenant, message, ct); await ProcessResultAsync(db, dispatcher, store, tenant, message, ct);
applied = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // shutdown in progress — do not ack, do not count
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to process result {MessageId} for tenant {TenantId}", message.Id, tenant); _logger.LogError(ex,
"Failed to process result {MessageId} for tenant {TenantId}; leaving it pending for redelivery",
message.Id, tenant);
} }
finally
{ if (!applied)
continue;
await _events.AckAsync(tenant, Streams.Results, message.Id, ct); await _events.AckAsync(tenant, Streams.Results, message.Id, ct);
}
processed++; processed++;
} }
} }

View file

@ -47,27 +47,46 @@ public class TriggerScheduler : BackgroundService
_logger.LogInformation("TriggerScheduler started (poll {Interval}s)", _pollSeconds); _logger.LogInformation("TriggerScheduler started (poll {Interval}s)", _pollSeconds);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_pollSeconds)); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_pollSeconds));
// First pass after a short warm-up so the app can serve initial requests.
try try
{ {
// First pass after a short warm-up so the app can serve initial requests.
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
await EvaluateAllAsync(stoppingToken);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await EvaluateAllAsync(stoppingToken);
}
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
// Graceful shutdown — the host is stopping. return; // graceful shutdown during warm-up
}
while (!stoppingToken.IsCancellationRequested)
{
// The try/catch must stay INSIDE the loop. If it wrapped the loop, a
// single throwing tick would end ExecuteAsync and silently kill every
// cron/interval trigger for the lifetime of the process (the host is
// configured with BackgroundServiceExceptionBehavior.Ignore).
try
{
await EvaluateAllAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // graceful shutdown — the host is stopping
} }
catch (Exception ex) catch (Exception ex)
{ {
// A transient env/DB error must never take the whole control plane // A transient env/DB error must never take the whole control plane
// down (HostOptions.BackgroundServiceExceptionBehavior defaults to // down. Log and keep the scheduler alive for the next tick.
// StopHost). Log and keep the scheduler alive for the next tick. _logger.LogError(ex, "TriggerScheduler tick failed; will retry on next tick");
_logger.LogError(ex, "TriggerScheduler loop failed; will retry on next tick"); }
try
{
if (!await timer.WaitForNextTickAsync(stoppingToken))
break;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
} }
} }

View file

@ -0,0 +1,97 @@
using System.Diagnostics;
using w4c_workflows.Services.Execution;
using Xunit;
namespace w4c_workflows.Tests;
/// <summary>
/// Regression tests for subprocess capture and reaping. The runner must bound
/// memory for chatty children and must never leave a child tree alive when the
/// caller's token is cancelled.
/// </summary>
public class ProcessRunnerTests
{
[Fact]
public async Task ReadCappedAsync_returns_everything_under_the_cap()
{
using var reader = new StringReader("hello world");
var captured = await ProcessRunner.ReadCappedAsync(reader, 100);
Assert.Equal("hello world", captured);
}
[Fact]
public async Task ReadCappedAsync_captures_at_most_the_cap_but_drains_the_reader()
{
// 10x the cap: the reader must be fully drained (so a child never blocks
// on a full pipe) while only the first `cap` characters are retained.
using var reader = new StringReader(new string('a', 1_000));
var captured = await ProcessRunner.ReadCappedAsync(reader, 100);
Assert.Equal(100, captured.Length);
Assert.Equal(new string('a', 100), captured);
}
[Fact]
public async Task RunAsync_throws_timeout_when_the_child_exceeds_the_limit()
{
if (!OperatingSystem.IsLinux())
return;
var ex = await Assert.ThrowsAsync<TimeoutException>(() => ProcessRunner.RunAsync(
"/bin/sh", new[] { "-c", "sleep 30" }, Path.GetTempPath(),
null, null, TimeSpan.FromMilliseconds(300), default));
Assert.Contains("time limit", ex.Message);
}
[Fact]
public async Task RunAsync_kills_the_child_when_cancelled_externally()
{
if (!OperatingSystem.IsLinux())
return;
var pidFile = Path.Combine(Path.GetTempPath(), $"w4c-proc-{Guid.NewGuid():N}.pid");
using var cts = new CancellationTokenSource();
var run = ProcessRunner.RunAsync(
"/bin/sh",
new[] { "-c", $"echo $$ > '{pidFile}'; exec sleep 30" },
Path.GetTempPath(),
null, null, TimeSpan.FromSeconds(30), cts.Token);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!File.Exists(pidFile) && DateTime.UtcNow < deadline)
await Task.Delay(20);
Assert.True(File.Exists(pidFile), "child never reported its pid");
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => run);
var pid = int.Parse(File.ReadAllText(pidFile).Trim());
var gone = false;
for (var i = 0; i < 100 && !gone; i++)
{
gone = !IsAlive(pid);
if (!gone)
await Task.Delay(50);
}
Assert.True(gone, $"child process {pid} survived external cancellation");
}
private static bool IsAlive(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
return false;
}
}
}