mirror of
https://github.com/actions/runner.git
synced 2025-12-28 04:17:51 +08:00
GitHub Actions Runner
This commit is contained in:
203
src/Runner.Worker/Handlers/ContainerActionHandler.cs
Normal file
203
src/Runner.Worker/Handlers/ContainerActionHandler.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using System.Linq;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
[ServiceLocator(Default = typeof(ContainerActionHandler))]
|
||||
public interface IContainerActionHandler : IHandler
|
||||
{
|
||||
ContainerActionExecutionData Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ContainerActionHandler : Handler, IContainerActionHandler
|
||||
{
|
||||
public ContainerActionExecutionData Data { get; set; }
|
||||
|
||||
public async Task RunAsync(ActionRunStage stage)
|
||||
{
|
||||
// Validate args.
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(Data, nameof(Data));
|
||||
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
|
||||
|
||||
#if OS_WINDOWS || OS_OSX
|
||||
throw new NotSupportedException($"Container action is only supported on Linux");
|
||||
#else
|
||||
// Update the env dictionary.
|
||||
AddInputsToEnvironment();
|
||||
|
||||
var dockerManger = HostContext.GetService<IDockerCommandManager>();
|
||||
|
||||
// container image haven't built/pull
|
||||
if (Data.Image.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Data.Image = Data.Image.Substring("docker://".Length);
|
||||
}
|
||||
else if (Data.Image.EndsWith("Dockerfile") || Data.Image.EndsWith("dockerfile"))
|
||||
{
|
||||
// ensure docker file exist
|
||||
var dockerFile = Path.Combine(ActionDirectory, Data.Image);
|
||||
ArgUtil.File(dockerFile, nameof(Data.Image));
|
||||
ExecutionContext.Output($"Dockerfile for action: '{dockerFile}'.");
|
||||
|
||||
var imageName = $"{dockerManger.DockerInstanceLabel}:{ExecutionContext.Id.ToString("N")}";
|
||||
var buildExitCode = await dockerManger.DockerBuild(ExecutionContext, ExecutionContext.GetGitHubContext("workspace"), Directory.GetParent(dockerFile).FullName, imageName);
|
||||
if (buildExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Docker build failed with exit code {buildExitCode}");
|
||||
}
|
||||
|
||||
Data.Image = imageName;
|
||||
}
|
||||
|
||||
// run container
|
||||
var container = new ContainerInfo()
|
||||
{
|
||||
ContainerImage = Data.Image,
|
||||
ContainerName = ExecutionContext.Id.ToString("N"),
|
||||
ContainerDisplayName = $"{Pipelines.Validation.NameValidation.Sanitize(Data.Image)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}",
|
||||
};
|
||||
|
||||
if (stage == ActionRunStage.Main)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Data.EntryPoint))
|
||||
{
|
||||
// use entrypoint from action.yml
|
||||
container.ContainerEntryPoint = Data.EntryPoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
// use entrypoint input, this is for action v1 which doesn't have action.yml
|
||||
container.ContainerEntryPoint = Inputs.GetValueOrDefault("entryPoint");
|
||||
}
|
||||
}
|
||||
else if (stage == ActionRunStage.Post)
|
||||
{
|
||||
container.ContainerEntryPoint = Data.Cleanup;
|
||||
}
|
||||
|
||||
// create inputs context for template evaluation
|
||||
var inputsContext = new DictionaryContextData();
|
||||
if (this.Inputs != null)
|
||||
{
|
||||
foreach (var input in Inputs)
|
||||
{
|
||||
inputsContext.Add(input.Key, new StringContextData(input.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var evaluateContext = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
|
||||
evaluateContext["inputs"] = inputsContext;
|
||||
|
||||
var manifestManager = HostContext.GetService<IActionManifestManager>();
|
||||
if (Data.Arguments != null)
|
||||
{
|
||||
container.ContainerEntryPointArgs = "";
|
||||
var evaluatedArgs = manifestManager.EvaluateContainerArguments(ExecutionContext, Data.Arguments, evaluateContext);
|
||||
foreach (var arg in evaluatedArgs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(arg))
|
||||
{
|
||||
container.ContainerEntryPointArgs = container.ContainerEntryPointArgs + $" \"{arg.Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
container.ContainerEntryPointArgs = container.ContainerEntryPointArgs + " \"\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
container.ContainerEntryPointArgs = Inputs.GetValueOrDefault("args");
|
||||
}
|
||||
|
||||
if (Data.Environment != null)
|
||||
{
|
||||
var evaluatedEnv = manifestManager.EvaluateContainerEnvironment(ExecutionContext, Data.Environment, evaluateContext);
|
||||
foreach (var env in evaluatedEnv)
|
||||
{
|
||||
if (!this.Environment.ContainsKey(env.Key))
|
||||
{
|
||||
this.Environment[env.Key] = env.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ExecutionContext.JobContext.Container.TryGetValue("network", out var networkContextData) && networkContextData is StringContextData networkStringData)
|
||||
{
|
||||
container.ContainerNetwork = networkStringData.ToString();
|
||||
}
|
||||
|
||||
var defaultWorkingDirectory = ExecutionContext.GetGitHubContext("workspace");
|
||||
var tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
|
||||
|
||||
ArgUtil.NotNullOrEmpty(defaultWorkingDirectory, nameof(defaultWorkingDirectory));
|
||||
ArgUtil.NotNullOrEmpty(tempDirectory, nameof(tempDirectory));
|
||||
|
||||
var tempHomeDirectory = Path.Combine(tempDirectory, "_github_home");
|
||||
Directory.CreateDirectory(tempHomeDirectory);
|
||||
this.Environment["HOME"] = tempHomeDirectory;
|
||||
|
||||
var tempWorkflowDirectory = Path.Combine(tempDirectory, "_github_workflow");
|
||||
ArgUtil.Directory(tempWorkflowDirectory, nameof(tempWorkflowDirectory));
|
||||
|
||||
container.MountVolumes.Add(new MountVolume("/var/run/docker.sock", "/var/run/docker.sock"));
|
||||
container.MountVolumes.Add(new MountVolume(tempHomeDirectory, "/github/home"));
|
||||
container.MountVolumes.Add(new MountVolume(tempWorkflowDirectory, "/github/workflow"));
|
||||
container.MountVolumes.Add(new MountVolume(defaultWorkingDirectory, "/github/workspace"));
|
||||
|
||||
container.AddPathTranslateMapping(tempHomeDirectory, "/github/home");
|
||||
container.AddPathTranslateMapping(tempWorkflowDirectory, "/github/workflow");
|
||||
container.AddPathTranslateMapping(defaultWorkingDirectory, "/github/workspace");
|
||||
|
||||
container.ContainerWorkDirectory = "/github/workspace";
|
||||
|
||||
// expose context to environment
|
||||
foreach (var context in ExecutionContext.ExpressionValues)
|
||||
{
|
||||
if (context.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
|
||||
{
|
||||
foreach (var env in runtimeContext.GetRuntimeEnvironmentVariables())
|
||||
{
|
||||
Environment[env.Key] = env.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Actions Runtime server info
|
||||
var systemConnection = ExecutionContext.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
|
||||
Environment["ACTIONS_RUNTIME_URL"] = systemConnection.Url.AbsoluteUri;
|
||||
Environment["ACTIONS_RUNTIME_TOKEN"] = systemConnection.Authorization.Parameters[EndpointAuthorizationParameters.AccessToken];
|
||||
if (systemConnection.Data.TryGetValue("CacheServerUrl", out var cacheUrl) && !string.IsNullOrEmpty(cacheUrl))
|
||||
{
|
||||
Environment["ACTIONS_CACHE_URL"] = cacheUrl;
|
||||
}
|
||||
|
||||
foreach (var variable in this.Environment)
|
||||
{
|
||||
container.ContainerEnvironmentVariables[variable.Key] = container.TranslateToContainerPath(variable.Value);
|
||||
}
|
||||
|
||||
using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
{
|
||||
var runExitCode = await dockerManger.DockerRun(ExecutionContext, container, stdoutManager.OnDataReceived, stderrManager.OnDataReceived);
|
||||
if (runExitCode != 0)
|
||||
{
|
||||
ExecutionContext.Error($"Docker run failed with exit code {runExitCode}");
|
||||
ExecutionContext.Result = TaskResult.Failed;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/Runner.Worker/Handlers/Handler.cs
Normal file
177
src/Runner.Worker/Handlers/Handler.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
public interface IHandler : IRunnerService
|
||||
{
|
||||
Pipelines.ActionStepDefinitionReference Action { get; set; }
|
||||
Dictionary<string, string> Environment { get; set; }
|
||||
IExecutionContext ExecutionContext { get; set; }
|
||||
Variables RuntimeVariables { get; set; }
|
||||
IStepHost StepHost { get; set; }
|
||||
Dictionary<string, string> Inputs { get; set; }
|
||||
string ActionDirectory { get; set; }
|
||||
Task RunAsync(ActionRunStage stage);
|
||||
void PrintActionDetails(ActionRunStage stage);
|
||||
}
|
||||
|
||||
public abstract class Handler : RunnerService
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
// In windows OS the maximum supported size of a environment variable value is 32k.
|
||||
// You can set environment variable greater then 32K, but that variable will not be able to read in node.exe.
|
||||
private const int _environmentVariableMaximumSize = 32766;
|
||||
#endif
|
||||
|
||||
protected IActionCommandManager ActionCommandManager { get; private set; }
|
||||
|
||||
public Pipelines.ActionStepDefinitionReference Action { get; set; }
|
||||
public Dictionary<string, string> Environment { get; set; }
|
||||
public Variables RuntimeVariables { get; set; }
|
||||
public IExecutionContext ExecutionContext { get; set; }
|
||||
public IStepHost StepHost { get; set; }
|
||||
public Dictionary<string, string> Inputs { get; set; }
|
||||
public string ActionDirectory { get; set; }
|
||||
|
||||
public virtual void PrintActionDetails(ActionRunStage stage)
|
||||
{
|
||||
if (stage == ActionRunStage.Post)
|
||||
{
|
||||
ExecutionContext.Output($"Post job cleanup.");
|
||||
return;
|
||||
}
|
||||
|
||||
string groupName = "";
|
||||
if (Action.Type == Pipelines.ActionSourceType.ContainerRegistry)
|
||||
{
|
||||
var registryAction = Action as Pipelines.ContainerRegistryReference;
|
||||
groupName = $"Run docker://{registryAction.Image}";
|
||||
}
|
||||
else if (Action.Type == Pipelines.ActionSourceType.Repository)
|
||||
{
|
||||
var repoAction = Action as Pipelines.RepositoryPathReference;
|
||||
if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
groupName = $"Run {repoAction.Path}";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(repoAction.Path))
|
||||
{
|
||||
groupName = $"Run {repoAction.Name}@{repoAction.Ref}";
|
||||
}
|
||||
else
|
||||
{
|
||||
groupName = $"Run {repoAction.Name}/{repoAction.Path}@{repoAction.Ref}";
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// this should never happen
|
||||
Trace.Error($"Can't generate default folding group name for action {Action.Type.ToString()}");
|
||||
groupName = "Action details";
|
||||
}
|
||||
|
||||
ExecutionContext.Output($"##[group]{groupName}");
|
||||
|
||||
if (this.Inputs?.Count > 0)
|
||||
{
|
||||
ExecutionContext.Output("with:");
|
||||
foreach (var input in this.Inputs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(input.Value))
|
||||
{
|
||||
ExecutionContext.Output($" {input.Key}: {input.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.Environment?.Count > 0)
|
||||
{
|
||||
ExecutionContext.Output("env:");
|
||||
foreach (var env in this.Environment)
|
||||
{
|
||||
ExecutionContext.Output($" {env.Key}: {env.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
ExecutionContext.Output("##[endgroup]");
|
||||
}
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
ActionCommandManager = hostContext.CreateService<IActionCommandManager>();
|
||||
}
|
||||
|
||||
protected void AddInputsToEnvironment()
|
||||
{
|
||||
// Validate args.
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(Inputs, nameof(Inputs));
|
||||
|
||||
// Add the inputs to the environment variable dictionary.
|
||||
foreach (KeyValuePair<string, string> pair in Inputs)
|
||||
{
|
||||
AddEnvironmentVariable(
|
||||
key: $"INPUT_{pair.Key?.Replace(' ', '_').ToUpperInvariant()}",
|
||||
value: pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
protected void AddEnvironmentVariable(string key, string value)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(key, nameof(key));
|
||||
Trace.Verbose($"Setting env '{key}' to '{value}'.");
|
||||
|
||||
Environment[key] = value ?? string.Empty;
|
||||
|
||||
#if OS_WINDOWS
|
||||
if (Environment[key].Length > _environmentVariableMaximumSize)
|
||||
{
|
||||
ExecutionContext.Warning($"Environment variable '{key}' exceeds the maximum supported length. Environment variable length: {value.Length} , Maximum supported length: {_environmentVariableMaximumSize}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
protected void AddPrependPathToEnvironment()
|
||||
{
|
||||
// Validate args.
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(ExecutionContext.PrependPath, nameof(ExecutionContext.PrependPath));
|
||||
if (ExecutionContext.PrependPath.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepend path.
|
||||
string prepend = string.Join(Path.PathSeparator.ToString(), ExecutionContext.PrependPath.Reverse<string>());
|
||||
var containerStepHost = StepHost as ContainerStepHost;
|
||||
if (containerStepHost != null)
|
||||
{
|
||||
containerStepHost.PrependPath = prepend;
|
||||
}
|
||||
else
|
||||
{
|
||||
string taskEnvPATH;
|
||||
Environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
|
||||
string originalPath = RuntimeVariables.Get(Constants.PathVariable) ?? // Prefer a job variable.
|
||||
taskEnvPATH ?? // Then a task-environment variable.
|
||||
System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
|
||||
string.Empty;
|
||||
string newPath = PathUtil.PrependPath(prepend, originalPath);
|
||||
AddEnvironmentVariable(Constants.PathVariable, newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/Runner.Worker/Handlers/HandlerFactory.cs
Normal file
85
src/Runner.Worker/Handlers/HandlerFactory.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
[ServiceLocator(Default = typeof(HandlerFactory))]
|
||||
public interface IHandlerFactory : IRunnerService
|
||||
{
|
||||
IHandler Create(
|
||||
IExecutionContext executionContext,
|
||||
Pipelines.ActionStepDefinitionReference action,
|
||||
IStepHost stepHost,
|
||||
ActionExecutionData data,
|
||||
Dictionary<string, string> inputs,
|
||||
Dictionary<string, string> environment,
|
||||
Variables runtimeVariables,
|
||||
string actionDirectory);
|
||||
}
|
||||
|
||||
public sealed class HandlerFactory : RunnerService, IHandlerFactory
|
||||
{
|
||||
public IHandler Create(
|
||||
IExecutionContext executionContext,
|
||||
Pipelines.ActionStepDefinitionReference action,
|
||||
IStepHost stepHost,
|
||||
ActionExecutionData data,
|
||||
Dictionary<string, string> inputs,
|
||||
Dictionary<string, string> environment,
|
||||
Variables runtimeVariables,
|
||||
string actionDirectory)
|
||||
{
|
||||
// Validate args.
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
ArgUtil.NotNull(stepHost, nameof(stepHost));
|
||||
ArgUtil.NotNull(data, nameof(data));
|
||||
ArgUtil.NotNull(inputs, nameof(inputs));
|
||||
ArgUtil.NotNull(environment, nameof(environment));
|
||||
ArgUtil.NotNull(runtimeVariables, nameof(runtimeVariables));
|
||||
|
||||
// Create the handler.
|
||||
IHandler handler;
|
||||
if (data.ExecutionType == ActionExecutionType.Container)
|
||||
{
|
||||
handler = HostContext.CreateService<IContainerActionHandler>();
|
||||
(handler as IContainerActionHandler).Data = data as ContainerActionExecutionData;
|
||||
}
|
||||
else if (data.ExecutionType == ActionExecutionType.NodeJS)
|
||||
{
|
||||
handler = HostContext.CreateService<INodeScriptActionHandler>();
|
||||
(handler as INodeScriptActionHandler).Data = data as NodeJSActionExecutionData;
|
||||
}
|
||||
else if (data.ExecutionType == ActionExecutionType.Script)
|
||||
{
|
||||
handler = HostContext.CreateService<IScriptHandler>();
|
||||
(handler as IScriptHandler).Data = data as ScriptActionExecutionData;
|
||||
}
|
||||
else if (data.ExecutionType == ActionExecutionType.Plugin)
|
||||
{
|
||||
// Agent plugin
|
||||
handler = HostContext.CreateService<IRunnerPluginHandler>();
|
||||
(handler as IRunnerPluginHandler).Data = data as PluginActionExecutionData;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This should never happen.
|
||||
throw new NotSupportedException(data.ExecutionType.ToString());
|
||||
}
|
||||
|
||||
handler.Action = action;
|
||||
handler.Environment = environment;
|
||||
handler.RuntimeVariables = runtimeVariables;
|
||||
handler.ExecutionContext = executionContext;
|
||||
handler.StepHost = stepHost;
|
||||
handler.Inputs = inputs;
|
||||
handler.ActionDirectory = actionDirectory;
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/Runner.Worker/Handlers/NodeScriptActionHandler.cs
Normal file
134
src/Runner.Worker/Handlers/NodeScriptActionHandler.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
[ServiceLocator(Default = typeof(NodeScriptActionHandler))]
|
||||
public interface INodeScriptActionHandler : IHandler
|
||||
{
|
||||
NodeJSActionExecutionData Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class NodeScriptActionHandler : Handler, INodeScriptActionHandler
|
||||
{
|
||||
public NodeJSActionExecutionData Data { get; set; }
|
||||
|
||||
public async Task RunAsync(ActionRunStage stage)
|
||||
{
|
||||
// Validate args.
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(Data, nameof(Data));
|
||||
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
|
||||
ArgUtil.NotNull(Inputs, nameof(Inputs));
|
||||
ArgUtil.Directory(ActionDirectory, nameof(ActionDirectory));
|
||||
|
||||
// Update the env dictionary.
|
||||
AddInputsToEnvironment();
|
||||
AddPrependPathToEnvironment();
|
||||
|
||||
// expose context to environment
|
||||
foreach (var context in ExecutionContext.ExpressionValues)
|
||||
{
|
||||
if (context.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
|
||||
{
|
||||
foreach (var env in runtimeContext.GetRuntimeEnvironmentVariables())
|
||||
{
|
||||
Environment[env.Key] = env.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Actions Runtime server info
|
||||
var systemConnection = ExecutionContext.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
|
||||
Environment["ACTIONS_RUNTIME_URL"] = systemConnection.Url.AbsoluteUri;
|
||||
Environment["ACTIONS_RUNTIME_TOKEN"] = systemConnection.Authorization.Parameters[EndpointAuthorizationParameters.AccessToken];
|
||||
if (systemConnection.Data.TryGetValue("CacheServerUrl", out var cacheUrl) && !string.IsNullOrEmpty(cacheUrl))
|
||||
{
|
||||
Environment["ACTIONS_CACHE_URL"] = cacheUrl;
|
||||
}
|
||||
|
||||
// Resolve the target script.
|
||||
string target = null;
|
||||
if (stage == ActionRunStage.Main)
|
||||
{
|
||||
target = Data.Script;
|
||||
}
|
||||
else if (stage == ActionRunStage.Post)
|
||||
{
|
||||
target = Data.Cleanup;
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
target = Path.Combine(ActionDirectory, target);
|
||||
ArgUtil.File(target, nameof(target));
|
||||
|
||||
// Resolve the working directory.
|
||||
string workingDirectory = ExecutionContext.GetGitHubContext("workspace");
|
||||
if (string.IsNullOrEmpty(workingDirectory))
|
||||
{
|
||||
workingDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
}
|
||||
|
||||
var nodeRuntimeVersion = await StepHost.DetermineNodeRuntimeVersion(ExecutionContext);
|
||||
string file = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Externals), nodeRuntimeVersion, "bin", $"node{IOUtil.ExeExtension}");
|
||||
|
||||
// Format the arguments passed to node.
|
||||
// 1) Wrap the script file path in double quotes.
|
||||
// 2) Escape double quotes within the script file path. Double-quote is a valid
|
||||
// file name character on Linux.
|
||||
string arguments = StepHost.ResolvePathForStepHost(StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
|
||||
|
||||
#if OS_WINDOWS
|
||||
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
||||
Encoding outputEncoding = Encoding.UTF8;
|
||||
#else
|
||||
// Let .NET choose the default.
|
||||
Encoding outputEncoding = null;
|
||||
#endif
|
||||
|
||||
using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
{
|
||||
StepHost.OutputDataReceived += stdoutManager.OnDataReceived;
|
||||
StepHost.ErrorDataReceived += stderrManager.OnDataReceived;
|
||||
|
||||
// Execute the process. Exit code 0 should always be returned.
|
||||
// A non-zero exit code indicates infrastructural failure.
|
||||
// Task failure should be communicated over STDOUT using ## commands.
|
||||
Task<int> step = StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory),
|
||||
fileName: StepHost.ResolvePathForStepHost(file),
|
||||
arguments: arguments,
|
||||
environment: Environment,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: false,
|
||||
inheritConsoleHandler: !ExecutionContext.Variables.Retain_Default_Encoding,
|
||||
cancellationToken: ExecutionContext.CancellationToken);
|
||||
|
||||
// Wait for either the node exit or force finish through ##vso command
|
||||
await System.Threading.Tasks.Task.WhenAny(step, ExecutionContext.ForceCompleted);
|
||||
|
||||
if (ExecutionContext.ForceCompleted.IsCompleted)
|
||||
{
|
||||
ExecutionContext.Debug("The task was marked as \"done\", but the process has not closed after 5 seconds. Treating the task as complete.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var exitCode = await step;
|
||||
if (exitCode != 0)
|
||||
{
|
||||
ExecutionContext.Error($"Node run failed with exit code {exitCode}");
|
||||
ExecutionContext.Result = TaskResult.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
319
src/Runner.Worker/Handlers/OutputManager.cs
Normal file
319
src/Runner.Worker/Handlers/OutputManager.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using DTWebApi = GitHub.DistributedTask.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
public sealed class OutputManager : IDisposable
|
||||
{
|
||||
private const string _colorCodePrefix = "\033[";
|
||||
private const int _maxAttempts = 3;
|
||||
private const string _timeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT";
|
||||
private static readonly Regex _colorCodeRegex = new Regex(@"\x0033\[[0-9;]*m?", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private readonly IActionCommandManager _commandManager;
|
||||
private readonly IExecutionContext _executionContext;
|
||||
private readonly object _matchersLock = new object();
|
||||
private readonly TimeSpan _timeout;
|
||||
private IssueMatcher[] _matchers = Array.Empty<IssueMatcher>();
|
||||
|
||||
public OutputManager(IExecutionContext executionContext, IActionCommandManager commandManager)
|
||||
{
|
||||
//executionContext.Debug("ENTERING OutputManager ctor");
|
||||
_executionContext = executionContext;
|
||||
_commandManager = commandManager;
|
||||
|
||||
//_executionContext.Debug("OutputManager ctor - determine timeout from variable");
|
||||
// Determine the timeout
|
||||
var timeoutStr = _executionContext.Variables.Get(_timeoutKey);
|
||||
if (string.IsNullOrEmpty(timeoutStr) ||
|
||||
!TimeSpan.TryParse(timeoutStr, CultureInfo.InvariantCulture, out _timeout) ||
|
||||
_timeout <= TimeSpan.Zero)
|
||||
{
|
||||
//_executionContext.Debug("OutputManager ctor - determine timeout from env var");
|
||||
timeoutStr = Environment.GetEnvironmentVariable(_timeoutKey);
|
||||
if (string.IsNullOrEmpty(timeoutStr) ||
|
||||
!TimeSpan.TryParse(timeoutStr, CultureInfo.InvariantCulture, out _timeout) ||
|
||||
_timeout <= TimeSpan.Zero)
|
||||
{
|
||||
//_executionContext.Debug("OutputManager ctor - set timeout to default");
|
||||
_timeout = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
}
|
||||
|
||||
//_executionContext.Debug("OutputManager ctor - adding matchers");
|
||||
// Lock
|
||||
lock (_matchersLock)
|
||||
{
|
||||
//_executionContext.Debug("OutputManager ctor - adding OnMatcherChanged");
|
||||
_executionContext.Add(OnMatcherChanged);
|
||||
//_executionContext.Debug("OutputManager ctor - getting matchers");
|
||||
_matchers = _executionContext.GetMatchers().Select(x => new IssueMatcher(x, _timeout)).ToArray();
|
||||
}
|
||||
//_executionContext.Debug("LEAVING OutputManager ctor");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_executionContext.Remove(OnMatcherChanged);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void OnDataReceived(object sender, ProcessDataReceivedEventArgs e)
|
||||
{
|
||||
//_executionContext.Debug("ENTERING OutputManager OnDataReceived");
|
||||
var line = e.Data;
|
||||
|
||||
// ## commands
|
||||
if (!String.IsNullOrEmpty(line) &&
|
||||
(line.IndexOf(ActionCommand.Prefix) >= 0 || line.IndexOf(ActionCommand._commandKey) >= 0))
|
||||
{
|
||||
// This does not need to be inside of a critical section.
|
||||
// The logging queues and command handlers are thread-safe.
|
||||
if (_commandManager.TryProcessCommand(_executionContext, line))
|
||||
{
|
||||
//_executionContext.Debug("LEAVING OutputManager OnDataReceived - command processed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Problem matchers
|
||||
if (_matchers.Length > 0)
|
||||
{
|
||||
// Copy the reference
|
||||
var matchers = _matchers;
|
||||
|
||||
// Strip color codes
|
||||
var stripped = line.Contains(_colorCodePrefix) ? _colorCodeRegex.Replace(line, string.Empty) : line;
|
||||
|
||||
foreach (var matcher in matchers)
|
||||
{
|
||||
IssueMatch match = null;
|
||||
for (var attempt = 1; attempt <= _maxAttempts; attempt++)
|
||||
{
|
||||
// Match
|
||||
try
|
||||
{
|
||||
match = matcher.Match(stripped);
|
||||
|
||||
break;
|
||||
}
|
||||
catch (RegexMatchTimeoutException ex)
|
||||
{
|
||||
if (attempt < _maxAttempts)
|
||||
{
|
||||
// Debug
|
||||
_executionContext.Debug($"Timeout processing issue matcher '{matcher.Owner}' against line '{stripped}'. Exception: {ex.ToString()}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Warn
|
||||
_executionContext.Warning($"Removing issue matcher '{matcher.Owner}'. Matcher failed {_maxAttempts} times. Error: {ex.Message}");
|
||||
|
||||
// Remove
|
||||
Remove(matcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (match != null)
|
||||
{
|
||||
// Reset other matchers
|
||||
foreach (var otherMatcher in matchers.Where(x => !object.ReferenceEquals(x, matcher)))
|
||||
{
|
||||
otherMatcher.Reset();
|
||||
}
|
||||
|
||||
// Convert to issue
|
||||
var issue = ConvertToIssue(match);
|
||||
|
||||
if (issue != null)
|
||||
{
|
||||
// Log issue
|
||||
_executionContext.AddIssue(issue, stripped);
|
||||
|
||||
//_executionContext.Debug("LEAVING OutputManager OnDataReceived - issue logged");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular output
|
||||
_executionContext.Output(line);
|
||||
//_executionContext.Debug("LEAVING OutputManager OnDataReceived");
|
||||
}
|
||||
|
||||
private void OnMatcherChanged(object sender, MatcherChangedEventArgs e)
|
||||
{
|
||||
// Lock
|
||||
lock (_matchersLock)
|
||||
{
|
||||
var newMatchers = new List<IssueMatcher>();
|
||||
|
||||
// Prepend
|
||||
if (e.Config.Patterns.Length > 0)
|
||||
{
|
||||
newMatchers.Add(new IssueMatcher(e.Config, _timeout));
|
||||
}
|
||||
|
||||
// Add existing non-matching
|
||||
newMatchers.AddRange(_matchers.Where(x => !string.Equals(x.Owner, e.Config.Owner, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
// Store
|
||||
_matchers = newMatchers.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void Remove(IssueMatcher matcher)
|
||||
{
|
||||
// Lock
|
||||
lock (_matchersLock)
|
||||
{
|
||||
var newMatchers = new List<IssueMatcher>();
|
||||
|
||||
// Match by object reference, not by owner name
|
||||
newMatchers.AddRange(_matchers.Where(x => !object.ReferenceEquals(x, matcher)));
|
||||
|
||||
// Store
|
||||
_matchers = newMatchers.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private DTWebApi.Issue ConvertToIssue(IssueMatch match)
|
||||
{
|
||||
// Validate the message
|
||||
if (string.IsNullOrWhiteSpace(match.Message))
|
||||
{
|
||||
_executionContext.Debug("Skipping logging an issue for the matched line because the message is empty.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate the severity
|
||||
DTWebApi.IssueType issueType;
|
||||
if (string.IsNullOrEmpty(match.Severity) || string.Equals(match.Severity, "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issueType = DTWebApi.IssueType.Error;
|
||||
}
|
||||
else if (string.Equals(match.Severity, "warning", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issueType = DTWebApi.IssueType.Warning;
|
||||
}
|
||||
else
|
||||
{
|
||||
_executionContext.Debug($"Skipped logging an issue for the matched line because the severity '{match.Severity}' is not supported.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var issue = new DTWebApi.Issue
|
||||
{
|
||||
Message = match.Message,
|
||||
Type = issueType,
|
||||
};
|
||||
|
||||
// Line
|
||||
if (!string.IsNullOrEmpty(match.Line))
|
||||
{
|
||||
if (int.TryParse(match.Line, NumberStyles.None, CultureInfo.InvariantCulture, out var line))
|
||||
{
|
||||
issue.Data["line"] = line.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
_executionContext.Debug($"Unable to parse line number '{match.Line}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Column
|
||||
if (!string.IsNullOrEmpty(match.Column))
|
||||
{
|
||||
if (int.TryParse(match.Column, NumberStyles.None, CultureInfo.InvariantCulture, out var column))
|
||||
{
|
||||
issue.Data["col"] = column.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
_executionContext.Debug($"Unable to parse column number '{match.Column}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Code
|
||||
if (!string.IsNullOrWhiteSpace(match.Code))
|
||||
{
|
||||
issue.Data["code"] = match.Code.Trim();
|
||||
}
|
||||
|
||||
// File
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(match.File))
|
||||
{
|
||||
var file = match.File;
|
||||
|
||||
// Root using fromPath
|
||||
if (!string.IsNullOrWhiteSpace(match.FromPath) && !Path.IsPathRooted(file))
|
||||
{
|
||||
file = Path.Combine(match.FromPath, file);
|
||||
}
|
||||
|
||||
// Root using system.defaultWorkingDirectory
|
||||
if (!Path.IsPathRooted(file))
|
||||
{
|
||||
var githubContext = _executionContext.ExpressionValues["github"] as GitHubContext;
|
||||
ArgUtil.NotNull(githubContext, nameof(githubContext));
|
||||
var workspace = githubContext["workspace"].ToString();
|
||||
ArgUtil.NotNullOrEmpty(workspace, "workspace");
|
||||
|
||||
file = Path.Combine(workspace, file);
|
||||
}
|
||||
|
||||
// Normalize slashes
|
||||
file = file.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
// File exists
|
||||
if (File.Exists(file))
|
||||
{
|
||||
// Repository path
|
||||
var repositoryPath = _executionContext.GetGitHubContext("workspace");
|
||||
ArgUtil.NotNullOrEmpty(repositoryPath, nameof(repositoryPath));
|
||||
|
||||
// Normalize slashes
|
||||
repositoryPath = repositoryPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!file.StartsWith(repositoryPath, IOUtil.FilePathStringComparison))
|
||||
{
|
||||
// File is not under repo
|
||||
_executionContext.Debug($"Dropping file value '{file}'. Path is not under the repo.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// prefer `/` on all platforms
|
||||
issue.Data["file"] = file.Substring(repositoryPath.Length).TrimStart(Path.DirectorySeparatorChar).Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
// File does not exist
|
||||
else
|
||||
{
|
||||
_executionContext.Debug($"Dropping file value '{file}'. Path does not exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_executionContext.Debug($"Dropping file value '{match.File}' and fromPath value '{match.FromPath}'. Exception during validation: {ex.ToString()}");
|
||||
}
|
||||
|
||||
return issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Runner.Worker/Handlers/RunnerPluginHandler.cs
Normal file
58
src/Runner.Worker/Handlers/RunnerPluginHandler.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Common;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
[ServiceLocator(Default = typeof(RunnerPluginHandler))]
|
||||
public interface IRunnerPluginHandler : IHandler
|
||||
{
|
||||
PluginActionExecutionData Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RunnerPluginHandler : Handler, IRunnerPluginHandler
|
||||
{
|
||||
public PluginActionExecutionData Data { get; set; }
|
||||
|
||||
public async Task RunAsync(ActionRunStage stage)
|
||||
{
|
||||
// Validate args.
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(Data, nameof(Data));
|
||||
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
|
||||
ArgUtil.NotNull(Inputs, nameof(Inputs));
|
||||
|
||||
string plugin = null;
|
||||
if (stage == ActionRunStage.Main)
|
||||
{
|
||||
plugin = Data.Plugin;
|
||||
}
|
||||
else if (stage == ActionRunStage.Post)
|
||||
{
|
||||
plugin = Data.Cleanup;
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(plugin, nameof(plugin));
|
||||
|
||||
// Update the env dictionary.
|
||||
AddPrependPathToEnvironment();
|
||||
|
||||
// Make sure only particular task get run as runner plugin.
|
||||
var runnerPlugin = HostContext.GetService<IRunnerPluginManager>();
|
||||
using (var outputManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
{
|
||||
ActionCommandManager.EnablePluginInternalCommand();
|
||||
try
|
||||
{
|
||||
await runnerPlugin.RunPluginActionAsync(ExecutionContext, plugin, Inputs, Environment, RuntimeVariables, outputManager.OnDataReceived);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ActionCommandManager.DisablePluginInternalCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
src/Runner.Worker/Handlers/ScriptHandler.cs
Normal file
241
src/Runner.Worker/Handlers/ScriptHandler.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
[ServiceLocator(Default = typeof(ScriptHandler))]
|
||||
public interface IScriptHandler : IHandler
|
||||
{
|
||||
ScriptActionExecutionData Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScriptHandler : Handler, IScriptHandler
|
||||
{
|
||||
public ScriptActionExecutionData Data { get; set; }
|
||||
|
||||
public override void PrintActionDetails(ActionRunStage stage)
|
||||
{
|
||||
if (stage == ActionRunStage.Post)
|
||||
{
|
||||
throw new NotSupportedException("Script action should not have 'Post' job action.");
|
||||
}
|
||||
|
||||
Inputs.TryGetValue("script", out string contents);
|
||||
contents = contents ?? string.Empty;
|
||||
if (Action.Type == Pipelines.ActionSourceType.Script)
|
||||
{
|
||||
var firstLine = contents.TrimStart(' ', '\t', '\r', '\n');
|
||||
var firstNewLine = firstLine.IndexOfAny(new[] { '\r', '\n' });
|
||||
if (firstNewLine >= 0)
|
||||
{
|
||||
firstLine = firstLine.Substring(0, firstNewLine);
|
||||
}
|
||||
|
||||
ExecutionContext.Output($"##[group]Run {firstLine}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid action type {Action.Type} for {nameof(ScriptHandler)}");
|
||||
}
|
||||
|
||||
var multiLines = contents.Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
|
||||
foreach (var line in multiLines)
|
||||
{
|
||||
// Bright Cyan color
|
||||
ExecutionContext.Output($"\x1b[36;1m{line}\x1b[0m");
|
||||
}
|
||||
|
||||
string argFormat;
|
||||
string shellCommand;
|
||||
string shellCommandPath = null;
|
||||
bool validateShellOnHost = !(StepHost is ContainerStepHost);
|
||||
Inputs.TryGetValue("shell", out var shell);
|
||||
if (string.IsNullOrEmpty(shell))
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
shellCommand = "cmd";
|
||||
if(validateShellOnHost)
|
||||
{
|
||||
shellCommandPath = System.Environment.GetEnvironmentVariable("ComSpec");
|
||||
}
|
||||
#else
|
||||
shellCommand = "sh";
|
||||
if (validateShellOnHost)
|
||||
{
|
||||
shellCommandPath = WhichUtil.Which("bash") ?? WhichUtil.Which("sh", true, Trace);
|
||||
}
|
||||
#endif
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parsed = ScriptHandlerHelpers.ParseShellOptionString(shell);
|
||||
shellCommand = parsed.shellCommand;
|
||||
if (validateShellOnHost)
|
||||
{
|
||||
shellCommandPath = WhichUtil.Which(parsed.shellCommand, true, Trace);
|
||||
}
|
||||
|
||||
argFormat = $"{parsed.shellArgs}".TrimStart();
|
||||
if (string.IsNullOrEmpty(argFormat))
|
||||
{
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(shellCommandPath))
|
||||
{
|
||||
ExecutionContext.Output($"shell: {shellCommandPath} {argFormat}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ExecutionContext.Output($"shell: {shellCommand} {argFormat}");
|
||||
}
|
||||
|
||||
if (this.Environment?.Count > 0)
|
||||
{
|
||||
ExecutionContext.Output("env:");
|
||||
foreach (var env in this.Environment)
|
||||
{
|
||||
ExecutionContext.Output($" {env.Key}: {env.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
ExecutionContext.Output("##[endgroup]");
|
||||
}
|
||||
|
||||
public async Task RunAsync(ActionRunStage stage)
|
||||
{
|
||||
if (stage == ActionRunStage.Post)
|
||||
{
|
||||
throw new NotSupportedException("Script action should not have 'Post' job action.");
|
||||
}
|
||||
|
||||
// Validate args
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
|
||||
ArgUtil.NotNull(Inputs, nameof(Inputs));
|
||||
|
||||
var githubContext = ExecutionContext.ExpressionValues["github"] as GitHubContext;
|
||||
ArgUtil.NotNull(githubContext, nameof(githubContext));
|
||||
|
||||
var tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
|
||||
|
||||
Inputs.TryGetValue("script", out var contents);
|
||||
contents = contents ?? string.Empty;
|
||||
|
||||
Inputs.TryGetValue("workingDirectory", out var workingDirectory);
|
||||
var workspaceDir = githubContext["workspace"] as StringContextData;
|
||||
workingDirectory = Path.Combine(workspaceDir, workingDirectory ?? string.Empty);
|
||||
|
||||
Inputs.TryGetValue("shell", out var shell);
|
||||
var isContainerStepHost = StepHost is ContainerStepHost;
|
||||
|
||||
string commandPath, argFormat, shellCommand;
|
||||
// Set up default command and arguments
|
||||
if (string.IsNullOrEmpty(shell))
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
shellCommand = "cmd";
|
||||
commandPath = System.Environment.GetEnvironmentVariable("ComSpec");
|
||||
ArgUtil.NotNullOrEmpty(commandPath, "%ComSpec%");
|
||||
#else
|
||||
shellCommand = "sh";
|
||||
commandPath = WhichUtil.Which("bash", false, Trace) ?? WhichUtil.Which("sh", true, Trace);
|
||||
#endif
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parsed = ScriptHandlerHelpers.ParseShellOptionString(shell);
|
||||
shellCommand = parsed.shellCommand;
|
||||
// For non-ContainerStepHost, the command must be located on the host by Which
|
||||
commandPath = WhichUtil.Which(parsed.shellCommand, !isContainerStepHost, Trace);
|
||||
argFormat = $"{parsed.shellArgs}".TrimStart();
|
||||
if (string.IsNullOrEmpty(argFormat))
|
||||
{
|
||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||
}
|
||||
}
|
||||
|
||||
// No arg format was given, shell must be a built-in
|
||||
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
|
||||
{
|
||||
throw new ArgumentException("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'");
|
||||
}
|
||||
|
||||
// We do not not the full path until we know what shell is being used, so that we can determine the file extension
|
||||
var scriptFilePath = Path.Combine(tempDirectory, $"{Guid.NewGuid()}{ScriptHandlerHelpers.GetScriptFileExtension(shellCommand)}");
|
||||
var resolvedScriptPath = $"{StepHost.ResolvePathForStepHost(scriptFilePath).Replace("\"", "\\\"")}";
|
||||
|
||||
// Format arg string with script path
|
||||
var arguments = string.Format(argFormat, resolvedScriptPath);
|
||||
|
||||
// Fix up and write the script
|
||||
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
||||
#if OS_WINDOWS
|
||||
// Normalize Windows line endings
|
||||
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
|
||||
var encoding = ExecutionContext.Variables.Retain_Default_Encoding && Console.InputEncoding.CodePage != 65001
|
||||
? Console.InputEncoding
|
||||
: new UTF8Encoding(false);
|
||||
#else
|
||||
// Don't add a BOM. It causes the script to fail on some operating systems (e.g. on Ubuntu 14).
|
||||
var encoding = new UTF8Encoding(false);
|
||||
#endif
|
||||
// Script is written to local path (ie host) but executed relative to the StepHost, which may be a container
|
||||
File.WriteAllText(scriptFilePath, contents, encoding);
|
||||
|
||||
// Prepend PATH
|
||||
AddPrependPathToEnvironment();
|
||||
|
||||
// expose context to environment
|
||||
foreach (var context in ExecutionContext.ExpressionValues)
|
||||
{
|
||||
if (context.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
|
||||
{
|
||||
foreach (var env in runtimeContext.GetRuntimeEnvironmentVariables())
|
||||
{
|
||||
Environment[env.Key] = env.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dump out the command
|
||||
var fileName = isContainerStepHost ? shellCommand : commandPath;
|
||||
ExecutionContext.Debug($"{fileName} {arguments}");
|
||||
|
||||
using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager))
|
||||
{
|
||||
StepHost.OutputDataReceived += stdoutManager.OnDataReceived;
|
||||
StepHost.ErrorDataReceived += stderrManager.OnDataReceived;
|
||||
|
||||
// Execute
|
||||
int exitCode = await StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory),
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: Environment,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: null,
|
||||
killProcessOnCancel: false,
|
||||
inheritConsoleHandler: !ExecutionContext.Variables.Retain_Default_Encoding,
|
||||
cancellationToken: ExecutionContext.CancellationToken);
|
||||
|
||||
// Error
|
||||
if (exitCode != 0)
|
||||
{
|
||||
ExecutionContext.Error($"Process completed with exit code {exitCode}.");
|
||||
ExecutionContext.Result = TaskResult.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs
Normal file
83
src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
internal class ScriptHandlerHelpers
|
||||
{
|
||||
private static readonly Dictionary<string, string> _defaultArguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cmd"] = "/D /E:ON /V:OFF /S /C \"CALL \"{0}\"\"",
|
||||
["pwsh"] = "-command \". '{0}'\"",
|
||||
["powershell"] = "-command \". '{0}'\"",
|
||||
["bash"] = "--noprofile --norc -e -o pipefail {0}",
|
||||
["sh"] = "-e {0}",
|
||||
["python"] = "{0}"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> _extensions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cmd"] = ".cmd",
|
||||
["pwsh"] = ".ps1",
|
||||
["powershell"] = ".ps1",
|
||||
["bash"] = ".sh",
|
||||
["sh"] = ".sh",
|
||||
["python"] = ".py"
|
||||
};
|
||||
|
||||
internal static string GetScriptArgumentsFormat(string scriptType)
|
||||
{
|
||||
if (_defaultArguments.TryGetValue(scriptType, out var argFormat))
|
||||
{
|
||||
return argFormat;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
internal static string GetScriptFileExtension(string scriptType)
|
||||
{
|
||||
if (_extensions.TryGetValue(scriptType, out var extension))
|
||||
{
|
||||
return extension;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
internal static string FixUpScriptContents(string scriptType, string contents)
|
||||
{
|
||||
switch (scriptType)
|
||||
{
|
||||
case "cmd":
|
||||
// Note, use @echo off instead of using the /Q command line switch.
|
||||
// When /Q is used, echo can't be turned on.
|
||||
contents = $"@echo off{Environment.NewLine}{contents}";
|
||||
break;
|
||||
case "powershell":
|
||||
case "pwsh":
|
||||
var prepend = "$ErrorActionPreference = 'stop'";
|
||||
var append = @"if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }";
|
||||
contents = $"{prepend}{Environment.NewLine}{contents}{Environment.NewLine}{append}";
|
||||
break;
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
internal static (string shellCommand, string shellArgs) ParseShellOptionString(string shellOption)
|
||||
{
|
||||
var shellStringParts = shellOption.Split(" ", 2);
|
||||
if (shellStringParts.Length == 2)
|
||||
{
|
||||
return (shellCommand: shellStringParts[0], shellArgs: shellStringParts[1]);
|
||||
}
|
||||
else if (shellStringParts.Length == 1)
|
||||
{
|
||||
return (shellCommand: shellStringParts[0], shellArgs: "");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException($"Failed to parse COMMAND [..ARGS] from {shellOption}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
236
src/Runner.Worker/Handlers/StepHost.cs
Normal file
236
src/Runner.Worker/Handlers/StepHost.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Services.WebApi;
|
||||
using Newtonsoft.Json;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System.Linq;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
public interface IStepHost : IRunnerService
|
||||
{
|
||||
event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
string ResolvePathForStepHost(string path);
|
||||
|
||||
Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext);
|
||||
|
||||
Task<int> ExecuteAsync(string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
bool inheritConsoleHandler,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(ContainerStepHost))]
|
||||
public interface IContainerStepHost : IStepHost
|
||||
{
|
||||
ContainerInfo Container { get; set; }
|
||||
string PrependPath { get; set; }
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(DefaultStepHost))]
|
||||
public interface IDefaultStepHost : IStepHost
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class DefaultStepHost : RunnerService, IDefaultStepHost
|
||||
{
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
public string ResolvePathForStepHost(string path)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
public Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext)
|
||||
{
|
||||
return Task.FromResult<string>("node12");
|
||||
}
|
||||
|
||||
public async Task<int> ExecuteAsync(string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
bool inheritConsoleHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
processInvoker.OutputDataReceived += OutputDataReceived;
|
||||
processInvoker.ErrorDataReceived += ErrorDataReceived;
|
||||
|
||||
return await processInvoker.ExecuteAsync(workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: null,
|
||||
inheritConsoleHandler: inheritConsoleHandler,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ContainerStepHost : RunnerService, IContainerStepHost
|
||||
{
|
||||
public ContainerInfo Container { get; set; }
|
||||
public string PrependPath { get; set; }
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
public string ResolvePathForStepHost(string path)
|
||||
{
|
||||
// make sure container exist.
|
||||
ArgUtil.NotNull(Container, nameof(Container));
|
||||
ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId));
|
||||
|
||||
// remove double quotes around the path
|
||||
path = path.Trim('\"');
|
||||
|
||||
// try to resolve path inside container if the request path is part of the mount volume
|
||||
#if OS_WINDOWS
|
||||
if (Container.MountVolumes.Exists(x => path.StartsWith(x.SourceVolumePath, StringComparison.OrdinalIgnoreCase)))
|
||||
#else
|
||||
if (Container.MountVolumes.Exists(x => path.StartsWith(x.SourceVolumePath)))
|
||||
#endif
|
||||
{
|
||||
return Container.TranslateToContainerPath(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext)
|
||||
{
|
||||
// Best effort to determine a compatible node runtime
|
||||
// There may be more variation in which libraries are linked than just musl/glibc,
|
||||
// so determine based on known distribtutions instead
|
||||
var osReleaseIdCmd = "sh -c \"cat /etc/*release | grep ^ID\"";
|
||||
var dockerManager = HostContext.GetService<IDockerCommandManager>();
|
||||
|
||||
var output = new List<string>();
|
||||
var execExitCode = await dockerManager.DockerExec(executionContext, Container.ContainerId, string.Empty, osReleaseIdCmd, output);
|
||||
string nodeExternal;
|
||||
if (execExitCode == 0)
|
||||
{
|
||||
foreach (var line in output)
|
||||
{
|
||||
executionContext.Debug(line);
|
||||
if (line.ToLower().Contains("alpine"))
|
||||
{
|
||||
nodeExternal = "node12_alpine";
|
||||
executionContext.Output($"Container distribution is alpine. Running JavaScript Action with external tool: {nodeExternal}");
|
||||
return nodeExternal;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Optimistically use the default
|
||||
nodeExternal = "node12";
|
||||
executionContext.Output($"Running JavaScript Action with default external tool: {nodeExternal}");
|
||||
return nodeExternal;
|
||||
}
|
||||
|
||||
public async Task<int> ExecuteAsync(string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
bool inheritConsoleHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// make sure container exist.
|
||||
ArgUtil.NotNull(Container, nameof(Container));
|
||||
ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId));
|
||||
|
||||
var dockerManager = HostContext.GetService<IDockerCommandManager>();
|
||||
string dockerClientPath = dockerManager.DockerPath;
|
||||
|
||||
// Usage: docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
|
||||
IList<string> dockerCommandArgs = new List<string>();
|
||||
dockerCommandArgs.Add($"exec");
|
||||
|
||||
// [OPTIONS]
|
||||
dockerCommandArgs.Add($"-i");
|
||||
dockerCommandArgs.Add($"--workdir {workingDirectory}");
|
||||
foreach (var env in environment)
|
||||
{
|
||||
// e.g. -e MY_SECRET maps the value into the exec'ed process without exposing
|
||||
// the value directly in the command
|
||||
dockerCommandArgs.Add($"-e {env.Key}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(PrependPath))
|
||||
{
|
||||
// Prepend tool paths to container's PATH
|
||||
var fullPath = !string.IsNullOrEmpty(Container.ContainerRuntimePath) ? $"{PrependPath}:{Container.ContainerRuntimePath}" : PrependPath;
|
||||
dockerCommandArgs.Add($"-e PATH=\"{fullPath}\"");
|
||||
}
|
||||
|
||||
// CONTAINER
|
||||
dockerCommandArgs.Add($"{Container.ContainerId}");
|
||||
|
||||
// COMMAND
|
||||
dockerCommandArgs.Add(fileName);
|
||||
|
||||
// [ARG...]
|
||||
dockerCommandArgs.Add(arguments);
|
||||
|
||||
string dockerCommandArgstring = string.Join(" ", dockerCommandArgs);
|
||||
|
||||
// make sure all env are using container path
|
||||
foreach (var envKey in environment.Keys.ToList())
|
||||
{
|
||||
environment[envKey] = this.Container.TranslateToContainerPath(environment[envKey]);
|
||||
}
|
||||
|
||||
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
processInvoker.OutputDataReceived += OutputDataReceived;
|
||||
processInvoker.ErrorDataReceived += ErrorDataReceived;
|
||||
|
||||
#if OS_WINDOWS
|
||||
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
||||
outputEncoding = Encoding.UTF8;
|
||||
#else
|
||||
// Let .NET choose the default.
|
||||
outputEncoding = null;
|
||||
#endif
|
||||
|
||||
return await processInvoker.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work),
|
||||
fileName: dockerClientPath,
|
||||
arguments: dockerCommandArgstring,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: null,
|
||||
inheritConsoleHandler: inheritConsoleHandler,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user