w4c-workflows-api/Services/Execution/SubprocessScriptExecutor.cs
Vitali sharp8n 9d601791ae fix
2026-09-11 15:06:06 +03:00

107 lines
3.9 KiB
C#

using System.Diagnostics;
namespace w4c_workflows.Services.Execution;
/// <summary>
/// Runs a language whose program is a single file executed by an interpreter
/// (shell → sh, python → python3, javascript → node). Input is written to stdin
/// as JSON; output follows the <see cref="ExecutionHelpers"/> stdout contract.
/// </summary>
public class SubprocessScriptExecutor : IScriptExecutor
{
private readonly string _language;
private readonly string _executable;
private readonly TimeSpan _timeout;
public SubprocessScriptExecutor(string language, string executable, IConfiguration config)
{
_language = language;
_executable = executable;
_timeout = TimeSpan.FromSeconds(ParseInt(config["Workflows:TaskTimeoutSeconds"], 60));
}
public string Language => _language;
public bool IsAvailable() => ProcessRunner.FindInPath(_executable) != null;
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation);
var entryPath = Path.Combine(workingDir, invocation.EntryFile);
var (executable, args) = ResolveInterpreter(entryPath);
args.Add(entryPath);
var output = await ProcessRunner.RunAsync(
executable,
args,
workingDir,
ExecutionHelpers.BuildEnvironment(invocation),
invocation.Input,
_timeout,
ct);
return ExecutionHelpers.ToResult(output, sw.Elapsed);
}
catch (Exception ex)
{
sw.Stop();
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);
}
}
/// <summary>
/// Chooses the interpreter for a shell script. A shell script may declare a
/// specific shell in its shebang (e.g. <c>#!/usr/bin/env bash</c>); running it
/// under the registered POSIX <c>sh</c> instead would reject bash-only syntax
/// such as <c>set -o pipefail</c>. When the shebang names an interpreter that
/// is available on PATH we honour it; otherwise (no shebang, unknown
/// interpreter) we fall back to the registered default so behaviour is
/// unchanged for plain POSIX scripts.
/// </summary>
private (string Executable, List<string> Args) ResolveInterpreter(string entryPath)
{
var fallback = (_executable, new List<string>());
if (!string.Equals(_language, "shell", StringComparison.Ordinal) || !File.Exists(entryPath))
return fallback;
try
{
string? firstLine;
using (var reader = new StreamReader(entryPath))
firstLine = reader.ReadLine();
if (firstLine is null || !firstLine.StartsWith("#!", StringComparison.Ordinal))
return fallback;
var parts = firstLine.Substring(2).Trim()
.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return fallback;
var candidate = parts[0];
var extra = parts.Skip(1).ToList();
// "#!/usr/bin/env bash" -> run "bash" (env resolves it via PATH).
if (string.Equals(Path.GetFileName(candidate), "env", StringComparison.Ordinal) && extra.Count > 0)
{
candidate = extra[0];
extra.RemoveAt(0);
}
var resolved = ProcessRunner.FindInPath(candidate);
return resolved is null ? fallback : (resolved, extra);
}
catch (IOException)
{
return fallback;
}
}
private static int ParseInt(string? text, int fallback)
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
}