using System.Diagnostics;
namespace w4c_workflows.Services.Execution;
///
/// 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 stdout contract.
///
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 ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation);
if (!ExecutionHelpers.TryResolveEntryPath(invocation, out var entryPath, out var pathError))
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, pathError, sw.Elapsed);
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) when (ex is not OperationCanceledException)
{
sw.Stop();
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);
}
}
///
/// Chooses the interpreter for a shell script. A shell script may declare a
/// specific shell in its shebang (e.g. #!/usr/bin/env bash); running it
/// under the registered POSIX sh instead would reject bash-only syntax
/// such as set -o pipefail. 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.
///
private (string Executable, List Args) ResolveInterpreter(string entryPath)
{
var fallback = (_executable, new List());
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;
}