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