199 lines
8.8 KiB
C#
199 lines
8.8 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>
|
|
return $"mkdir -p {safeWorkDir} "
|
|
+ $"&& echo {ShQuote(b64)} | base64 -d > {ShQuote(remoteFile)} "
|
|
+ $"&& {interpreter} {ShQuote(remoteFile)}";
|
|
}
|
|
|
|
private static string? RemoteInterpreter(string language) => language switch
|
|
{
|
|
"shell" => "sh",
|
|
"javascript" => "node",
|
|
"typescript" => "node",
|
|
"python" => "python3",
|
|
"csharp" => null, // requires a compiler + runtime not assumed present on a target
|
|
_ => null,
|
|
};
|
|
|
|
private string ResolveLocalScriptPath(TaskInvocation invocation)
|
|
{
|
|
var workDir = ExecutionHelpers.ResolveWorkingDir(invocation);
|
|
return Path.Combine(workDir, invocation.EntryFile);
|
|
}
|
|
|
|
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; }
|
|
}
|
|
}
|