mirror of
https://github.com/actions/runner.git
synced 2025-12-13 10:05:23 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user