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; }
}
}