Runner container hooks Beta (#1853)

* Added ability to run Dockerfile.SUFFIX ContainerAction

* Extracted IsDockerFile method

* reformatted, moved from index to Last()

* extracted IsDockerfile to DockerUtil with L0

* added check for IsDockerfile to account for docker://

* updated test to clearly show path/dockerfile:tag

* fail if Data.Image is not Dockerfile or docker://[image]

* Setup noops for JobPrepare and JobCleanup hooks

* Add container jobstarted and jobcomplete hooks

* Run 'index.js' instead of specific command hooks

* Call jobprepare with command arg

* Use right command name (hardcoded)

Co-authored-by: Nikola Jokic <nikola-jokic@users.noreply.github.com>

* Invoke hooks with arguments

* Add PrepareJob hook to work with jobcontainers

Co-authored-by: Nikola Jokic <nikola-jokic@users.noreply.github.com>

* Rename methods

* Use new hookcontainer to run prep and clean hooks

* Get path from ENV

* Use enums

* Use IOUtils.cs

* Move container files to folder

* Move namespaces

* Store "state" between hooks

* Remove stdin stream in containerstephosts

* Update Constants.cs

* Throw if stdin fails

* Cleanup obvious nullrefs and unused vars

* Cleanup containerhook directory

* Call step exec hook

* Fix windows build

* Remove hook from hookContainer

* Rename file

* More renamings

* Add TODOs

* Fix env name

* Fix missing imports

* Fix imports

* Run script step on jobcontainer

* Enable feature if env is set

* Update ContainerHookManager.cs

* Update ContainerHookManager.cs

* Hooks allowed to work even when context isn't returned

* Custom hooks enabled flag and additional null checks

* New line at the end of the FeatureFlagManager.cs

* Code refactoring

* Supported just in time container building or pulling

* Try mock-build for osx

* Build all platforms

* Run mock on self-hosted

* Remove GITHUB prefix

* Use ContainerHooksPath instead of CustomHooksPath

* Null checks simplified

* Code refactoring

* Changing condition for image builing/pulling

* Code refactoring

* TODO comment removed

Co-authored-by: Ferenc Hammerl <31069338+fhammerl@users.noreply.github.com>

* Call container step if FF is on

* Rename run script function

* Use JToken instead of dynamic

* Add TODO

* Small refactoring + renames + TODOs

* Throw on DetermineNodeRuntimeVersion

* Fix formatting

* Add run-container-step

* Supported nodeJS in Alpine containers

* Renamed Alpine to IsAlpine in HookResponse

* Method for checking platform for alpine container

* Added container hooks feature flag check

* Update IsHookFeatureEnabled with new params

* Rename featureflag method

* Finish rename

* Set collection null values to empty arrays when JSON serialising them

* Disable FF until we merge

* Update src/Runner.Worker/Container/ContainerHooks/HookContainer.cs

* Fix method name

* Change hookargs to superclass from interface

* Using only Path.Combine in GenerateResponsePath

* fix merge error

* EntryPointArgs changed to list of args instead of one args string

* Changed List to IEnumerable for EntryPointArgs and MountVolumes

* Get ContainerRuntimePath for JobContainers from hooks

* Read ContainerEnv from response file

* Port mappings saved after creating services

* Support case when responseFile doesn't exist

* Check if response file exists

* Logging in ExecuteHookScript

* Save hook state after all 4 hooks

* Code refactoring

* Remove TODO

Co-authored-by: Ferenc Hammerl <31069338+fhammerl@users.noreply.github.com>

* Remove second TODO

Co-authored-by: Ferenc Hammerl <31069338+fhammerl@users.noreply.github.com>

* Removing container env changes

* Removing containerEnv and dockerManager

* Delete mock-build.yml

* Update IOUtil.cs

* Add comment about containerhooks

* Fix merge mistake

* Remove solved todo

* Determine which shell to use for hooks scenario

* Overload for method ExecuteHookScript with prependPath as arg

* Adding HostContext to the GetDefaultShellForScript call

* prependPath as a mandatory parameter

* Improve logging for hooks

* Small changes in logging

* Allow null for ContainerEntryPointArgs

* Changed log messages

* Skip setting EntryPoint and EntryPointArgs if hooks are enabled

* Throw if IsAlpine is null in PrepareJob

* Code refactoring - added GetAndValidateResponse method

* Code refactoring

* Changes in exception message

* Only save hookState if returned

* Use FF from server

* Empty line

* Code refactoring

Co-authored-by: Ferenc Hammerl <31069338+fhammerl@users.noreply.github.com>

* Send null instead of string empty

* Remove TODO

* Code refactoring and some small changes

* Allow Globals to be null to pass L0

* Fix setup in StepHostL0

* Throw exception earlier if response file doesn't exist and prepare_job hook is running

* Refactoring GetResponse method

* Changing exception message if response file is not found

Co-authored-by: Ferenc Hammerl <31069338+fhammerl@users.noreply.github.com>

* Chaning exception message if isAlpine is null for prepare_job hook

* Rename hook folder

* Fail if compatible hookfile not found

* Use .Value instead of casting bool? to bool

* Format spacing

* Formatting

* User user and system mvs

* Use variables instead of entire context in featuremanager

* Update stepTelemetry if step uses containerhooks

* Restore  import

* Remove unneccessary field from HookContainer

* Refactor response context and portmappings

* Force allow hooks if FF is on

* Code refactoring

* Revert deleting usings

* Better hookContainer defaults and use correct portmapping list

* Make GetDefaultShellForScript a  HostContext extension method

* Generic hookresponse

* Code refactoring, unnecessary properties removed - HookContainer moved to the HookInput.cs

* Remove empty line

* Code refactoring and better exception handling

* code refactor, removing unnecessary props

* Move hookstate to global ContainerHookState

* Trace exception before we throw it for not losing information

* Fix for null ref exception in GetResponse

* Adding additional check for null response in prepareJob hook

* Refactoring GetResponse with additional check

* Update error messages

* Ports in ResponseContainer changed from IList to IDictionary

* Fix port format

* Include dockerfile

* Send null Registry obj if there's nothing in it

* Minor formatting

* Check if hookIndexPath exists relocated to the ContainerHookManager

* Code refactoring - ValidateHookExecutable added to the ContainerHookManager

* check if ContainerHooksPath when AllowRunnerContainerHooks is on

* Submit JSON telemetry instead of boolean

* Prefix step hooks with "run"

* Rename FeatureManager

* Fix flipped condition

* Unify js shell path getter with ps1 and sh getter

* Validate on run, not on instantiation of manager

* Cleanup ExecuteAsync methods

* Handle exception in executeHookScript

* Better exception types

* Remove comment

* Simplify boolean

* Allow jobs without jobContainer to run

* Use JObject instead of JToken

* Use correct Response type

* Format class to move cleanupJobAsync to the end of public methods

* Rename HookIndexPath to HookScriptPath

* Refactor methods into expression bodies

* Fix args class hierarchy

* Fix argument order

* Formatting

* Fix nullref and don't swallow stacktrace

* Whilelist HookArgs

* Use FF in FeatureManager

* Update src/Runner.Worker/ContainerOperationProvider.cs

Co-authored-by: Tingluo Huang <tingluohuang@github.com>

* Update src/Runner.Worker/ActionRunner.cs

Co-authored-by: Thomas Boop <52323235+thboop@users.noreply.github.com>

* Update src/Runner.Worker/ActionRunner.cs

Co-authored-by: Thomas Boop <52323235+thboop@users.noreply.github.com>

* Only mount well known dirs to job containers

* Get trace from hostcontext

* Use hook execution for setting telemetry

Co-authored-by: Nikola Jokic <nikola.jokic@akvelon.com>
Co-authored-by: Nikola Jokic <nikola-jokic@users.noreply.github.com>
Co-authored-by: Nikola Jokic <97525037+nikola-jokic@users.noreply.github.com>
Co-authored-by: Stefan Ruvceski <stefan.ruvceski@akvelon.com>
Co-authored-by: ruvceskistefan <96768603+ruvceskistefan@users.noreply.github.com>
Co-authored-by: Thomas Boop <thboop@github.com>
Co-authored-by: stefanruvceski <ruvceskistefan@github.com>
Co-authored-by: Tingluo Huang <tingluohuang@github.com>
Co-authored-by: Thomas Boop <52323235+thboop@users.noreply.github.com>
This commit is contained in:
Ferenc Hammerl
2022-06-10 15:51:20 +02:00
committed by GitHub
parent ac7b34a071
commit 591f8c3510
24 changed files with 800 additions and 207 deletions

View File

@@ -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

View File

@@ -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<IHttpClientHandlerFactory>();
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

View File

@@ -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);
}
/// <summary>
/// Recursively enumerates a directory without following directory reparse points.
/// </summary>

View File

@@ -101,38 +101,41 @@ namespace GitHub.Runner.Worker
IEnumerable<Pipelines.ActionStep> actions = steps.OfType<Pipelines.ActionStep>();
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);
}

View File

@@ -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<IContainerStepHost>();
containerStepHost.Container = ExecutionContext.Global.Container;
stepHost = containerStepHost;

View File

@@ -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<ContainerInfo> containers);
Task RunContainerStepAsync(IExecutionContext context, ContainerInfo container, string dockerFile);
Task RunScriptStepAsync(IExecutionContext context, ContainerInfo container, string workingDirectory, string fileName, string arguments, IDictionary<string, string> environment, string prependPath);
Task CleanupJobAsync(IExecutionContext context, List<ContainerInfo> 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<ContainerInfo> 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<PrepareJobResponse>(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<HookResponse>(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<string, string> 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<HookResponse>(context, input, ActionRunStage.Pre, prependPath);
if (response == null)
{
return;
}
SaveHookState(context, response.State, input);
}
public async Task CleanupJobAsync(IExecutionContext context, List<ContainerInfo> 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<HookResponse>(context, input, ActionRunStage.Pre, prependPath);
}
public string GetContainerHookData()
{
return JsonUtility.ToString(new { HookScriptPath });
}
private async Task<T> ExecuteHookScript<T>(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<IDefaultStepHost>();
Dictionary<string, string> inputs = new()
{
["standardInInput"] = JsonUtility.ToString(input),
["path"] = HookScriptPath,
["shell"] = HostContext.GetDefaultShellForScript(HookScriptPath, prependPath)
};
var handlerFactory = HostContext.GetService<IHandlerFactory>();
var handler = handlerFactory.Create(
context,
null,
stepHost,
new ScriptActionExecutionData(),
inputs,
environment: new Dictionary<string, string>(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<T>(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<string>());
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<T>(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<T>(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<ContainerInfo> 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;
}
}
}
}

View File

@@ -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<HookContainer> Services { get; set; }
public bool IsRequireAlpineInResponse() => Container != null;
}
public class ScriptStepArgs : IHookArgs
{
public IEnumerable<string> EntryPointArgs { get; set; }
public string EntryPoint { get; set; }
public IDictionary<string, string> 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<string> EntryPointArgs { get; set; } = new List<string>();
public string EntryPoint { get; set; }
public string WorkingDirectory { get; set; }
public string CreateOptions { get; private set; }
public ContainerRegistry Registry { get; set; }
public IDictionary<string, string> EnvironmentVariables { get; set; } = new Dictionary<string, string>();
public IEnumerable<string> PortMappings { get; set; } = new List<string>();
public IEnumerable<MountVolume> SystemMountVolumes { get; set; } = new List<MountVolume>();
public IEnumerable<MountVolume> UserMountVolumes { get; set; } = new List<MountVolume>();
public HookContainer() { } // For Json deserializer
public HookContainer(ContainerInfo container)
{
Image = container.ContainerImage;
EntryPointArgs = container.ContainerEntryPointArgs?.Split(' ').Select(arg => arg.Trim()) ?? new List<string>();
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);
}
}
}

View File

@@ -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<ResponseContainer> Services { get; set; } = new List<ResponseContainer>();
}
public class ResponseContainer
{
public string Id { get; set; }
public string Network { get; set; }
public IDictionary<string, string> Ports { get; set; }
}
}

View File

@@ -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<string, string> ContainerEnvironmentVariables
{
@@ -232,6 +233,14 @@ namespace GitHub.Runner.Worker.Container
}
}
public void AddPortMappings(IDictionary<string, string> 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;

View File

@@ -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<IDockerCommandManager>();
_containerHookManager = HostContext.GetService<IContainerHookManager>();
}
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<ContainerInfo> containers = data as List<ContainerInfo>;
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}'");
}
}
}
}

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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<IDockerCommandManager>();
var containerHookManager = HostContext.GetService<IContainerHookManager>();
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

View File

@@ -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<int> step = StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory),
fileName: StepHost.ResolvePathForStepHost(file),
Task<int> 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

View File

@@ -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

View File

@@ -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<string, string> _defaultArguments = new Dictionary<string, string>(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'.");
}
}
}
}

View File

@@ -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<ProcessDataReceivedEventArgs> OutputDataReceived;
event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
string ResolvePathForStepHost(string path);
string ResolvePathForStepHost(IExecutionContext executionContext, string path);
Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext, string preferredVersion);
Task<int> ExecuteAsync(string workingDirectory,
Task<int> ExecuteAsync(IExecutionContext context,
string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> 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<ProcessDataReceivedEventArgs> OutputDataReceived;
public event EventHandler<ProcessDataReceivedEventArgs> 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<string>(preferredVersion);
}
public async Task<int> ExecuteAsync(string workingDirectory,
public async Task<int> ExecuteAsync(IExecutionContext context,
string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> 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<IProcessInvoker>())
{
Channel<string> redirectStandardIn = null;
if (standardInInput != null)
{
redirectStandardIn = Channel.CreateUnbounded<string>(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<ProcessDataReceivedEventArgs> OutputDataReceived;
public event EventHandler<ProcessDataReceivedEventArgs> 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<string> 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<string>();
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<int> ExecuteAsync(string workingDirectory,
public async Task<int> ExecuteAsync(IExecutionContext context,
string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> 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<IContainerHookManager>();
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<IDockerCommandManager>();
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<IProcessInvoker>())
{
@@ -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<string, string> environment)
{
foreach (var envKey in environment.Keys.ToList())
{
environment[envKey] = this.Container.TranslateToContainerPath(environment[envKey]);
}
}
}
}

View File

@@ -60,7 +60,7 @@ namespace GitHub.Runner.Worker
Dictionary<string, string> inputs = new()
{
["path"] = hookData.Path,
["shell"] = ScriptHandlerHelpers.GetDefaultShellNameForScript(hookData.Path, Trace, prependPath)
["shell"] = HostContext.GetDefaultShellForScript(hookData.Path, prependPath)
};
// Create the handler

View File

@@ -56,5 +56,8 @@ namespace GitHub.DistributedTask.WebApi
[DataMember(EmitDefaultValue = false)]
public int? ExecutionTimeInSeconds { get; set; }
[DataMember(EmitDefaultValue = false)]
public string ContainerHookData { get; set; }
}
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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<IExecutionContext>();
_ec.SetupAllProperties();
_ec.Setup(x => x.Global).Returns(new GlobalContext { WriteDebug = true });
_ec.Object.Global.Variables = new Variables(hc, new Dictionary<string, VariableValue>());
var trace = hc.GetTrace();
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { trace.Info($"[{tag}]{message}"); });