w4c-workflows-api/Services/Execution/RemoteServerExecutor.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

206 lines
9.6 KiB
C#

using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;
namespace w4c_workflows.Services.Execution;
/// <summary>
/// Executes a workflow task on a <em>remote</em> managed server instead of a local
/// subprocess (S8). When a task carries a <c>server</c> reference, this executor:
/// <list type="number">
/// <item>reads the task's entry script from the local worker working dir;</item>
/// <item>builds a shell command that writes the script to the target (base64, so the
/// script can never inject shell metacharacters) and runs it with the language's
/// interpreter;</item>
/// <item>POSTs it to w4c-webapi <c>/api/serverconsole/exec</c>, which resolves the
/// server via <c>ServerConnectionRegistry</c> and runs it over SSH with
/// <c>SshRunner</c> — the same path the /server console uses;</item>
/// <item>maps the remote stdout/stderr back into the standard
/// <see cref="ExecutionResult"/> contract.</item>
/// </list>
///
/// The local subprocess path stays the default: this executor is only used when
/// <see cref="TaskInvocation.Server"/> is non-empty. The w4c-webapi endpoint is
/// authenticated with the run's tenant id (<c>X-Tenant-Id</c>, the dev bypass) plus an
/// optional service token configured via <c>Workflows:ServerApiToken</c>.
/// </summary>
public sealed class RemoteServerExecutor
{
private readonly IHttpClientFactory _httpFactory;
private readonly string _serverApiUrl;
private readonly string _serverApiToken;
private readonly int _timeoutSeconds;
private readonly ILogger<RemoteServerExecutor> _logger;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public RemoteServerExecutor(IHttpClientFactory httpFactory, IConfiguration config, ILogger<RemoteServerExecutor> logger)
{
_httpFactory = httpFactory;
_serverApiUrl = (config["Workflows:ServerApiUrl"] ?? string.Empty).TrimEnd('/');
_serverApiToken = config["Workflows:ServerApiToken"] ?? string.Empty;
_timeoutSeconds = ParseInt(config["Workflows:ServerTaskTimeoutSeconds"], 120);
_logger = logger;
}
/// <summary>True when the w4c-webapi server-console URL is configured; otherwise remote execution is unavailable.</summary>
public bool IsAvailable() => !string.IsNullOrWhiteSpace(_serverApiUrl);
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
if (!IsAvailable())
return Fail($"remote execution is not configured (Workflows:ServerApiUrl is empty)", sw.Elapsed);
var entryFile = ResolveLocalScriptPath(invocation);
if (!File.Exists(entryFile))
return Fail($"entry script not found locally: {entryFile}", sw.Elapsed);
var script = await File.ReadAllTextAsync(entryFile, ct);
var command = BuildRemoteCommand(invocation, script, out var remoteErr);
if (remoteErr != null)
return Fail(remoteErr, sw.Elapsed);
var env = ExecutionHelpers.BuildEnvironment(invocation);
var request = new ServerExecRequestDto
{
ServerId = invocation.Server!,
Cwd = ExecutionHelpers.ResolveWorkingDir(invocation),
Command = command,
Env = env.ToDictionary(kv => kv.Key, kv => kv.Value),
TimeoutSeconds = _timeoutSeconds,
};
var body = JsonSerializer.Serialize(request, Json);
using var client = _httpFactory.CreateClient();
using var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{_serverApiUrl}/api/serverconsole/exec")
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
};
httpReq.Headers.TryAddWithoutValidation("X-Tenant-Id", invocation.TenantId);
if (!string.IsNullOrWhiteSpace(_serverApiToken))
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_serverApiToken}");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds + 15));
using var response = await client.SendAsync(httpReq, HttpCompletionOption.ResponseContentRead, cts.Token);
if (!response.IsSuccessStatusCode)
{
var errText = await response.Content.ReadAsStringAsync(ct);
return Fail($"server {invocation.Server} exec returned {(int)response.StatusCode}: {Truncate(errText)}", sw.Elapsed);
}
var result = await response.Content.ReadFromJsonAsync<ServerExecResultDto>(Json, ct)
?? new ServerExecResultDto { ExitCode = -1, Stderr = "empty exec response" };
// Map the remote result into the same ProcessOutput -> ExecutionResult
// contract the local executors use (last non-empty stdout line => JSON output).
var output = new ProcessOutput(result.ExitCode, result.Stdout ?? string.Empty, result.Stderr ?? string.Empty);
var mapped = ExecutionHelpers.ToResult(output, sw.Elapsed);
if (result.TimedOut)
return new ExecutionResult(false, result.ExitCode, output.Stdout, output.Stderr, null,
$"remote exec timed out after {_timeoutSeconds}s", sw.Elapsed);
return mapped;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
sw.Stop();
return Fail(ex.Message, sw.Elapsed);
}
finally
{
sw.Stop();
}
}
/// <summary>
/// Builds the remote shell command: write the script to the target via base64
/// (so content can never inject metacharacters) and run it with the interpreter.
/// </summary>
private static string BuildRemoteCommand(TaskInvocation invocation, string script, out string? error)
{
error = null;
var interpreter = RemoteInterpreter(invocation.Language);
if (interpreter == null)
{
error = $"language '{invocation.Language}' is not supported for remote (SSH) execution";
return string.Empty;
}
var workDir = ExecutionHelpers.ResolveWorkingDir(invocation);
var safeWorkDir = ShQuote(workDir);
var remoteFile = Path.Combine(workDir, $".w4c-run-{invocation.TaskId[..8]}-{invocation.Attempt}-{SafeName(invocation.EntryFile)}");
var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(script));
// mkdir -p <dir> && echo '<b64>' | base64 -d > <file> && {
// <interpreter> <file>; rc=$?; rm -f <file>; exit $rc; }
// The staged script is removed on every completion path (including a
// non-zero interpreter exit) instead of being left as .w4c-run-* litter;
// the exit code is still propagated. A hard timeout kills the ssh
// connection before this runs, so the local startup sweep cannot reach it —
// the next run overwrites the deterministic per-(task,attempt) name.
return $"mkdir -p {safeWorkDir} "
+ $"&& echo {ShQuote(b64)} | base64 -d > {ShQuote(remoteFile)} "
+ $"&& {{ {interpreter} {ShQuote(remoteFile)}; rc=$?; rm -f {ShQuote(remoteFile)}; exit $rc; }}";
}
// Only languages whose entry file can be shipped to the target as-is are
// mapped here. TypeScript is deliberately absent: the local path bundles the
// entry file (and its imports) with esbuild before running node, so sending
// raw .ts source to a remote `node` would always fail with a syntax error.
// Returning null yields an honest "not supported for remote" instead.
private static string? RemoteInterpreter(string language) => language switch
{
"shell" => "sh",
"javascript" => "node",
"python" => "python3",
"csharp" => null, // requires a compiler + runtime not assumed present on a target
_ => null,
};
private string ResolveLocalScriptPath(TaskInvocation invocation)
=> ExecutionHelpers.TryResolveEntryPath(invocation, out var path, out _) ? path : string.Empty;
private ExecutionResult Fail(string message, TimeSpan elapsed)
{
_logger.LogWarning("Remote task execution failed: {Message}", message);
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, message, elapsed);
}
/// <summary>Single-quotes a value for the remote sh -c evaluation (same semantics as SshRunner.ShellQuote).</summary>
private static string ShQuote(string value)
=> string.IsNullOrEmpty(value) ? "''" : "'" + value.Replace("'", "'\"'\"'") + "'";
private static string SafeName(string path)
{
var name = Path.GetFileName(path);
return string.IsNullOrWhiteSpace(name) ? "script" : name.Replace(' ', '_');
}
private static string Truncate(string text)
=> text.Length > 400 ? text[..400] + "…" : text;
private static int ParseInt(string? text, int fallback)
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
private sealed class ServerExecRequestDto
{
public string ServerId { get; set; } = string.Empty;
public string? Cwd { get; set; }
public string Command { get; set; } = string.Empty;
public Dictionary<string, string>? Env { get; set; }
public int? TimeoutSeconds { get; set; }
}
private sealed class ServerExecResultDto
{
public int ExitCode { get; set; }
public string? Stdout { get; set; }
public string? Stderr { get; set; }
public bool TimedOut { get; set; }
}
}