107 lines
3.9 KiB
C#
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;
|
|
}
|