diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index a40280a05..f7aca5158 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -151,6 +151,7 @@ namespace GitHub.Runner.Common public static readonly string DiskSpaceWarning = "runner.diskspace.warning"; public static readonly string Node12Warning = "DistributedTask.AddWarningToNode12Action"; public static readonly string UseContainerPathForTemplate = "DistributedTask.UseContainerPathForTemplate"; + public static readonly string AllowRunnerContainerHooks = "DistributedTask.AllowRunnerContainerHooks"; } public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry"; @@ -196,6 +197,7 @@ namespace GitHub.Runner.Common { public static readonly string JobStartedStepName = "Set up runner"; public static readonly string JobCompletedStepName = "Complete runner"; + public static readonly string ContainerHooksPath = "ACTIONS_RUNNER_CONTAINER_HOOKS"; } public static class Path diff --git a/src/Runner.Common/HostContext.cs b/src/Runner.Common/HostContext.cs index 6ad149428..e8eab1989 100644 --- a/src/Runner.Common/HostContext.cs +++ b/src/Runner.Common/HostContext.cs @@ -13,6 +13,7 @@ using System.Runtime.Loader; using System.Threading; using System.Threading.Tasks; using GitHub.DistributedTask.Logging; +using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; namespace GitHub.Runner.Common @@ -641,6 +642,31 @@ namespace GitHub.Runner.Common var handlerFactory = context.GetService(); return handlerFactory.CreateClientHandler(context.WebProxy); } + + public static string GetDefaultShellForScript(this IHostContext hostContext, string path, string prependPath) + { + var trace = hostContext.GetTrace(nameof(GetDefaultShellForScript)); + switch (Path.GetExtension(path)) + { + case ".sh": + // use 'sh' args but prefer bash + if (WhichUtil.Which("bash", false, trace, prependPath) != null) + { + return "bash"; + } + return "sh"; + case ".ps1": + if (WhichUtil.Which("pwsh", false, trace, prependPath) != null) + { + return "pwsh"; + } + return "powershell"; + case ".js": + return Path.Combine(hostContext.GetDirectory(WellKnownDirectory.Externals), NodeUtil.GetInternalNodeVersion(), "bin", $"node{IOUtil.ExeExtension}") + " {0}"; + default: + throw new ArgumentException($"{path} is not a valid path to a script. Make sure it ends in '.sh', '.ps1' or '.js'."); + } + } } public enum ShutdownReason diff --git a/src/Runner.Sdk/Util/IOUtil.cs b/src/Runner.Sdk/Util/IOUtil.cs index 521998a97..1c8a0cd50 100644 --- a/src/Runner.Sdk/Util/IOUtil.cs +++ b/src/Runner.Sdk/Util/IOUtil.cs @@ -424,6 +424,12 @@ namespace GitHub.Runner.Sdk throw new NotSupportedException($"Unable to validate execute permissions for directory '{directory}'. Exceeded maximum iterations."); } + public static void CreateEmptyFile(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, null); + } + /// /// Recursively enumerates a directory without following directory reparse points. /// diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 954d9a691..30e5c3577 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -101,38 +101,41 @@ namespace GitHub.Runner.Worker IEnumerable actions = steps.OfType(); executionContext.Output("Prepare all required actions"); var result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId); - if (state.ImagesToPull.Count > 0) + if (!FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) { - foreach (var imageToPull in result.ImagesToPull) + if (state.ImagesToPull.Count > 0) { - Trace.Info($"{imageToPull.Value.Count} steps need to pull image '{imageToPull.Key}'"); - containerSetupSteps.Add(new JobExtensionRunner(runAsync: this.PullActionContainerAsync, - condition: $"{PipelineTemplateConstants.Success}()", - displayName: $"Pull {imageToPull.Key}", - data: new ContainerSetupInfo(imageToPull.Value, imageToPull.Key))); + foreach (var imageToPull in result.ImagesToPull) + { + Trace.Info($"{imageToPull.Value.Count} steps need to pull image '{imageToPull.Key}'"); + containerSetupSteps.Add(new JobExtensionRunner(runAsync: this.PullActionContainerAsync, + condition: $"{PipelineTemplateConstants.Success}()", + displayName: $"Pull {imageToPull.Key}", + data: new ContainerSetupInfo(imageToPull.Value, imageToPull.Key))); + } } - } - if (result.ImagesToBuild.Count > 0) - { - foreach (var imageToBuild in result.ImagesToBuild) + if (result.ImagesToBuild.Count > 0) { - var setupInfo = result.ImagesToBuildInfo[imageToBuild.Key]; - Trace.Info($"{imageToBuild.Value.Count} steps need to build image from '{setupInfo.Dockerfile}'"); - containerSetupSteps.Add(new JobExtensionRunner(runAsync: this.BuildActionContainerAsync, - condition: $"{PipelineTemplateConstants.Success}()", - displayName: $"Build {setupInfo.ActionRepository}", - data: new ContainerSetupInfo(imageToBuild.Value, setupInfo.Dockerfile, setupInfo.WorkingDirectory))); + foreach (var imageToBuild in result.ImagesToBuild) + { + var setupInfo = result.ImagesToBuildInfo[imageToBuild.Key]; + Trace.Info($"{imageToBuild.Value.Count} steps need to build image from '{setupInfo.Dockerfile}'"); + containerSetupSteps.Add(new JobExtensionRunner(runAsync: this.BuildActionContainerAsync, + condition: $"{PipelineTemplateConstants.Success}()", + displayName: $"Build {setupInfo.ActionRepository}", + data: new ContainerSetupInfo(imageToBuild.Value, setupInfo.Dockerfile, setupInfo.WorkingDirectory))); + } } - } #if !OS_LINUX - if (containerSetupSteps.Count > 0) - { - executionContext.Output("Container action is only supported on Linux, skip pull and build docker images."); - containerSetupSteps.Clear(); - } + if (containerSetupSteps.Count > 0) + { + executionContext.Output("Container action is only supported on Linux, skip pull and build docker images."); + containerSetupSteps.Clear(); + } #endif + } return new PrepareResult(containerSetupSteps, result.PreStepTracker); } diff --git a/src/Runner.Worker/ActionRunner.cs b/src/Runner.Worker/ActionRunner.cs index 18b266e2c..1d18118a4 100644 --- a/src/Runner.Worker/ActionRunner.cs +++ b/src/Runner.Worker/ActionRunner.cs @@ -158,8 +158,12 @@ namespace GitHub.Runner.Worker // Setup container stephost for running inside the container. if (ExecutionContext.Global.Container != null) { - // Make sure required container is already created. - ArgUtil.NotNullOrEmpty(ExecutionContext.Global.Container.ContainerId, nameof(ExecutionContext.Global.Container.ContainerId)); + // Make sure the required container is already created + // Container hooks do not necessarily set 'ContainerId' + if (!FeatureManager.IsContainerHooksEnabled(ExecutionContext.Global.Variables)) + { + ArgUtil.NotNullOrEmpty(ExecutionContext.Global.Container.ContainerId, nameof(ExecutionContext.Global.Container.ContainerId)); + } var containerStepHost = HostContext.CreateService(); containerStepHost.Container = ExecutionContext.Global.Container; stepHost = containerStepHost; diff --git a/src/Runner.Worker/Container/ContainerHooks/ContainerHookManager.cs b/src/Runner.Worker/Container/ContainerHooks/ContainerHookManager.cs new file mode 100644 index 000000000..850fb8ebe --- /dev/null +++ b/src/Runner.Worker/Container/ContainerHooks/ContainerHookManager.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Handlers; +using GitHub.Services.WebApi; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Container.ContainerHooks +{ + [ServiceLocator(Default = typeof(ContainerHookManager))] + public interface IContainerHookManager : IRunnerService + { + Task PrepareJobAsync(IExecutionContext context, List containers); + Task RunContainerStepAsync(IExecutionContext context, ContainerInfo container, string dockerFile); + Task RunScriptStepAsync(IExecutionContext context, ContainerInfo container, string workingDirectory, string fileName, string arguments, IDictionary environment, string prependPath); + Task CleanupJobAsync(IExecutionContext context, List containers); + string GetContainerHookData(); + } + + public class ContainerHookManager : RunnerService, IContainerHookManager + { + private const string ResponseFolderName = "_runner_hook_responses"; + private string HookScriptPath; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + HookScriptPath = $"{Environment.GetEnvironmentVariable(Constants.Hooks.ContainerHooksPath)}"; + } + + public async Task PrepareJobAsync(IExecutionContext context, List containers) + { + Trace.Entering(); + var jobContainer = containers.Where(c => c.IsJobContainer).SingleOrDefault(); + var serviceContainers = containers.Where(c => !c.IsJobContainer).ToList(); + + var input = new HookInput + { + Command = HookCommand.PrepareJob, + ResponseFile = GenerateResponsePath(), + Args = new PrepareJobArgs + { + Container = jobContainer?.GetHookContainer(), + Services = serviceContainers.Select(c => c.GetHookContainer()).ToList(), + } + }; + + var prependPath = GetPrependPath(context); + var response = await ExecuteHookScript(context, input, ActionRunStage.Pre, prependPath); + if (jobContainer != null) + { + jobContainer.IsAlpine = response.IsAlpine.Value; + } + SaveHookState(context, response.State, input); + UpdateJobContext(context, jobContainer, serviceContainers, response); + } + + public async Task RunContainerStepAsync(IExecutionContext context, ContainerInfo container, string dockerFile) + { + Trace.Entering(); + var hookState = context.Global.ContainerHookState; + var containerStepArgs = new ContainerStepArgs(container); + if (!string.IsNullOrEmpty(dockerFile)) + { + containerStepArgs.Dockerfile = dockerFile; + containerStepArgs.Image = null; + } + var input = new HookInput + { + Args = containerStepArgs, + Command = HookCommand.RunContainerStep, + ResponseFile = GenerateResponsePath(), + State = hookState + }; + + var prependPath = GetPrependPath(context); + var response = await ExecuteHookScript(context, input, ActionRunStage.Pre, prependPath); + if (response == null) + { + return; + } + SaveHookState(context, response.State, input); + } + + public async Task RunScriptStepAsync(IExecutionContext context, ContainerInfo container, string workingDirectory, string entryPoint, string entryPointArgs, IDictionary environmentVariables, string prependPath) + { + Trace.Entering(); + var input = new HookInput + { + Command = HookCommand.RunScriptStep, + ResponseFile = GenerateResponsePath(), + Args = new ScriptStepArgs + { + EntryPointArgs = entryPointArgs.Split(' ').Select(arg => arg.Trim()), + EntryPoint = entryPoint, + EnvironmentVariables = environmentVariables, + PrependPath = prependPath, + WorkingDirectory = workingDirectory, + }, + State = context.Global.ContainerHookState + }; + + var response = await ExecuteHookScript(context, input, ActionRunStage.Pre, prependPath); + + if (response == null) + { + return; + } + SaveHookState(context, response.State, input); + } + + public async Task CleanupJobAsync(IExecutionContext context, List containers) + { + Trace.Entering(); + var input = new HookInput + { + Command = HookCommand.CleanupJob, + ResponseFile = GenerateResponsePath(), + Args = new CleanupJobArgs(), + State = context.Global.ContainerHookState + }; + var prependPath = GetPrependPath(context); + await ExecuteHookScript(context, input, ActionRunStage.Pre, prependPath); + } + + public string GetContainerHookData() + { + return JsonUtility.ToString(new { HookScriptPath }); + } + + private async Task ExecuteHookScript(IExecutionContext context, HookInput input, ActionRunStage stage, string prependPath) where T : HookResponse + { + try + { + ValidateHookExecutable(); + context.StepTelemetry.ContainerHookData = GetContainerHookData(); + var scriptDirectory = Path.GetDirectoryName(HookScriptPath); + var stepHost = HostContext.CreateService(); + + Dictionary inputs = new() + { + ["standardInInput"] = JsonUtility.ToString(input), + ["path"] = HookScriptPath, + ["shell"] = HostContext.GetDefaultShellForScript(HookScriptPath, prependPath) + }; + var handlerFactory = HostContext.GetService(); + var handler = handlerFactory.Create( + context, + null, + stepHost, + new ScriptActionExecutionData(), + inputs, + environment: new Dictionary(VarUtil.EnvironmentVariableKeyComparer), + context.Global.Variables, + actionDirectory: scriptDirectory, + localActionContainerSetupSteps: null) as ScriptHandler; + handler.PrepareExecution(stage); + + IOUtil.CreateEmptyFile(input.ResponseFile); + await handler.RunAsync(stage); + if (handler.ExecutionContext.Result == TaskResult.Failed) + { + throw new Exception($"The hook script at '{HookScriptPath}' running command '{input.Command}' did not execute successfully"); + } + var response = GetResponse(input); + return response; + } + catch (Exception ex) + { + Trace.Error(ex); + throw new Exception($"Custom container implementation failed with error: {ex.Message} Please contact your self hosted runner administrator.", ex); + } + } + + private string GenerateResponsePath() => Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), ResponseFolderName, $"{Guid.NewGuid()}.json"); + + private static string GetPrependPath(IExecutionContext context) => string.Join(Path.PathSeparator.ToString(), context.Global.PrependPath.Reverse()); + + private void ValidateHookExecutable() + { + if (!string.IsNullOrEmpty(HookScriptPath) && !File.Exists(HookScriptPath)) + { + throw new FileNotFoundException($"File not found at '{HookScriptPath}'. Set {Constants.Hooks.ContainerHooksPath} to the path of an existing file."); + } + + var supportedHookExtensions = new string[] { ".js", ".sh", ".ps1" }; + if (!supportedHookExtensions.Any(extension => HookScriptPath.EndsWith(extension))) + { + throw new ArgumentOutOfRangeException($"Invalid file extension at '{HookScriptPath}'. {Constants.Hooks.ContainerHooksPath} must be a path to a file with one of the following extensions: {string.Join(", ", supportedHookExtensions)}"); + } + } + + private T GetResponse(HookInput input) where T : HookResponse + { + if (!File.Exists(input.ResponseFile)) + { + Trace.Info($"Response file for the hook script at '{HookScriptPath}' running command '{input.Command}' not found."); + if (input.Args.IsRequireAlpineInResponse()) + { + throw new Exception($"Response file is required but not found for the hook script at '{HookScriptPath}' running command '{input.Command}'"); + } + return null; + } + + T response = IOUtil.LoadObject(input.ResponseFile); + Trace.Info($"Response file for the hook script at '{HookScriptPath}' running command '{input.Command}' was processed successfully"); + IOUtil.DeleteFile(input.ResponseFile); + Trace.Info($"Response file for the hook script at '{HookScriptPath}' running command '{input.Command}' was deleted successfully"); + if (response == null && input.Args.IsRequireAlpineInResponse()) + { + throw new Exception($"Response file could not be read at '{HookScriptPath}' running command '{input.Command}'"); + } + response?.Validate(input); + return response; + } + + private void SaveHookState(IExecutionContext context, JObject hookState, HookInput input) + { + if (hookState == null) + { + Trace.Info($"No 'state' property found in response file for '{input.Command}'. Global variable for 'ContainerHookState' will not be updated."); + return; + } + context.Global.ContainerHookState = hookState; + Trace.Info($"Global variable 'ContainerHookState' updated successfully for '{input.Command}' with data found in 'state' property of the response file."); + } + + private void UpdateJobContext(IExecutionContext context, ContainerInfo jobContainer, List serviceContainers, PrepareJobResponse response) + { + if (response.Context == null) + { + Trace.Info($"The response file does not contain a context. The fields 'jobContext.Container' and 'jobContext.Services' will not be set."); + return; + } + + var containerId = response.Context.Container?.Id; + if (containerId != null) + { + context.JobContext.Container["id"] = new StringContextData(containerId); + jobContainer.ContainerId = containerId; + } + + var containerNetwork = response.Context.Container?.Network; + if (containerNetwork != null) + { + context.JobContext.Container["network"] = new StringContextData(containerNetwork); + jobContainer.ContainerNetwork = containerNetwork; + } + + for (var i = 0; i < response.Context.Services.Count; i++) + { + var responseContainerInfo = response.Context.Services[i]; + var globalContainerInfo = serviceContainers[i]; + globalContainerInfo.ContainerId = responseContainerInfo.Id; + globalContainerInfo.ContainerNetwork = responseContainerInfo.Network; + + var service = new DictionaryContextData() + { + ["id"] = new StringContextData(responseContainerInfo.Id), + ["ports"] = new DictionaryContextData(), + ["network"] = new StringContextData(responseContainerInfo.Network) + }; + + globalContainerInfo.AddPortMappings(responseContainerInfo.Ports); + foreach (var portMapping in responseContainerInfo.Ports) + { + (service["ports"] as DictionaryContextData)[portMapping.Key] = new StringContextData(portMapping.Value); + } + context.JobContext.Services[globalContainerInfo.ContainerNetworkAlias] = service; + } + } + } +} diff --git a/src/Runner.Worker/Container/ContainerHooks/HookInput.cs b/src/Runner.Worker/Container/ContainerHooks/HookInput.cs new file mode 100644 index 000000000..41d897c55 --- /dev/null +++ b/src/Runner.Worker/Container/ContainerHooks/HookInput.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using System.Linq; + +namespace GitHub.Runner.Worker.Container.ContainerHooks +{ + public class HookInput + { + public HookCommand Command { get; set; } + public string ResponseFile { get; set; } + public IHookArgs Args { get; set; } + public JObject State { get; set; } + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum HookCommand + { + [EnumMember(Value = "prepare_job")] + PrepareJob, + [EnumMember(Value = "cleanup_job")] + CleanupJob, + [EnumMember(Value = "run_script_step")] + RunScriptStep, + [EnumMember(Value = "run_container_step")] + RunContainerStep, + } + public interface IHookArgs + { + bool IsRequireAlpineInResponse(); + } + + public class PrepareJobArgs : IHookArgs + { + public HookContainer Container { get; set; } + public IList Services { get; set; } + public bool IsRequireAlpineInResponse() => Container != null; + } + + public class ScriptStepArgs : IHookArgs + { + public IEnumerable EntryPointArgs { get; set; } + public string EntryPoint { get; set; } + public IDictionary EnvironmentVariables { get; set; } + public string PrependPath { get; set; } + public string WorkingDirectory { get; set; } + public bool IsRequireAlpineInResponse() => false; + } + + public class ContainerStepArgs : HookContainer, IHookArgs + { + public bool IsRequireAlpineInResponse() => false; + public ContainerStepArgs(ContainerInfo container) : base(container) { } + } + public class CleanupJobArgs : IHookArgs + { + public bool IsRequireAlpineInResponse() => false; + } + + public class ContainerRegistry + { + public string Username { get; set; } + public string Password { get; set; } + public string ServerUrl { get; set; } + } + + public class HookContainer + { + public string Image { get; set; } + public string Dockerfile { get; set; } + public IEnumerable EntryPointArgs { get; set; } = new List(); + public string EntryPoint { get; set; } + public string WorkingDirectory { get; set; } + public string CreateOptions { get; private set; } + public ContainerRegistry Registry { get; set; } + public IDictionary EnvironmentVariables { get; set; } = new Dictionary(); + public IEnumerable PortMappings { get; set; } = new List(); + public IEnumerable SystemMountVolumes { get; set; } = new List(); + public IEnumerable UserMountVolumes { get; set; } = new List(); + public HookContainer() { } // For Json deserializer + public HookContainer(ContainerInfo container) + { + Image = container.ContainerImage; + EntryPointArgs = container.ContainerEntryPointArgs?.Split(' ').Select(arg => arg.Trim()) ?? new List(); + EntryPoint = container.ContainerEntryPoint; + WorkingDirectory = container.ContainerWorkDirectory; + CreateOptions = container.ContainerCreateOptions; + if (!string.IsNullOrEmpty(container.RegistryAuthUsername)) + { + Registry = new ContainerRegistry + { + Username = container.RegistryAuthUsername, + Password = container.RegistryAuthPassword, + ServerUrl = container.RegistryServer, + }; + } + EnvironmentVariables = container.ContainerEnvironmentVariables; + PortMappings = container.UserPortMappings.Select(p => p.Value).ToList(); + SystemMountVolumes = container.SystemMountVolumes; + UserMountVolumes = container.UserMountVolumes; + } + } + + public static class ContainerInfoExtensions + { + public static HookContainer GetHookContainer(this ContainerInfo containerInfo) + { + return new HookContainer(containerInfo); + } + } +} diff --git a/src/Runner.Worker/Container/ContainerHooks/HookResponse.cs b/src/Runner.Worker/Container/ContainerHooks/HookResponse.cs new file mode 100644 index 000000000..5d81cea96 --- /dev/null +++ b/src/Runner.Worker/Container/ContainerHooks/HookResponse.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Container.ContainerHooks +{ + public class HookResponse + { + public JObject State { get; set; } + public virtual void Validate(HookInput input) { } + } + public class PrepareJobResponse : HookResponse + { + public ResponseContext Context { get; set; } + public bool? IsAlpine { get; set; } + + public override void Validate(HookInput input) + { + bool hasJobContainer = ((PrepareJobArgs)input.Args).Container != null; + if (hasJobContainer && IsAlpine == null) + { + throw new Exception("The property 'isAlpine' is required but was not found in the response file."); + } + } + } + public class ResponseContext + { + public ResponseContainer Container { get; set; } + public IList Services { get; set; } = new List(); + } + public class ResponseContainer + { + public string Id { get; set; } + public string Network { get; set; } + public IDictionary Ports { get; set; } + } +} diff --git a/src/Runner.Worker/Container/ContainerInfo.cs b/src/Runner.Worker/Container/ContainerInfo.cs index 3b1b46609..9c114939e 100644 --- a/src/Runner.Worker/Container/ContainerInfo.cs +++ b/src/Runner.Worker/Container/ContainerInfo.cs @@ -90,6 +90,7 @@ namespace GitHub.Runner.Worker.Container public string RegistryAuthUsername { get; set; } public string RegistryAuthPassword { get; set; } public bool IsJobContainer { get; set; } + public bool IsAlpine { get; set; } public IDictionary ContainerEnvironmentVariables { @@ -232,6 +233,14 @@ namespace GitHub.Runner.Worker.Container } } + public void AddPortMappings(IDictionary portMappings) + { + foreach (var pair in portMappings) + { + PortMappings.Add(new PortMapping(pair.Key, pair.Value)); + } + } + public void AddPathTranslateMapping(string hostCommonPath, string containerCommonPath) { _pathMappings.Insert(0, new PathMapping(hostCommonPath, containerCommonPath)); @@ -322,6 +331,12 @@ namespace GitHub.Runner.Worker.Container public class PortMapping { + public PortMapping(string hostPort, string containerPort) + { + this.HostPort = hostPort; + this.ContainerPort = containerPort; + } + public PortMapping(string hostPort, string containerPort, string protocol) { this.HostPort = hostPort; diff --git a/src/Runner.Worker/ContainerOperationProvider.cs b/src/Runner.Worker/ContainerOperationProvider.cs index a44b2547d..73472795c 100644 --- a/src/Runner.Worker/ContainerOperationProvider.cs +++ b/src/Runner.Worker/ContainerOperationProvider.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.ServiceProcess; using System.Threading.Tasks; using System.Linq; using System.Threading; @@ -10,8 +9,12 @@ using GitHub.Services.Common; using GitHub.Runner.Common; using GitHub.Runner.Sdk; using GitHub.DistributedTask.Pipelines.ContextData; -using Microsoft.Win32; using GitHub.DistributedTask.Pipelines.ObjectTemplating; +using GitHub.Runner.Worker.Container.ContainerHooks; +#if OS_WINDOWS // keep win specific imports around even through we don't support containers on win at the moment +using System.ServiceProcess; +using Microsoft.Win32; +#endif namespace GitHub.Runner.Worker { @@ -25,11 +28,13 @@ namespace GitHub.Runner.Worker public class ContainerOperationProvider : RunnerService, IContainerOperationProvider { private IDockerCommandManager _dockerManager; + private IContainerHookManager _containerHookManager; public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); _dockerManager = HostContext.GetService(); + _containerHookManager = HostContext.GetService(); } public async Task StartContainersAsync(IExecutionContext executionContext, object data) @@ -50,72 +55,15 @@ namespace GitHub.Runner.Worker executionContext.Debug($"Register post job cleanup for stopping/deleting containers."); executionContext.RegisterPostJobStep(postJobStep); - - // Check whether we are inside a container. - // Our container feature requires to map working directory from host to the container. - // If we are already inside a container, we will not able to find out the real working direcotry path on the host. -#if OS_WINDOWS -#pragma warning disable CA1416 - // service CExecSvc is Container Execution Agent. - ServiceController[] scServices = ServiceController.GetServices(); - if (scServices.Any(x => String.Equals(x.ServiceName, "cexecsvc", StringComparison.OrdinalIgnoreCase) && x.Status == ServiceControllerStatus.Running)) + if (FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) { - throw new NotSupportedException("Container feature is not supported when runner is already running inside container."); - } -#pragma warning restore CA1416 -#else - var initProcessCgroup = File.ReadLines("/proc/1/cgroup"); - if (initProcessCgroup.Any(x => x.IndexOf(":/docker/", StringComparison.OrdinalIgnoreCase) >= 0)) - { - throw new NotSupportedException("Container feature is not supported when runner is already running inside container."); - } -#endif - -#if OS_WINDOWS -#pragma warning disable CA1416 - // Check OS version (Windows server 1803 is required) - object windowsInstallationType = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion", "InstallationType", defaultValue: null); - ArgUtil.NotNull(windowsInstallationType, nameof(windowsInstallationType)); - object windowsReleaseId = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion", "ReleaseId", defaultValue: null); - ArgUtil.NotNull(windowsReleaseId, nameof(windowsReleaseId)); - executionContext.Debug($"Current Windows version: '{windowsReleaseId} ({windowsInstallationType})'"); - - if (int.TryParse(windowsReleaseId.ToString(), out int releaseId)) - { - if (!windowsInstallationType.ToString().StartsWith("Server", StringComparison.OrdinalIgnoreCase) || releaseId < 1803) - { - throw new NotSupportedException("Container feature requires Windows Server 1803 or higher."); - } - } - else - { - throw new ArgumentOutOfRangeException(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ReleaseId"); - } -#pragma warning restore CA1416 -#endif - - // Check docker client/server version - executionContext.Output("##[group]Checking docker version"); - DockerVersion dockerVersion = await _dockerManager.DockerVersion(executionContext); - executionContext.Output("##[endgroup]"); - - ArgUtil.NotNull(dockerVersion.ServerVersion, nameof(dockerVersion.ServerVersion)); - ArgUtil.NotNull(dockerVersion.ClientVersion, nameof(dockerVersion.ClientVersion)); - -#if OS_WINDOWS - Version requiredDockerEngineAPIVersion = new Version(1, 30); // Docker-EE version 17.6 -#else - Version requiredDockerEngineAPIVersion = new Version(1, 35); // Docker-CE version 17.12 -#endif - - if (dockerVersion.ServerVersion < requiredDockerEngineAPIVersion) - { - throw new NotSupportedException($"Min required docker engine API server version is '{requiredDockerEngineAPIVersion}', your docker ('{_dockerManager.DockerPath}') server version is '{dockerVersion.ServerVersion}'"); - } - if (dockerVersion.ClientVersion < requiredDockerEngineAPIVersion) - { - throw new NotSupportedException($"Min required docker engine API client version is '{requiredDockerEngineAPIVersion}', your docker ('{_dockerManager.DockerPath}') client version is '{dockerVersion.ClientVersion}'"); + // Initialize the containers + containers.ForEach(container => UpdateRegistryAuthForGitHubToken(executionContext, container)); + containers.Where(container => container.IsJobContainer).ForEach(container => MountWellKnownDirectories(executionContext, container)); + await _containerHookManager.PrepareJobAsync(executionContext, containers); + return; } + await AssertCompatibleOS(executionContext); // Clean up containers left by previous runs executionContext.Output("##[group]Clean up resources from previous jobs"); @@ -166,6 +114,12 @@ namespace GitHub.Runner.Worker List containers = data as List; ArgUtil.NotNull(containers, nameof(containers)); + if (FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) + { + await _containerHookManager.CleanupJobAsync(executionContext, containers); + return; + } + foreach (var container in containers) { await StopContainerAsync(executionContext, container); @@ -238,35 +192,7 @@ namespace GitHub.Runner.Worker if (container.IsJobContainer) { - // Configure job container - Mount workspace and tools, set up environment, and start long running process - var githubContext = executionContext.ExpressionValues["github"] as GitHubContext; - ArgUtil.NotNull(githubContext, nameof(githubContext)); - var workingDirectory = githubContext["workspace"] as StringContextData; - ArgUtil.NotNullOrEmpty(workingDirectory, nameof(workingDirectory)); - container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Work), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Work)))); -#if OS_WINDOWS - container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Externals), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Externals)))); -#else - container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Externals), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Externals)), true)); -#endif - container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Temp), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Temp)))); - container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Actions), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Actions)))); - container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Tools), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Tools)))); - - var tempHomeDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), "_github_home"); - Directory.CreateDirectory(tempHomeDirectory); - container.MountVolumes.Add(new MountVolume(tempHomeDirectory, "/github/home")); - container.AddPathTranslateMapping(tempHomeDirectory, "/github/home"); - container.ContainerEnvironmentVariables["HOME"] = container.TranslateToContainerPath(tempHomeDirectory); - - var tempWorkflowDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), "_github_workflow"); - Directory.CreateDirectory(tempWorkflowDirectory); - container.MountVolumes.Add(new MountVolume(tempWorkflowDirectory, "/github/workflow")); - container.AddPathTranslateMapping(tempWorkflowDirectory, "/github/workflow"); - - container.ContainerWorkDirectory = container.TranslateToContainerPath(workingDirectory); - container.ContainerEntryPoint = "tail"; - container.ContainerEntryPointArgs = "\"-f\" \"/dev/null\""; + MountWellKnownDirectories(executionContext, container); } container.ContainerId = await _dockerManager.DockerCreate(executionContext, container); @@ -329,6 +255,42 @@ namespace GitHub.Runner.Worker executionContext.Output("##[endgroup]"); } + private void MountWellKnownDirectories(IExecutionContext executionContext, ContainerInfo container) + { + // Configure job container - Mount workspace and tools, set up environment, and start long running process + var githubContext = executionContext.ExpressionValues["github"] as GitHubContext; + ArgUtil.NotNull(githubContext, nameof(githubContext)); + var workingDirectory = githubContext["workspace"] as StringContextData; + ArgUtil.NotNullOrEmpty(workingDirectory, nameof(workingDirectory)); + container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Work), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Work)))); +#if OS_WINDOWS + container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Externals), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Externals)))); +#else + container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Externals), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Externals)), true)); +#endif + container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Temp), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Temp)))); + container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Actions), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Actions)))); + container.MountVolumes.Add(new MountVolume(HostContext.GetDirectory(WellKnownDirectory.Tools), container.TranslateToContainerPath(HostContext.GetDirectory(WellKnownDirectory.Tools)))); + + var tempHomeDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), "_github_home"); + Directory.CreateDirectory(tempHomeDirectory); + container.MountVolumes.Add(new MountVolume(tempHomeDirectory, "/github/home")); + container.AddPathTranslateMapping(tempHomeDirectory, "/github/home"); + container.ContainerEnvironmentVariables["HOME"] = container.TranslateToContainerPath(tempHomeDirectory); + + var tempWorkflowDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), "_github_workflow"); + Directory.CreateDirectory(tempWorkflowDirectory); + container.MountVolumes.Add(new MountVolume(tempWorkflowDirectory, "/github/workflow")); + container.AddPathTranslateMapping(tempWorkflowDirectory, "/github/workflow"); + + container.ContainerWorkDirectory = container.TranslateToContainerPath(workingDirectory); + if (!FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) + { + container.ContainerEntryPoint = "tail"; + container.ContainerEntryPointArgs = "\"-f\" \"/dev/null\""; + } + } + private async Task StopContainerAsync(IExecutionContext executionContext, ContainerInfo container) { Trace.Entering(); @@ -337,11 +299,11 @@ namespace GitHub.Runner.Worker if (!string.IsNullOrEmpty(container.ContainerId)) { - if(!container.IsJobContainer) + if (!container.IsJobContainer) { // Print logs for service container jobs (not the "action" job itself b/c that's already logged). executionContext.Output($"Print service container logs: {container.ContainerDisplayName}"); - + int logsExitCode = await _dockerManager.DockerLogs(executionContext, container.ContainerId); if (logsExitCode != 0) { @@ -522,5 +484,74 @@ namespace GitHub.Runner.Worker container.RegistryAuthPassword = executionContext.GetGitHubContext("token"); } } + + private async Task AssertCompatibleOS(IExecutionContext executionContext) + { + // Check whether we are inside a container. + // Our container feature requires to map working directory from host to the container. + // If we are already inside a container, we will not able to find out the real working direcotry path on the host. +#if OS_WINDOWS +#pragma warning disable CA1416 + // service CExecSvc is Container Execution Agent. + ServiceController[] scServices = ServiceController.GetServices(); + if (scServices.Any(x => String.Equals(x.ServiceName, "cexecsvc", StringComparison.OrdinalIgnoreCase) && x.Status == ServiceControllerStatus.Running)) + { + throw new NotSupportedException("Container feature is not supported when runner is already running inside container."); + } +#pragma warning restore CA1416 +#else + var initProcessCgroup = File.ReadLines("/proc/1/cgroup"); + if (initProcessCgroup.Any(x => x.IndexOf(":/docker/", StringComparison.OrdinalIgnoreCase) >= 0)) + { + throw new NotSupportedException("Container feature is not supported when runner is already running inside container."); + } +#endif + +#if OS_WINDOWS +#pragma warning disable CA1416 + // Check OS version (Windows server 1803 is required) + object windowsInstallationType = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion", "InstallationType", defaultValue: null); + ArgUtil.NotNull(windowsInstallationType, nameof(windowsInstallationType)); + object windowsReleaseId = Registry.GetValue(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion", "ReleaseId", defaultValue: null); + ArgUtil.NotNull(windowsReleaseId, nameof(windowsReleaseId)); + executionContext.Debug($"Current Windows version: '{windowsReleaseId} ({windowsInstallationType})'"); + + if (int.TryParse(windowsReleaseId.ToString(), out int releaseId)) + { + if (!windowsInstallationType.ToString().StartsWith("Server", StringComparison.OrdinalIgnoreCase) || releaseId < 1803) + { + throw new NotSupportedException("Container feature requires Windows Server 1803 or higher."); + } + } + else + { + throw new ArgumentOutOfRangeException(@"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ReleaseId"); + } +#pragma warning restore CA1416 +#endif + + // Check docker client/server version + executionContext.Output("##[group]Checking docker version"); + DockerVersion dockerVersion = await _dockerManager.DockerVersion(executionContext); + executionContext.Output("##[endgroup]"); + + ArgUtil.NotNull(dockerVersion.ServerVersion, nameof(dockerVersion.ServerVersion)); + ArgUtil.NotNull(dockerVersion.ClientVersion, nameof(dockerVersion.ClientVersion)); + +#if OS_WINDOWS + Version requiredDockerEngineAPIVersion = new Version(1, 30); // Docker-EE version 17.6 +#else + Version requiredDockerEngineAPIVersion = new Version(1, 35); // Docker-CE version 17.12 +#endif + + if (dockerVersion.ServerVersion < requiredDockerEngineAPIVersion) + { + throw new NotSupportedException($"Min required docker engine API server version is '{requiredDockerEngineAPIVersion}', your docker ('{_dockerManager.DockerPath}') server version is '{dockerVersion.ServerVersion}'"); + } + if (dockerVersion.ClientVersion < requiredDockerEngineAPIVersion) + { + throw new NotSupportedException($"Min required docker engine API client version is '{requiredDockerEngineAPIVersion}', your docker ('{_dockerManager.DockerPath}') client version is '{dockerVersion.ClientVersion}'"); + } + } } } diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 4fc869c07..7bb7e7efc 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1226,7 +1226,7 @@ namespace GitHub.Runner.Worker var value = dict[key].ToString(); if (!string.IsNullOrEmpty(value)) { - dict[key] = new StringContextData(stepHost.ResolvePathForStepHost(value)); + dict[key] = new StringContextData(stepHost.ResolvePathForStepHost(context, value)); } } else if (dict[key] is DictionaryContextData) diff --git a/src/Runner.Worker/FeatureManager.cs b/src/Runner.Worker/FeatureManager.cs new file mode 100644 index 000000000..98f49e8fd --- /dev/null +++ b/src/Runner.Worker/FeatureManager.cs @@ -0,0 +1,15 @@ +using System; +using GitHub.Runner.Common; + +namespace GitHub.Runner.Worker +{ + public class FeatureManager + { + public static bool IsContainerHooksEnabled(Variables variables) + { + var isContainerHookFeatureFlagSet = variables?.GetBoolean(Constants.Runner.Features.AllowRunnerContainerHooks) ?? false; + var isContainerHooksPathSet = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(Constants.Hooks.ContainerHooksPath)); + return isContainerHookFeatureFlagSet && isContainerHooksPathSet; + } + } +} diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index b79092da0..b367383d1 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common.Util; using GitHub.Runner.Worker.Container; +using Newtonsoft.Json.Linq; namespace GitHub.Runner.Worker { @@ -22,5 +23,6 @@ namespace GitHub.Runner.Worker public StepsContext StepsContext { get; set; } public Variables Variables { get; set; } public bool WriteDebug { get; set; } + public JObject ContainerHookState { get; set; } } } diff --git a/src/Runner.Worker/Handlers/CompositeActionHandler.cs b/src/Runner.Worker/Handlers/CompositeActionHandler.cs index 8290bb874..0dc538157 100644 --- a/src/Runner.Worker/Handlers/CompositeActionHandler.cs +++ b/src/Runner.Worker/Handlers/CompositeActionHandler.cs @@ -11,6 +11,8 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Container.ContainerHooks; using GitHub.Runner.Worker.Expressions; using Pipelines = GitHub.DistributedTask.Pipelines; diff --git a/src/Runner.Worker/Handlers/ContainerActionHandler.cs b/src/Runner.Worker/Handlers/ContainerActionHandler.cs index 3eaacb5a9..1fe205f40 100644 --- a/src/Runner.Worker/Handlers/ContainerActionHandler.cs +++ b/src/Runner.Worker/Handlers/ContainerActionHandler.cs @@ -8,6 +8,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Sdk; using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Container.ContainerHooks; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Handlers @@ -38,6 +39,8 @@ namespace GitHub.Runner.Worker.Handlers AddInputsToEnvironment(); var dockerManager = HostContext.GetService(); + var containerHookManager = HostContext.GetService(); + string dockerFile = null; // container image haven't built/pull if (Data.Image.StartsWith("docker://", StringComparison.OrdinalIgnoreCase)) @@ -47,26 +50,28 @@ namespace GitHub.Runner.Worker.Handlers else if (Data.Image.EndsWith("Dockerfile") || Data.Image.EndsWith("dockerfile")) { // ensure docker file exist - var dockerFile = Path.Combine(ActionDirectory, Data.Image); + dockerFile = Path.Combine(ActionDirectory, Data.Image); ArgUtil.File(dockerFile, nameof(Data.Image)); - - ExecutionContext.Output($"##[group]Building docker image"); - ExecutionContext.Output($"Dockerfile for action: '{dockerFile}'."); - var imageName = $"{dockerManager.DockerInstanceLabel}:{ExecutionContext.Id.ToString("N")}"; - var buildExitCode = await dockerManager.DockerBuild( - ExecutionContext, - ExecutionContext.GetGitHubContext("workspace"), - dockerFile, - Directory.GetParent(dockerFile).FullName, - imageName); - ExecutionContext.Output("##[endgroup]"); - - if (buildExitCode != 0) + if (!FeatureManager.IsContainerHooksEnabled(ExecutionContext.Global.Variables)) { - throw new InvalidOperationException($"Docker build failed with exit code {buildExitCode}"); - } + ExecutionContext.Output($"##[group]Building docker image"); + ExecutionContext.Output($"Dockerfile for action: '{dockerFile}'."); + var imageName = $"{dockerManager.DockerInstanceLabel}:{ExecutionContext.Id.ToString("N")}"; + var buildExitCode = await dockerManager.DockerBuild( + ExecutionContext, + ExecutionContext.GetGitHubContext("workspace"), + dockerFile, + Directory.GetParent(dockerFile).FullName, + imageName); + ExecutionContext.Output("##[endgroup]"); - Data.Image = imageName; + if (buildExitCode != 0) + { + throw new InvalidOperationException($"Docker build failed with exit code {buildExitCode}"); + } + + Data.Image = imageName; + } } string type = Action.Type == Pipelines.ActionSourceType.Repository ? "Dockerfile" : "DockerHub"; @@ -220,14 +225,21 @@ namespace GitHub.Runner.Worker.Handlers container.ContainerEnvironmentVariables[variable.Key] = container.TranslateToContainerPath(variable.Value); } - using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager, container)) - using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager, container)) + if (FeatureManager.IsContainerHooksEnabled(ExecutionContext.Global.Variables)) { - var runExitCode = await dockerManager.DockerRun(ExecutionContext, container, stdoutManager.OnDataReceived, stderrManager.OnDataReceived); - ExecutionContext.Debug($"Docker Action run completed with exit code {runExitCode}"); - if (runExitCode != 0) + await containerHookManager.RunContainerStepAsync(ExecutionContext, container, dockerFile); + } + else + { + using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager, container)) + using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager, container)) { - ExecutionContext.Result = TaskResult.Failed; + var runExitCode = await dockerManager.DockerRun(ExecutionContext, container, stdoutManager.OnDataReceived, stderrManager.OnDataReceived); + ExecutionContext.Debug($"Docker Action run completed with exit code {runExitCode}"); + if (runExitCode != 0) + { + ExecutionContext.Result = TaskResult.Failed; + } } } #endif diff --git a/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs b/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs index 197e10e76..6f513c863 100644 --- a/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs +++ b/src/Runner.Worker/Handlers/NodeScriptActionHandler.cs @@ -8,6 +8,8 @@ using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Container.ContainerHooks; namespace GitHub.Runner.Worker.Handlers { @@ -109,7 +111,7 @@ namespace GitHub.Runner.Worker.Handlers // 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(@"""", @"\"""))); + string arguments = StepHost.ResolvePathForStepHost(ExecutionContext, StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\"""))); #if OS_WINDOWS // It appears that node.exe outputs UTF8 when not in TTY mode. @@ -142,14 +144,16 @@ namespace GitHub.Runner.Worker.Handlers // 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 step = StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory), - fileName: StepHost.ResolvePathForStepHost(file), + Task step = StepHost.ExecuteAsync(ExecutionContext, + workingDirectory: StepHost.ResolvePathForStepHost(ExecutionContext, workingDirectory), + fileName: StepHost.ResolvePathForStepHost(ExecutionContext, file), arguments: arguments, environment: Environment, requireExitCodeZero: false, outputEncoding: outputEncoding, killProcessOnCancel: false, inheritConsoleHandler: !ExecutionContext.Global.Variables.Retain_Default_Encoding, + standardInInput: null, cancellationToken: ExecutionContext.CancellationToken); // Wait for either the node exit or force finish through ##vso command diff --git a/src/Runner.Worker/Handlers/ScriptHandler.cs b/src/Runner.Worker/Handlers/ScriptHandler.cs index 5530b15c7..57f61e9c4 100644 --- a/src/Runner.Worker/Handlers/ScriptHandler.cs +++ b/src/Runner.Worker/Handlers/ScriptHandler.cs @@ -8,6 +8,8 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Container; +using GitHub.Runner.Worker.Container.ContainerHooks; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Handlers @@ -247,7 +249,7 @@ namespace GitHub.Runner.Worker.Handlers { // We do not not the full path until we know what shell is being used, so that we can determine the file extension scriptFilePath = Path.Combine(tempDirectory, $"{Guid.NewGuid()}{ScriptHandlerHelpers.GetScriptFileExtension(shellCommand)}"); - resolvedScriptPath = StepHost.ResolvePathForStepHost(scriptFilePath).Replace("\"", "\\\""); + resolvedScriptPath = StepHost.ResolvePathForStepHost(ExecutionContext, scriptFilePath).Replace("\"", "\\\""); } else { @@ -318,6 +320,7 @@ namespace GitHub.Runner.Worker.Handlers ExecutionContext.Debug($"{fileName} {arguments}"); + Inputs.TryGetValue("standardInInput", out var standardInInput); using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager)) using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager)) { @@ -325,7 +328,8 @@ namespace GitHub.Runner.Worker.Handlers StepHost.ErrorDataReceived += stderrManager.OnDataReceived; // Execute - int exitCode = await StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory), + int exitCode = await StepHost.ExecuteAsync(ExecutionContext, + workingDirectory: StepHost.ResolvePathForStepHost(ExecutionContext, workingDirectory), fileName: fileName, arguments: arguments, environment: Environment, @@ -333,6 +337,7 @@ namespace GitHub.Runner.Worker.Handlers outputEncoding: null, killProcessOnCancel: false, inheritConsoleHandler: !ExecutionContext.Global.Variables.Retain_Default_Encoding, + standardInInput: standardInInput, cancellationToken: ExecutionContext.CancellationToken); // Error diff --git a/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs b/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs index eabfc085f..159a028f9 100644 --- a/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs +++ b/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; using System.IO; using GitHub.Runner.Sdk; +using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; namespace GitHub.Runner.Worker.Handlers { - internal class ScriptHandlerHelpers + internal static class ScriptHandlerHelpers { private static readonly Dictionary _defaultArguments = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -81,27 +83,5 @@ namespace GitHub.Runner.Worker.Handlers throw new ArgumentException($"Failed to parse COMMAND [..ARGS] from {shellOption}"); } } - - internal static string GetDefaultShellNameForScript(string path, Common.Tracing trace, string prependPath) - { - switch (Path.GetExtension(path)) - { - case ".sh": - // use 'sh' args but prefer bash - if (WhichUtil.Which("bash", false, trace, prependPath) != null) - { - return "bash"; - } - return "sh"; - case ".ps1": - if (WhichUtil.Which("pwsh", false, trace, prependPath) != null) - { - return "pwsh"; - } - return "powershell"; - default: - throw new ArgumentException($"{path} is not a valid path to a script. Make sure it ends in '.sh' or '.ps1'."); - } - } } } diff --git a/src/Runner.Worker/Handlers/StepHost.cs b/src/Runner.Worker/Handlers/StepHost.cs index 2db55913f..95feaca9a 100644 --- a/src/Runner.Worker/Handlers/StepHost.cs +++ b/src/Runner.Worker/Handlers/StepHost.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using GitHub.DistributedTask.Pipelines.ContextData; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -7,7 +8,9 @@ using GitHub.Runner.Worker.Container; using GitHub.Runner.Common; using GitHub.Runner.Sdk; using System.Linq; -using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Worker.Container.ContainerHooks; +using System.IO; +using System.Threading.Channels; namespace GitHub.Runner.Worker.Handlers { @@ -16,11 +19,12 @@ namespace GitHub.Runner.Worker.Handlers event EventHandler OutputDataReceived; event EventHandler ErrorDataReceived; - string ResolvePathForStepHost(string path); + string ResolvePathForStepHost(IExecutionContext executionContext, string path); Task DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion); - Task ExecuteAsync(string workingDirectory, + Task ExecuteAsync(IExecutionContext context, + string workingDirectory, string fileName, string arguments, IDictionary environment, @@ -28,6 +32,7 @@ namespace GitHub.Runner.Worker.Handlers Encoding outputEncoding, bool killProcessOnCancel, bool inheritConsoleHandler, + string standardInInput, CancellationToken cancellationToken); } @@ -48,7 +53,7 @@ namespace GitHub.Runner.Worker.Handlers public event EventHandler OutputDataReceived; public event EventHandler ErrorDataReceived; - public string ResolvePathForStepHost(string path) + public string ResolvePathForStepHost(IExecutionContext executionContext, string path) { return path; } @@ -58,7 +63,8 @@ namespace GitHub.Runner.Worker.Handlers return Task.FromResult(preferredVersion); } - public async Task ExecuteAsync(string workingDirectory, + public async Task ExecuteAsync(IExecutionContext context, + string workingDirectory, string fileName, string arguments, IDictionary environment, @@ -66,10 +72,17 @@ namespace GitHub.Runner.Worker.Handlers Encoding outputEncoding, bool killProcessOnCancel, bool inheritConsoleHandler, + string standardInInput, CancellationToken cancellationToken) { using (var processInvoker = HostContext.CreateService()) { + Channel redirectStandardIn = null; + if (standardInInput != null) + { + redirectStandardIn = Channel.CreateUnbounded(new UnboundedChannelOptions() { SingleReader = true, SingleWriter = true }); + redirectStandardIn.Writer.TryWrite(standardInInput); + } processInvoker.OutputDataReceived += OutputDataReceived; processInvoker.ErrorDataReceived += ErrorDataReceived; @@ -80,7 +93,7 @@ namespace GitHub.Runner.Worker.Handlers requireExitCodeZero: requireExitCodeZero, outputEncoding: outputEncoding, killProcessOnCancel: killProcessOnCancel, - redirectStandardIn: null, + redirectStandardIn: redirectStandardIn, inheritConsoleHandler: inheritConsoleHandler, cancellationToken: cancellationToken); } @@ -94,11 +107,15 @@ namespace GitHub.Runner.Worker.Handlers public event EventHandler OutputDataReceived; public event EventHandler ErrorDataReceived; - public string ResolvePathForStepHost(string path) + public string ResolvePathForStepHost(IExecutionContext executionContext, string path) { // make sure container exist. ArgUtil.NotNull(Container, nameof(Container)); - ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId)); + if (!FeatureManager.IsContainerHooksEnabled(executionContext.Global?.Variables)) + { + // TODO: Remove nullcheck with executionContext.Global? by setting up ExecutionContext.Global at GitHub.Runner.Common.Tests.Worker.ExecutionContextL0.GetExpressionValues_ContainerStepHost + ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId)); + } // remove double quotes around the path path = path.Trim('\"'); @@ -120,6 +137,19 @@ namespace GitHub.Runner.Worker.Handlers public async Task DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion) { + // Optimistically use the default + string nodeExternal = preferredVersion; + + if (FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) + { + if (Container.IsAlpine) + { + nodeExternal = CheckPlatformForAlpineContainer(executionContext, preferredVersion); + } + executionContext.Debug($"Running JavaScript Action with default external tool: {nodeExternal}"); + return nodeExternal; + } + // 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 @@ -128,7 +158,6 @@ namespace GitHub.Runner.Worker.Handlers var output = new List(); var execExitCode = await dockerManager.DockerExec(executionContext, Container.ContainerId, string.Empty, osReleaseIdCmd, output); - string nodeExternal; if (execExitCode == 0) { foreach (var line in output) @@ -136,26 +165,17 @@ namespace GitHub.Runner.Worker.Handlers executionContext.Debug(line); if (line.ToLower().Contains("alpine")) { - if (!Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.X64)) - { - var os = Constants.Runner.Platform.ToString(); - var arch = Constants.Runner.PlatformArchitecture.ToString(); - var msg = $"JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected {os} {arch}"; - throw new NotSupportedException(msg); - } - nodeExternal = $"{preferredVersion}_alpine"; - executionContext.Debug($"Container distribution is alpine. Running JavaScript Action with external tool: {nodeExternal}"); + nodeExternal = CheckPlatformForAlpineContainer(executionContext, preferredVersion); return nodeExternal; } } } - // Optimistically use the default - nodeExternal = preferredVersion; executionContext.Debug($"Running JavaScript Action with default external tool: {nodeExternal}"); return nodeExternal; } - public async Task ExecuteAsync(string workingDirectory, + public async Task ExecuteAsync(IExecutionContext context, + string workingDirectory, string fileName, string arguments, IDictionary environment, @@ -163,12 +183,25 @@ namespace GitHub.Runner.Worker.Handlers Encoding outputEncoding, bool killProcessOnCancel, bool inheritConsoleHandler, + string standardInInput, CancellationToken cancellationToken) { - // make sure container exist. ArgUtil.NotNull(Container, nameof(Container)); - ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId)); + var containerHookManager = HostContext.GetService(); + if (FeatureManager.IsContainerHooksEnabled(context.Global.Variables)) + { + TranslateToContainerPath(environment); + await containerHookManager.RunScriptStepAsync(context, + Container, + workingDirectory, + fileName, + arguments, + environment, + PrependPath); + return (int)(context.Result ?? 0); + } + ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId)); var dockerManager = HostContext.GetService(); string dockerClientPath = dockerManager.DockerPath; @@ -202,12 +235,7 @@ namespace GitHub.Runner.Worker.Handlers 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]); - } + TranslateToContainerPath(environment); using (var processInvoker = HostContext.CreateService()) { @@ -221,7 +249,6 @@ namespace GitHub.Runner.Worker.Handlers // Let .NET choose the default. outputEncoding = null; #endif - return await processInvoker.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work), fileName: dockerClientPath, arguments: dockerCommandArgstring, @@ -234,5 +261,28 @@ namespace GitHub.Runner.Worker.Handlers cancellationToken: cancellationToken); } } + + private string CheckPlatformForAlpineContainer(IExecutionContext executionContext, string preferredVersion) + { + string nodeExternal = preferredVersion; + if (!Constants.Runner.PlatformArchitecture.Equals(Constants.Architecture.X64)) + { + var os = Constants.Runner.Platform.ToString(); + var arch = Constants.Runner.PlatformArchitecture.ToString(); + var msg = $"JavaScript Actions in Alpine containers are only supported on x64 Linux runners. Detected {os} {arch}"; + throw new NotSupportedException(msg); + } + nodeExternal = $"{preferredVersion}_alpine"; + executionContext.Debug($"Container distribution is alpine. Running JavaScript Action with external tool: {nodeExternal}"); + return nodeExternal; + } + + private void TranslateToContainerPath(IDictionary environment) + { + foreach (var envKey in environment.Keys.ToList()) + { + environment[envKey] = this.Container.TranslateToContainerPath(environment[envKey]); + } + } } } diff --git a/src/Runner.Worker/JobHookProvider.cs b/src/Runner.Worker/JobHookProvider.cs index 11d2c09dc..9d616851d 100644 --- a/src/Runner.Worker/JobHookProvider.cs +++ b/src/Runner.Worker/JobHookProvider.cs @@ -60,7 +60,7 @@ namespace GitHub.Runner.Worker Dictionary inputs = new() { ["path"] = hookData.Path, - ["shell"] = ScriptHandlerHelpers.GetDefaultShellNameForScript(hookData.Path, Trace, prependPath) + ["shell"] = HostContext.GetDefaultShellForScript(hookData.Path, prependPath) }; // Create the handler diff --git a/src/Sdk/DTWebApi/WebApi/ActionsStepTelemetry.cs b/src/Sdk/DTWebApi/WebApi/ActionsStepTelemetry.cs index f46abe4c7..713840139 100644 --- a/src/Sdk/DTWebApi/WebApi/ActionsStepTelemetry.cs +++ b/src/Sdk/DTWebApi/WebApi/ActionsStepTelemetry.cs @@ -56,5 +56,8 @@ namespace GitHub.DistributedTask.WebApi [DataMember(EmitDefaultValue = false)] public int? ExecutionTimeInSeconds { get; set; } + + [DataMember(EmitDefaultValue = false)] + public string ContainerHookData { get; set; } } } diff --git a/src/Test/L0/ServiceInterfacesL0.cs b/src/Test/L0/ServiceInterfacesL0.cs index 4faacbd40..03affe57e 100644 --- a/src/Test/L0/ServiceInterfacesL0.cs +++ b/src/Test/L0/ServiceInterfacesL0.cs @@ -2,6 +2,7 @@ using GitHub.Runner.Listener.Check; using GitHub.Runner.Listener.Configuration; using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Container.ContainerHooks; using GitHub.Runner.Worker.Handlers; using System; using System.Collections.Generic; @@ -68,7 +69,8 @@ namespace GitHub.Runner.Common.Tests typeof(IStep), typeof(IStepHost), typeof(IDiagnosticLogManager), - typeof(IEnvironmentContextData) + typeof(IEnvironmentContextData), + typeof(IHookArgs), }; Validate( assembly: typeof(IStepsRunner).GetTypeInfo().Assembly, diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index d20fe3e72..6ee0161a7 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -100,7 +100,6 @@ namespace GitHub.Runner.Common.Tests.Worker { using (TestHostContext hc = CreateTestContext()) { - // Arrange: Create a job request message. TaskOrchestrationPlanReference plan = new TaskOrchestrationPlanReference(); TimelineReference timeline = new TimelineReference(); diff --git a/src/Test/L0/Worker/StepHostL0.cs b/src/Test/L0/Worker/StepHostL0.cs index 9e18c1128..0130b84cb 100644 --- a/src/Test/L0/Worker/StepHostL0.cs +++ b/src/Test/L0/Worker/StepHostL0.cs @@ -10,6 +10,7 @@ using GitHub.Runner.Worker.Container; using GitHub.DistributedTask.Pipelines.ContextData; using System.Linq; using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.WebApi; namespace GitHub.Runner.Common.Tests.Worker { @@ -24,6 +25,7 @@ namespace GitHub.Runner.Common.Tests.Worker _ec = new Mock(); _ec.SetupAllProperties(); _ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true }); + _ec.Object.Global.Variables = new Variables(hc, new Dictionary()); var trace = hc.GetTrace(); _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });