using System.Diagnostics; using System.Net.Http.Json; using System.Text.Json; namespace w4c_workflows.Services.Execution; /// /// Executes a workflow task on a remote managed server instead of a local /// subprocess (S8). When a task carries a server reference, this executor: /// /// reads the task's entry script from the local worker working dir; /// 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; /// POSTs it to w4c-webapi /api/serverconsole/exec, which resolves the /// server via ServerConnectionRegistry and runs it over SSH with /// SshRunner — the same path the /server console uses; /// maps the remote stdout/stderr back into the standard /// contract. /// /// /// The local subprocess path stays the default: this executor is only used when /// is non-empty. The w4c-webapi endpoint is /// authenticated with the run's tenant id (X-Tenant-Id, the dev bypass) plus an /// optional service token configured via Workflows:ServerApiToken. /// public sealed class RemoteServerExecutor { private readonly IHttpClientFactory _httpFactory; private readonly string _serverApiUrl; private readonly string _serverApiToken; private readonly int _timeoutSeconds; private readonly ILogger _logger; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public RemoteServerExecutor(IHttpClientFactory httpFactory, IConfiguration config, ILogger logger) { _httpFactory = httpFactory; _serverApiUrl = (config["Workflows:ServerApiUrl"] ?? string.Empty).TrimEnd('/'); _serverApiToken = config["Workflows:ServerApiToken"] ?? string.Empty; _timeoutSeconds = ParseInt(config["Workflows:ServerTaskTimeoutSeconds"], 120); _logger = logger; } /// True when the w4c-webapi server-console URL is configured; otherwise remote execution is unavailable. public bool IsAvailable() => !string.IsNullOrWhiteSpace(_serverApiUrl); public async Task 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(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(); } } /// /// 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. /// 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 && echo '' | base64 -d > && { // ; rc=$?; rm -f ; 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); } /// Single-quotes a value for the remote sh -c evaluation (same semantics as SshRunner.ShellQuote). 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? 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; } } }