GitHub Actions Runner

This commit is contained in:
Tingluo Huang
2019-10-10 00:52:42 -04:00
commit c8afc84840
1255 changed files with 198670 additions and 0 deletions

View File

@@ -0,0 +1,527 @@
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(ActionCommandManager))]
public interface IActionCommandManager : IRunnerService
{
void EnablePluginInternalCommand();
void DisablePluginInternalCommand();
bool TryProcessCommand(IExecutionContext context, string input);
}
public sealed class ActionCommandManager : RunnerService, IActionCommandManager
{
private const string _stopCommand = "stop-commands";
private readonly Dictionary<string, IActionCommandExtension> _commandExtensions = new Dictionary<string, IActionCommandExtension>(StringComparer.OrdinalIgnoreCase);
private HashSet<string> _registeredCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly object _commandSerializeLock = new object();
private bool _stopProcessCommand = false;
private string _stopToken = null;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_registeredCommands.Add(_stopCommand);
// Register all command extensions
var extensionManager = hostContext.GetService<IExtensionManager>();
foreach (var commandExt in extensionManager.GetExtensions<IActionCommandExtension>() ?? new List<IActionCommandExtension>())
{
Trace.Info($"Register action command extension for command {commandExt.Command}");
_commandExtensions[commandExt.Command] = commandExt;
if (commandExt.Command != "internal-set-repo-path")
{
_registeredCommands.Add(commandExt.Command);
}
}
}
public void EnablePluginInternalCommand()
{
Trace.Info($"Enable plugin internal command extension.");
_registeredCommands.Add("internal-set-repo-path");
}
public void DisablePluginInternalCommand()
{
Trace.Info($"Disable plugin internal command extension.");
_registeredCommands.Remove("internal-set-repo-path");
}
public bool TryProcessCommand(IExecutionContext context, string input)
{
if (string.IsNullOrEmpty(input))
{
return false;
}
// TryParse input to Command
ActionCommand actionCommand;
if (!ActionCommand.TryParseV2(input, _registeredCommands, out actionCommand) &&
!ActionCommand.TryParse(input, _registeredCommands, out actionCommand))
{
return false;
}
// process action command in serialize oreder.
lock (_commandSerializeLock)
{
if (_stopProcessCommand)
{
if (!string.IsNullOrEmpty(_stopToken) &&
string.Equals(actionCommand.Command, _stopToken, StringComparison.OrdinalIgnoreCase))
{
context.Output(input);
context.Debug("Resume processing commands");
_registeredCommands.Remove(_stopToken);
_stopProcessCommand = false;
_stopToken = null;
return true;
}
else
{
context.Debug($"Process commands has been stopped and waiting for '##[{_stopToken}]' to resume.");
return false;
}
}
else
{
if (string.Equals(actionCommand.Command, _stopCommand, StringComparison.OrdinalIgnoreCase))
{
context.Output(input);
context.Debug("Paused processing commands until '##[{actionCommand.Data}]' is received");
_stopToken = actionCommand.Data;
_stopProcessCommand = true;
_registeredCommands.Add(_stopToken);
return true;
}
else if (_commandExtensions.TryGetValue(actionCommand.Command, out IActionCommandExtension extension))
{
bool omitEcho;
try
{
extension.ProcessCommand(context, input, actionCommand, out omitEcho);
}
catch (Exception ex)
{
omitEcho = true;
context.Output(input);
context.Error($"Unable to process command '{input}' successfully.");
context.Error(ex);
context.CommandResult = TaskResult.Failed;
}
if (!omitEcho)
{
context.Output(input);
context.Debug($"Processed command");
}
}
else
{
context.Warning($"Can't find command extension for ##[{actionCommand.Command}.command].");
}
}
}
return true;
}
}
public interface IActionCommandExtension : IExtension
{
string Command { get; }
void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho);
}
public sealed class InternalPluginSetRepoPathCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "internal-set-repo-path";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
if (!command.Properties.TryGetValue(SetRepoPathCommandProperties.repoFullName, out string repoFullName) || string.IsNullOrEmpty(repoFullName))
{
throw new Exception("Required field 'repoFullName' is missing in ##[internal-set-repo-path] command.");
}
if (!command.Properties.TryGetValue(SetRepoPathCommandProperties.workspaceRepo, out string workspaceRepo) || string.IsNullOrEmpty(workspaceRepo))
{
throw new Exception("Required field 'workspaceRepo' is missing in ##[internal-set-repo-path] command.");
}
var directoryManager = HostContext.GetService<IPipelineDirectoryManager>();
var trackingConfig = directoryManager.UpdateRepositoryDirectory(context, repoFullName, command.Data, StringUtil.ConvertToBoolean(workspaceRepo));
omitEcho = true;
}
private static class SetRepoPathCommandProperties
{
public const String repoFullName = "repoFullName";
public const String workspaceRepo = "workspaceRepo";
}
}
public sealed class SetEnvCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "set-env";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
if (!command.Properties.TryGetValue(SetEnvCommandProperties.Name, out string envName) || string.IsNullOrEmpty(envName))
{
throw new Exception("Required field 'name' is missing in ##[set-env] command.");
}
context.EnvironmentVariables[envName] = command.Data;
context.SetEnvContext(envName, command.Data);
context.Output(line);
context.Debug($"{envName}='{command.Data}'");
omitEcho = true;
}
private static class SetEnvCommandProperties
{
public const String Name = "name";
}
}
public sealed class SetOutputCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "set-output";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
if (!command.Properties.TryGetValue(SetOutputCommandProperties.Name, out string outputName) || string.IsNullOrEmpty(outputName))
{
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
}
context.SetOutput(outputName, command.Data, out var reference);
context.Output(line);
context.Debug($"{reference}='{command.Data}'");
omitEcho = true;
}
private static class SetOutputCommandProperties
{
public const String Name = "name";
}
}
public sealed class SaveStateCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "save-state";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
if (!command.Properties.TryGetValue(SaveStateCommandProperties.Name, out string stateName) || string.IsNullOrEmpty(stateName))
{
throw new Exception("Required field 'name' is missing in ##[save-state] command.");
}
context.IntraActionState[stateName] = command.Data;
context.Debug($"Save intra-action state {stateName} = {command.Data}");
omitEcho = true;
}
private static class SaveStateCommandProperties
{
public const String Name = "name";
}
}
public sealed class AddMaskCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "add-mask";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
if (string.IsNullOrWhiteSpace(command.Data))
{
context.Warning("Can't add secret mask for empty string.");
}
else
{
HostContext.SecretMasker.AddValue(command.Data);
Trace.Info($"Add new secret mask with length of {command.Data.Length}");
}
omitEcho = true;
}
}
public sealed class AddPathCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "add-path";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
ArgUtil.NotNullOrEmpty(command.Data, "path");
context.PrependPath.RemoveAll(x => string.Equals(x, command.Data, StringComparison.CurrentCulture));
context.PrependPath.Add(command.Data);
omitEcho = false;
}
}
public sealed class AddMatcherCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "add-matcher";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
omitEcho = false;
var file = command.Data;
// File is required
if (string.IsNullOrEmpty(file))
{
context.Warning("File path must be specified.");
return;
}
// Translate file path back from container path
if (context.Container != null)
{
file = context.Container.TranslateToHostPath(file);
}
// Root the path
if (!Path.IsPathRooted(file))
{
var githubContext = context.ExpressionValues["github"] as GitHubContext;
ArgUtil.NotNull(githubContext, nameof(githubContext));
var workspace = githubContext["workspace"].ToString();
ArgUtil.NotNullOrEmpty(workspace, "workspace");
file = Path.Combine(workspace, file);
}
// Load the config
var config = IOUtil.LoadObject<IssueMatchersConfig>(file);
// Add
if (config?.Matchers?.Count > 0)
{
config.Validate();
context.AddMatchers(config);
}
}
}
public sealed class RemoveMatcherCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "remove-matcher";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
omitEcho = false;
command.Properties.TryGetValue(RemoveMatcherCommandProperties.Owner, out string owner);
var file = command.Data;
// Owner and file are mutually exclusive
if (!string.IsNullOrEmpty(owner) && !string.IsNullOrEmpty(file))
{
context.Warning("Either specify a matcher owner name or a file path. Both values cannot be set.");
return;
}
// Owner or file is required
if (string.IsNullOrEmpty(owner) && string.IsNullOrEmpty(file))
{
context.Warning("Either a matcher owner name or a file path must be specified.");
return;
}
// Remove by owner
if (!string.IsNullOrEmpty(owner))
{
context.RemoveMatchers(new[] { owner });
}
// Remove by file
else
{
// Translate file path back from container path
if (context.Container != null)
{
file = context.Container.TranslateToHostPath(file);
}
// Root the path
if (!Path.IsPathRooted(file))
{
var githubContext = context.ExpressionValues["github"] as GitHubContext;
ArgUtil.NotNull(githubContext, nameof(githubContext));
var workspace = githubContext["workspace"].ToString();
ArgUtil.NotNullOrEmpty(workspace, "workspace");
file = Path.Combine(workspace, file);
}
// Load the config
var config = IOUtil.LoadObject<IssueMatchersConfig>(file);
if (config?.Matchers?.Count > 0)
{
// Remove
context.RemoveMatchers(config.Matchers.Select(x => x.Owner));
}
}
}
private static class RemoveMatcherCommandProperties
{
public const string Owner = "owner";
}
}
public sealed class DebugCommandExtension : RunnerService, IActionCommandExtension
{
public string Command => "debug";
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string inputLine, ActionCommand command, out bool omitEcho)
{
omitEcho = true;
context.Debug(command.Data);
}
}
public sealed class WarningCommandExtension : IssueCommandExtension
{
public override IssueType Type => IssueType.Warning;
public override string Command => "warning";
}
public sealed class ErrorCommandExtension : IssueCommandExtension
{
public override IssueType Type => IssueType.Error;
public override string Command => "error";
}
public abstract class IssueCommandExtension : RunnerService, IActionCommandExtension
{
public abstract IssueType Type { get; }
public abstract string Command { get; }
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string inputLine, ActionCommand command, out bool omitEcho)
{
omitEcho = true;
command.Properties.TryGetValue(IssueCommandProperties.File, out string file);
command.Properties.TryGetValue(IssueCommandProperties.Line, out string line);
command.Properties.TryGetValue(IssueCommandProperties.Column, out string column);
Issue issue = new Issue()
{
Category = "General",
Type = this.Type,
Message = command.Data
};
if (!string.IsNullOrEmpty(file))
{
issue.Category = "Code";
if (context.Container != null)
{
// Translate file path back from container path
file = context.Container.TranslateToHostPath(file);
command.Properties[IssueCommandProperties.File] = file;
}
// Get the values that represent the server path given a local path
string repoName = context.GetGitHubContext("repository");
var repoPath = context.GetGitHubContext("workspace");
string relativeSourcePath = IOUtil.MakeRelative(file, repoPath);
if (!string.Equals(relativeSourcePath, file, IOUtil.FilePathStringComparison))
{
// add repo info
if (!string.IsNullOrEmpty(repoName))
{
command.Properties["repo"] = repoName;
}
if (!string.IsNullOrEmpty(relativeSourcePath))
{
// replace sourcePath with the new relative path
// prefer `/` on all platforms
command.Properties[IssueCommandProperties.File] = relativeSourcePath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}
}
foreach (var property in command.Properties)
{
issue.Data[property.Key] = property.Value;
}
context.AddIssue(issue);
}
private static class IssueCommandProperties
{
public const String File = "file";
public const String Line = "line";
public const String Column = "col";
}
}
public sealed class GroupCommandExtension : GroupingCommandExtension
{
public override string Command => "group";
}
public sealed class EndGroupCommandExtension : GroupingCommandExtension
{
public override string Command => "endgroup";
}
public abstract class GroupingCommandExtension : RunnerService, IActionCommandExtension
{
public abstract string Command { get; }
public Type ExtensionType => typeof(IActionCommandExtension);
public void ProcessCommand(IExecutionContext context, string line, ActionCommand command, out bool omitEcho)
{
var data = this is GroupCommandExtension ? command.Data : string.Empty;
context.Output($"##[{Command}]{data}");
omitEcho = true;
}
}
}

View File

@@ -0,0 +1,847 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using GitHub.Services.Common;
using Newtonsoft.Json;
using Pipelines = GitHub.DistributedTask.Pipelines;
using PipelineTemplateConstants = GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(ActionManager))]
public interface IActionManager : IRunnerService
{
Dictionary<Guid, ContainerInfo> CachedActionContainers { get; }
Task<List<JobExtensionRunner>> PrepareActionsAsync(IExecutionContext executionContext, IEnumerable<Pipelines.JobStep> steps);
Definition LoadAction(IExecutionContext executionContext, Pipelines.ActionStep action);
}
public sealed class ActionManager : RunnerService, IActionManager
{
private const int _defaultFileStreamBufferSize = 4096;
//81920 is the default used by System.IO.Stream.CopyTo and is under the large object heap threshold (85k).
private const int _defaultCopyBufferSize = 81920;
private readonly Dictionary<Guid, ContainerInfo> _cachedActionContainers = new Dictionary<Guid, ContainerInfo>();
public Dictionary<Guid, ContainerInfo> CachedActionContainers => _cachedActionContainers;
public async Task<List<JobExtensionRunner>> PrepareActionsAsync(IExecutionContext executionContext, IEnumerable<Pipelines.JobStep> steps)
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
ArgUtil.NotNull(steps, nameof(steps));
executionContext.Output("Prepare all required actions");
Dictionary<string, List<Guid>> imagesToPull = new Dictionary<string, List<Guid>>(StringComparer.OrdinalIgnoreCase);
Dictionary<string, List<Guid>> imagesToBuild = new Dictionary<string, List<Guid>>(StringComparer.OrdinalIgnoreCase);
Dictionary<string, ActionContainer> imagesToBuildInfo = new Dictionary<string, ActionContainer>(StringComparer.OrdinalIgnoreCase);
List<JobExtensionRunner> containerSetupSteps = new List<JobExtensionRunner>();
IEnumerable<Pipelines.ActionStep> actions = steps.OfType<Pipelines.ActionStep>();
// TODO: Depreciate the PREVIEW_ACTION_TOKEN
// Log even if we aren't using it to ensure users know.
if (!string.IsNullOrEmpty(executionContext.Variables.Get("PREVIEW_ACTION_TOKEN")))
{
executionContext.Warning("The 'PREVIEW_ACTION_TOKEN' secret is depreciated. Please remove it from the repository's secrets");
}
foreach (var action in actions)
{
if (action.Reference.Type == Pipelines.ActionSourceType.ContainerRegistry)
{
ArgUtil.NotNull(action, nameof(action));
var containerReference = action.Reference as Pipelines.ContainerRegistryReference;
ArgUtil.NotNull(containerReference, nameof(containerReference));
ArgUtil.NotNullOrEmpty(containerReference.Image, nameof(containerReference.Image));
if (!imagesToPull.ContainsKey(containerReference.Image))
{
imagesToPull[containerReference.Image] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) needs to pull image '{containerReference.Image}'");
imagesToPull[containerReference.Image].Add(action.Id);
}
else if (action.Reference.Type == Pipelines.ActionSourceType.Repository)
{
// only download the repository archive
await DownloadRepositoryActionAsync(executionContext, action);
// more preparation base on content in the repository (action.yml)
var setupInfo = PrepareRepositoryActionAsync(executionContext, action);
if (setupInfo != null)
{
if (!string.IsNullOrEmpty(setupInfo.Image))
{
if (!imagesToPull.ContainsKey(setupInfo.Image))
{
imagesToPull[setupInfo.Image] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.ActionRepository}' needs to pull image '{setupInfo.Image}'");
imagesToPull[setupInfo.Image].Add(action.Id);
}
else
{
ArgUtil.NotNullOrEmpty(setupInfo.ActionRepository, nameof(setupInfo.ActionRepository));
if (!imagesToBuild.ContainsKey(setupInfo.ActionRepository))
{
imagesToBuild[setupInfo.ActionRepository] = new List<Guid>();
}
Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.ActionRepository}' needs to build image '{setupInfo.Dockerfile}'");
imagesToBuild[setupInfo.ActionRepository].Add(action.Id);
imagesToBuildInfo[setupInfo.ActionRepository] = setupInfo;
}
}
}
}
if (imagesToPull.Count > 0)
{
foreach (var imageToPull in 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 (imagesToBuild.Count > 0)
{
foreach (var imageToBuild in imagesToBuild)
{
var setupInfo = 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();
}
#endif
return containerSetupSteps;
}
public Definition LoadAction(IExecutionContext executionContext, Pipelines.ActionStep action)
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(action, nameof(action));
// Initialize the definition wrapper object.
var definition = new Definition()
{
Data = new ActionDefinitionData()
};
if (action.Reference.Type == Pipelines.ActionSourceType.ContainerRegistry)
{
Trace.Info("Load action that reference container from registry.");
CachedActionContainers.TryGetValue(action.Id, out var container);
ArgUtil.NotNull(container, nameof(container));
definition.Data.Execution = new ContainerActionExecutionData()
{
Image = container.ContainerImage
};
Trace.Info($"Using action container image: {container.ContainerImage}.");
}
else if (action.Reference.Type == Pipelines.ActionSourceType.Repository)
{
string actionDirectory = null;
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
{
actionDirectory = executionContext.GetGitHubContext("workspace");
if (!string.IsNullOrEmpty(repoAction.Path))
{
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
}
}
else
{
actionDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repoAction.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repoAction.Ref);
if (!string.IsNullOrEmpty(repoAction.Path))
{
actionDirectory = Path.Combine(actionDirectory, repoAction.Path);
}
}
Trace.Info($"Load action that reference repository from '{actionDirectory}'");
definition.Directory = actionDirectory;
string manifestFile = Path.Combine(actionDirectory, "action.yml");
string dockerFile = Path.Combine(actionDirectory, "Dockerfile");
string dockerFileLowerCase = Path.Combine(actionDirectory, "dockerfile");
if (File.Exists(manifestFile))
{
var manifestManager = HostContext.GetService<IActionManifestManager>();
definition.Data = manifestManager.Load(executionContext, manifestFile);
Trace.Verbose($"Action friendly name: '{definition.Data.Name}'");
Trace.Verbose($"Action description: '{definition.Data.Description}'");
if (definition.Data.Inputs != null)
{
foreach (var input in definition.Data.Inputs)
{
Trace.Verbose($"Action input: '{input.Key.ToString()}' default to '{input.Value.ToString()}'");
}
}
if (definition.Data.Execution.ExecutionType == ActionExecutionType.Container)
{
var containerAction = definition.Data.Execution as ContainerActionExecutionData;
Trace.Info($"Action container Dockerfile/image: {containerAction.Image}.");
if (containerAction.Arguments != null)
{
Trace.Info($"Action container args: {StringUtil.ConvertToJson(containerAction.Arguments)}.");
}
if (containerAction.Environment != null)
{
Trace.Info($"Action container env: {StringUtil.ConvertToJson(containerAction.Environment)}.");
}
if (!string.IsNullOrEmpty(containerAction.EntryPoint))
{
Trace.Info($"Action container entrypoint: {containerAction.EntryPoint}.");
}
if (!string.IsNullOrEmpty(containerAction.Cleanup))
{
Trace.Info($"Action container cleanup entrypoint: {containerAction.Cleanup}.");
}
if (CachedActionContainers.TryGetValue(action.Id, out var container))
{
Trace.Info($"Image '{containerAction.Image}' already built/pulled, use image: {container.ContainerImage}.");
containerAction.Image = container.ContainerImage;
}
}
else if (definition.Data.Execution.ExecutionType == ActionExecutionType.NodeJS)
{
var nodeAction = definition.Data.Execution as NodeJSActionExecutionData;
Trace.Info($"Action node.js file: {nodeAction.Script}.");
Trace.Info($"Action cleanup node.js file: {nodeAction.Cleanup ?? "N/A"}.");
}
else if (definition.Data.Execution.ExecutionType == ActionExecutionType.Plugin)
{
var pluginAction = definition.Data.Execution as PluginActionExecutionData;
var pluginManager = HostContext.GetService<IRunnerPluginManager>();
var plugin = pluginManager.GetPluginAction(pluginAction.Plugin);
ArgUtil.NotNull(plugin, pluginAction.Plugin);
ArgUtil.NotNullOrEmpty(plugin.PluginTypeName, pluginAction.Plugin);
pluginAction.Plugin = plugin.PluginTypeName;
Trace.Info($"Action plugin: {plugin.PluginTypeName}.");
if (!string.IsNullOrEmpty(plugin.PostPluginTypeName))
{
pluginAction.Cleanup = plugin.PostPluginTypeName;
Trace.Info($"Action cleanup plugin: {plugin.PluginTypeName}.");
}
}
else
{
throw new NotSupportedException(definition.Data.Execution.ExecutionType.ToString());
}
}
else if (File.Exists(dockerFile))
{
if (CachedActionContainers.TryGetValue(action.Id, out var container))
{
definition.Data.Execution = new ContainerActionExecutionData()
{
Image = container.ContainerImage
};
}
else
{
definition.Data.Execution = new ContainerActionExecutionData()
{
Image = dockerFile
};
}
}
else if (File.Exists(dockerFileLowerCase))
{
if (CachedActionContainers.TryGetValue(action.Id, out var container))
{
definition.Data.Execution = new ContainerActionExecutionData()
{
Image = container.ContainerImage
};
}
else
{
definition.Data.Execution = new ContainerActionExecutionData()
{
Image = dockerFileLowerCase
};
}
}
else
{
var fullPath = IOUtil.ResolvePath(actionDirectory, "."); // resolve full path without access filesystem.
throw new NotSupportedException($"Can't find 'action.yml' or 'Dockerfile' under '{fullPath}'. Did you forget to run actions/checkout before running your local action?");
}
}
else if (action.Reference.Type == Pipelines.ActionSourceType.Script)
{
definition.Data.Execution = new ScriptActionExecutionData();
definition.Data.Name = "Run";
definition.Data.Description = "Execute a script";
}
else
{
throw new NotSupportedException(action.Reference.Type.ToString());
}
return definition;
}
private async Task PullActionContainerAsync(IExecutionContext executionContext, object data)
{
var setupInfo = data as ContainerSetupInfo;
ArgUtil.NotNull(setupInfo, nameof(setupInfo));
ArgUtil.NotNullOrEmpty(setupInfo.Container.Image, nameof(setupInfo.Container.Image));
executionContext.Output($"Pull down action image '{setupInfo.Container.Image}'");
// Pull down docker image with retry up to 3 times
var dockerManger = HostContext.GetService<IDockerCommandManager>();
int retryCount = 0;
int pullExitCode = 0;
while (retryCount < 3)
{
pullExitCode = await dockerManger.DockerPull(executionContext, setupInfo.Container.Image);
if (pullExitCode == 0)
{
break;
}
else
{
retryCount++;
if (retryCount < 3)
{
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
executionContext.Warning($"Docker pull failed with exit code {pullExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
await Task.Delay(backOff);
}
}
}
if (retryCount == 3 && pullExitCode != 0)
{
throw new InvalidOperationException($"Docker pull failed with exit code {pullExitCode}");
}
foreach (var stepId in setupInfo.StepIds)
{
CachedActionContainers[stepId] = new ContainerInfo() { ContainerImage = setupInfo.Container.Image };
Trace.Info($"Prepared docker image '{setupInfo.Container.Image}' for action {stepId} ({setupInfo.Container.Image})");
}
}
private async Task BuildActionContainerAsync(IExecutionContext executionContext, object data)
{
var setupInfo = data as ContainerSetupInfo;
ArgUtil.NotNull(setupInfo, nameof(setupInfo));
ArgUtil.NotNullOrEmpty(setupInfo.Container.Dockerfile, nameof(setupInfo.Container.Dockerfile));
executionContext.Output($"Build container for action use: '{setupInfo.Container.Dockerfile}'.");
// Build docker image with retry up to 3 times
var dockerManger = HostContext.GetService<IDockerCommandManager>();
int retryCount = 0;
int buildExitCode = 0;
var imageName = $"{dockerManger.DockerInstanceLabel}:{Guid.NewGuid().ToString("N")}";
while (retryCount < 3)
{
buildExitCode = await dockerManger.DockerBuild(executionContext, setupInfo.Container.WorkingDirectory, Directory.GetParent(setupInfo.Container.Dockerfile).FullName, imageName);
if (buildExitCode == 0)
{
break;
}
else
{
retryCount++;
if (retryCount < 3)
{
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
executionContext.Warning($"Docker build failed with exit code {buildExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
await Task.Delay(backOff);
}
}
}
if (retryCount == 3 && buildExitCode != 0)
{
throw new InvalidOperationException($"Docker build failed with exit code {buildExitCode}");
}
foreach (var stepId in setupInfo.StepIds)
{
CachedActionContainers[stepId] = new ContainerInfo() { ContainerImage = imageName };
Trace.Info($"Prepared docker image '{imageName}' for action {stepId} ({setupInfo.Container.Dockerfile})");
}
}
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference;
ArgUtil.NotNull(repositoryReference, nameof(repositoryReference));
if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
{
Trace.Info($"Repository action is in 'self' repository.");
return;
}
if (!string.Equals(repositoryReference.RepositoryType, Pipelines.RepositoryTypes.GitHub, StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException(repositoryReference.RepositoryType);
}
ArgUtil.NotNullOrEmpty(repositoryReference.Name, nameof(repositoryReference.Name));
ArgUtil.NotNullOrEmpty(repositoryReference.Ref, nameof(repositoryReference.Ref));
string destDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repositoryReference.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repositoryReference.Ref);
if (File.Exists(destDirectory + ".completed"))
{
executionContext.Debug($"Action '{repositoryReference.Name}@{repositoryReference.Ref}' already downloaded at '{destDirectory}'.");
return;
}
else
{
// make sure we get an clean folder ready to use.
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
Directory.CreateDirectory(destDirectory);
executionContext.Output($"Download action repository '{repositoryReference.Name}@{repositoryReference.Ref}'");
}
#if OS_WINDOWS
string archiveLink = $"https://api.github.com/repos/{repositoryReference.Name}/zipball/{repositoryReference.Ref}";
#else
string archiveLink = $"https://api.github.com/repos/{repositoryReference.Name}/tarball/{repositoryReference.Ref}";
#endif
Trace.Info($"Download archive '{archiveLink}' to '{destDirectory}'.");
//download and extract action in a temp folder and rename it on success
string tempDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), "_temp_" + Guid.NewGuid());
Directory.CreateDirectory(tempDirectory);
#if OS_WINDOWS
string archiveFile = Path.Combine(tempDirectory, $"{Guid.NewGuid()}.zip");
#else
string archiveFile = Path.Combine(tempDirectory, $"{Guid.NewGuid()}.tar.gz");
#endif
Trace.Info($"Save archive '{archiveLink}' into {archiveFile}.");
try
{
int retryCount = 0;
// Allow up to 20 * 60s for any action to be downloaded from github graph.
int timeoutSeconds = 20 * 60;
while (retryCount < 3)
{
using (var actionDownloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
using (var actionDownloadCancellation = CancellationTokenSource.CreateLinkedTokenSource(actionDownloadTimeout.Token, executionContext.CancellationToken))
{
try
{
//open zip stream in async mode
using (FileStream fs = new FileStream(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true))
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
using (var httpClient = new HttpClient(httpClientHandler))
{
var authToken = Environment.GetEnvironmentVariable("_GITHUB_ACTION_TOKEN");
if (string.IsNullOrEmpty(authToken))
{
// TODO: Depreciate the PREVIEW_ACTION_TOKEN
authToken = executionContext.Variables.Get("PREVIEW_ACTION_TOKEN");
}
if (!string.IsNullOrEmpty(authToken))
{
HostContext.SecretMasker.AddValue(authToken);
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"PAT:{authToken}"));
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodingToken);
}
else
{
var accessToken = executionContext.GetGitHubContext("token");
var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{accessToken}"));
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64EncodingToken);
}
httpClient.DefaultRequestHeaders.UserAgent.Add(HostContext.UserAgent);
using (var result = await httpClient.GetStreamAsync(archiveLink))
{
await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token);
await fs.FlushAsync(actionDownloadCancellation.Token);
// download succeed, break out the retry loop.
break;
}
}
}
catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested)
{
Trace.Info($"Action download has been cancelled.");
throw;
}
catch (Exception ex) when (retryCount < 2)
{
retryCount++;
Trace.Error($"Fail to download archive '{archiveLink}' -- Attempt: {retryCount}");
Trace.Error(ex);
if (actionDownloadTimeout.Token.IsCancellationRequested)
{
// action download didn't finish within timeout
executionContext.Warning($"Action '{archiveLink}' didn't finish download within {timeoutSeconds} seconds.");
}
else
{
executionContext.Warning($"Failed to download action '{archiveLink}'. Error {ex.Message}");
}
}
}
if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF")))
{
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
executionContext.Warning($"Back off {backOff.TotalSeconds} seconds before retry.");
await Task.Delay(backOff);
}
}
ArgUtil.NotNullOrEmpty(archiveFile, nameof(archiveFile));
executionContext.Debug($"Download '{archiveLink}' to '{archiveFile}'");
var stagingDirectory = Path.Combine(tempDirectory, "_staging");
Directory.CreateDirectory(stagingDirectory);
#if OS_WINDOWS
ZipFile.ExtractToDirectory(archiveFile, stagingDirectory);
#else
string tar = WhichUtil.Which("tar", require: true, trace: Trace);
// tar -xzf
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
{
processInvoker.OutputDataReceived += new EventHandler<ProcessDataReceivedEventArgs>((sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
Trace.Info(args.Data);
}
});
processInvoker.ErrorDataReceived += new EventHandler<ProcessDataReceivedEventArgs>((sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
Trace.Error(args.Data);
}
});
int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken);
if (exitCode != 0)
{
throw new NotSupportedException($"Can't use 'tar -xzf' extract archive file: {archiveFile}. return code: {exitCode}.");
}
}
#endif
// repository archive from github always contains a nested folder
var subDirectories = new DirectoryInfo(stagingDirectory).GetDirectories();
if (subDirectories.Length != 1)
{
throw new InvalidOperationException($"'{archiveFile}' contains '{subDirectories.Length}' directories");
}
else
{
executionContext.Debug($"Unwrap '{subDirectories[0].Name}' to '{destDirectory}'");
IOUtil.CopyDirectory(subDirectories[0].FullName, destDirectory, executionContext.CancellationToken);
}
Trace.Verbose("Create watermark file indicate action download succeed.");
File.WriteAllText(destDirectory + ".completed", DateTime.UtcNow.ToString());
executionContext.Debug($"Archive '{archiveFile}' has been unzipped into '{destDirectory}'.");
Trace.Info("Finished getting action repository.");
}
finally
{
try
{
//if the temp folder wasn't moved -> wipe it
if (Directory.Exists(tempDirectory))
{
Trace.Verbose("Deleting action temp folder: {0}", tempDirectory);
IOUtil.DeleteDirectory(tempDirectory, CancellationToken.None); // Don't cancel this cleanup and should be pretty fast.
}
}
catch (Exception ex)
{
//it is not critical if we fail to delete the temp folder
Trace.Warning("Failed to delete temp folder '{0}'. Exception: {1}", tempDirectory, ex);
}
}
}
private ActionContainer PrepareRepositoryActionAsync(IExecutionContext executionContext, Pipelines.ActionStep repositoryAction)
{
var repositoryReference = repositoryAction.Reference as Pipelines.RepositoryPathReference;
if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
{
Trace.Info($"Repository action is in 'self' repository.");
return null;
}
var setupInfo = new ActionContainer();
string destDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), repositoryReference.Name.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), repositoryReference.Ref);
string actionEntryDirectory = destDirectory;
string dockerFileRelativePath = repositoryReference.Name;
ArgUtil.NotNull(repositoryReference, nameof(repositoryReference));
if (!string.IsNullOrEmpty(repositoryReference.Path))
{
actionEntryDirectory = Path.Combine(destDirectory, repositoryReference.Path);
dockerFileRelativePath = $"{dockerFileRelativePath}/{repositoryReference.Path}";
setupInfo.ActionRepository = $"{repositoryReference.Name}/{repositoryReference.Path}@{repositoryReference.Ref}";
}
else
{
setupInfo.ActionRepository = $"{repositoryReference.Name}@{repositoryReference.Ref}";
}
// find the docker file or action.yml file
var dockerFile = Path.Combine(actionEntryDirectory, "Dockerfile");
var dockerFileLowerCase = Path.Combine(actionEntryDirectory, "dockerfile");
var actionManifest = Path.Combine(actionEntryDirectory, "action.yml");
if (File.Exists(actionManifest))
{
executionContext.Debug($"action.yml for action: '{actionManifest}'.");
var manifestManager = HostContext.GetService<IActionManifestManager>();
var actionDefinitionData = manifestManager.Load(executionContext, actionManifest);
if (actionDefinitionData.Execution.ExecutionType == ActionExecutionType.Container)
{
var containerAction = actionDefinitionData.Execution as ContainerActionExecutionData;
if (containerAction.Image.EndsWith("Dockerfile") || containerAction.Image.EndsWith("dockerfile"))
{
var dockerFileFullPath = Path.Combine(actionEntryDirectory, containerAction.Image);
executionContext.Debug($"Dockerfile for action: '{dockerFileFullPath}'.");
setupInfo.Dockerfile = dockerFileFullPath;
setupInfo.WorkingDirectory = destDirectory;
return setupInfo;
}
else if (containerAction.Image.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
{
var actionImage = containerAction.Image.Substring("docker://".Length);
executionContext.Debug($"Container image for action: '{actionImage}'.");
setupInfo.Image = actionImage;
return setupInfo;
}
else
{
throw new NotSupportedException($"'{containerAction.Image}' should be either '[path]/Dockerfile' or 'docker://image[:tag]'.");
}
}
else if (actionDefinitionData.Execution.ExecutionType == ActionExecutionType.NodeJS)
{
Trace.Info($"Action node.js file: {(actionDefinitionData.Execution as NodeJSActionExecutionData).Script}, no more preparation.");
return null;
}
else if (actionDefinitionData.Execution.ExecutionType == ActionExecutionType.Plugin)
{
Trace.Info($"Action plugin: {(actionDefinitionData.Execution as PluginActionExecutionData).Plugin}, no more preparation.");
return null;
}
else
{
throw new NotSupportedException(actionDefinitionData.Execution.ExecutionType.ToString());
}
}
else if (File.Exists(dockerFile))
{
executionContext.Debug($"Dockerfile for action: '{dockerFile}'.");
setupInfo.Dockerfile = dockerFile;
setupInfo.WorkingDirectory = destDirectory;
return setupInfo;
}
else if (File.Exists(dockerFileLowerCase))
{
executionContext.Debug($"Dockerfile for action: '{dockerFileLowerCase}'.");
setupInfo.Dockerfile = dockerFileLowerCase;
setupInfo.WorkingDirectory = destDirectory;
return setupInfo;
}
else
{
var fullPath = IOUtil.ResolvePath(actionEntryDirectory, "."); // resolve full path without access filesystem.
throw new InvalidOperationException($"Can't find 'action.yml' or 'Dockerfile' under '{fullPath}'. Did you forget to run actions/checkout before running your local action?");
}
}
}
public sealed class Definition
{
public ActionDefinitionData Data { get; set; }
public string Directory { get; set; }
}
public sealed class ActionDefinitionData
{
public string Name { get; set; }
public string Description { get; set; }
public MappingToken Inputs { get; set; }
public ActionExecutionData Execution { get; set; }
public Dictionary<String, String> Deprecated { get; set; }
}
public enum ActionExecutionType
{
Container,
NodeJS,
Plugin,
Script,
}
public sealed class ContainerActionExecutionData : ActionExecutionData
{
public override ActionExecutionType ExecutionType => ActionExecutionType.Container;
public override bool HasCleanup => !string.IsNullOrEmpty(Cleanup);
public string Image { get; set; }
public string EntryPoint { get; set; }
public SequenceToken Arguments { get; set; }
public MappingToken Environment { get; set; }
public string Cleanup { get; set; }
}
public sealed class NodeJSActionExecutionData : ActionExecutionData
{
public override ActionExecutionType ExecutionType => ActionExecutionType.NodeJS;
public override bool HasCleanup => !string.IsNullOrEmpty(Cleanup);
public string Script { get; set; }
public string Cleanup { get; set; }
}
public sealed class PluginActionExecutionData : ActionExecutionData
{
public override ActionExecutionType ExecutionType => ActionExecutionType.Plugin;
public override bool HasCleanup => !string.IsNullOrEmpty(Cleanup);
public string Plugin { get; set; }
public string Cleanup { get; set; }
}
public sealed class ScriptActionExecutionData : ActionExecutionData
{
public override ActionExecutionType ExecutionType => ActionExecutionType.Script;
public override bool HasCleanup => false;
}
public abstract class ActionExecutionData
{
private string _cleanupCondition = $"{Constants.Expressions.Always}()";
public abstract ActionExecutionType ExecutionType { get; }
public abstract bool HasCleanup { get; }
public string CleanupCondition
{
get { return _cleanupCondition; }
set { _cleanupCondition = value; }
}
}
public class ContainerSetupInfo
{
public ContainerSetupInfo(List<Guid> ids, string image)
{
StepIds = ids;
Container = new ActionContainer()
{
Image = image
};
}
public ContainerSetupInfo(List<Guid> ids, string dockerfile, string workingDirectory)
{
StepIds = ids;
Container = new ActionContainer()
{
Dockerfile = dockerfile,
WorkingDirectory = workingDirectory
};
}
public List<Guid> StepIds { get; set; }
public ActionContainer Container { get; set; }
}
public class ActionContainer
{
public string Image { get; set; }
public string Dockerfile { get; set; }
public string WorkingDirectory { get; set; }
public string ActionRepository { get; set; }
}
}

View File

@@ -0,0 +1,980 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using System.Reflection;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Schema;
using GitHub.DistributedTask.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines.ContextData;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using System.Globalization;
using System.Linq;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(ActionManifestManager))]
public interface IActionManifestManager : IRunnerService
{
ActionDefinitionData Load(IExecutionContext executionContext, string manifestFile);
List<string> EvaluateContainerArguments(IExecutionContext executionContext, SequenceToken token, IDictionary<string, PipelineContextData> contextData);
Dictionary<string, string> EvaluateContainerEnvironment(IExecutionContext executionContext, MappingToken token, IDictionary<string, PipelineContextData> contextData);
string EvaluateDefaultInput(IExecutionContext executionContext, string inputName, TemplateToken token, IDictionary<string, PipelineContextData> contextData);
}
public sealed class ActionManifestManager : RunnerService, IActionManifestManager
{
private TemplateSchema _actionManifestSchema;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
var assembly = Assembly.GetExecutingAssembly();
var json = default(string);
using (var stream = assembly.GetManifestResourceStream("GitHub.Runner.Worker.action_yaml.json"))
using (var streamReader = new StreamReader(stream))
{
json = streamReader.ReadToEnd();
}
var objectReader = new JsonObjectReader(null, json);
_actionManifestSchema = TemplateSchema.Load(objectReader);
ArgUtil.NotNull(_actionManifestSchema, nameof(_actionManifestSchema));
Trace.Info($"Load schema file with definitions: {StringUtil.ConvertToJson(_actionManifestSchema.Definitions.Keys)}");
}
public ActionDefinitionData Load(IExecutionContext executionContext, string manifestFile)
{
var context = CreateContext(executionContext, null);
ActionDefinitionData actionDefinition = new ActionDefinitionData();
try
{
var token = default(TemplateToken);
// Get the file ID
var fileId = context.GetFileId(manifestFile);
var fileContent = File.ReadAllText(manifestFile);
using (var stringReader = new StringReader(fileContent))
{
var yamlObjectReader = new YamlObjectReader(null, stringReader);
token = TemplateReader.Read(context, "action-root", yamlObjectReader, fileId, out _);
}
var actionMapping = token.AssertMapping("action manifest root");
foreach (var actionPair in actionMapping)
{
var propertyName = actionPair.Key.AssertString($"action.yml property key");
switch (propertyName.Value)
{
case "name":
actionDefinition.Name = actionPair.Value.AssertString("name").Value;
break;
case "description":
actionDefinition.Description = actionPair.Value.AssertString("description").Value;
break;
case "inputs":
ConvertInputs(context, actionPair.Value, actionDefinition);
break;
case "runs":
actionDefinition.Execution = ConvertRuns(context, actionPair.Value);
break;
default:
Trace.Info($"Ignore action property {propertyName}.");
break;
}
}
}
catch (Exception ex)
{
Trace.Error(ex);
context.Errors.Add(ex);
}
if (context.Errors.Count > 0)
{
foreach (var error in context.Errors)
{
Trace.Error($"Action.yml load error: {error.Message}");
executionContext.Error(error.Message);
}
throw new ArgumentException($"Fail to load {manifestFile}");
}
if (actionDefinition.Execution == null)
{
executionContext.Debug($"Loaded action.yml file: {StringUtil.ConvertToJson(actionDefinition)}");
throw new ArgumentException($"Top level 'run:' section is required for {manifestFile}");
}
else
{
Trace.Info($"Loaded action.yml file: {StringUtil.ConvertToJson(actionDefinition)}");
}
return actionDefinition;
}
public List<string> EvaluateContainerArguments(
IExecutionContext executionContext,
SequenceToken token,
IDictionary<string, PipelineContextData> contextData)
{
var result = new List<string>();
if (token != null)
{
var context = CreateContext(executionContext, contextData);
try
{
var evaluateResult = TemplateEvaluator.Evaluate(context, "container-runs-args", token, 0, null, omitHeader: true);
context.Errors.Check();
Trace.Info($"Arguments evaluate result: {StringUtil.ConvertToJson(evaluateResult)}");
// Sequence
var args = evaluateResult.AssertSequence("container args");
foreach (var arg in args)
{
var str = arg.AssertString("container arg").Value;
result.Add(str);
Trace.Info($"Add argument {str}");
}
}
catch (Exception ex) when (!(ex is TemplateValidationException))
{
Trace.Error(ex);
context.Errors.Add(ex);
}
context.Errors.Check();
}
return result;
}
public Dictionary<string, string> EvaluateContainerEnvironment(
IExecutionContext executionContext,
MappingToken token,
IDictionary<string, PipelineContextData> contextData)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (token != null)
{
var context = CreateContext(executionContext, contextData);
try
{
var evaluateResult = TemplateEvaluator.Evaluate(context, "container-runs-env", token, 0, null, omitHeader: true);
context.Errors.Check();
Trace.Info($"Environments evaluate result: {StringUtil.ConvertToJson(evaluateResult)}");
// Mapping
var mapping = evaluateResult.AssertMapping("container env");
foreach (var pair in mapping)
{
// Literal key
var key = pair.Key.AssertString("container env key");
// Literal value
var value = pair.Value.AssertString("container env value");
result[key.Value] = value.Value;
Trace.Info($"Add env {key} = {value}");
}
}
catch (Exception ex) when (!(ex is TemplateValidationException))
{
Trace.Error(ex);
context.Errors.Add(ex);
}
context.Errors.Check();
}
return result;
}
public string EvaluateDefaultInput(
IExecutionContext executionContext,
string inputName,
TemplateToken token,
IDictionary<string, PipelineContextData> contextData)
{
string result = "";
if (token != null)
{
var context = CreateContext(executionContext, contextData);
try
{
var evaluateResult = TemplateEvaluator.Evaluate(context, "input-default-context", token, 0, null, omitHeader: true);
context.Errors.Check();
Trace.Info($"Input '{inputName}': default value evaluate result: {StringUtil.ConvertToJson(evaluateResult)}");
// String
result = evaluateResult.AssertString($"default value for input '{inputName}'").Value;
}
catch (Exception ex) when (!(ex is TemplateValidationException))
{
Trace.Error(ex);
context.Errors.Add(ex);
}
context.Errors.Check();
}
return result;
}
private TemplateContext CreateContext(
IExecutionContext executionContext,
IDictionary<string, PipelineContextData> contextData)
{
var result = new TemplateContext
{
CancellationToken = CancellationToken.None,
Errors = new TemplateValidationErrors(10, 500),
Memory = new TemplateMemory(
maxDepth: 100,
maxEvents: 1000000,
maxBytes: 10 * 1024 * 1024),
Schema = _actionManifestSchema,
TraceWriter = executionContext.ToTemplateTraceWriter(),
};
if (contextData?.Count > 0)
{
foreach (var pair in contextData)
{
result.ExpressionValues[pair.Key] = pair.Value;
}
}
return result;
}
private ActionExecutionData ConvertRuns(
TemplateContext context,
TemplateToken inputsToken)
{
var runsMapping = inputsToken.AssertMapping("runs");
var usingToken = default(StringToken);
var imageToken = default(StringToken);
var argsToken = default(SequenceToken);
var entrypointToken = default(StringToken);
var envToken = default(MappingToken);
var mainToken = default(StringToken);
var pluginToken = default(StringToken);
var postToken = default(StringToken);
var postEntrypointToken = default(StringToken);
var postIfToken = default(StringToken);
foreach (var run in runsMapping)
{
var runsKey = run.Key.AssertString("runs key").Value;
switch (runsKey)
{
case "using":
usingToken = run.Value.AssertString("using");
break;
case "image":
imageToken = run.Value.AssertString("image");
break;
case "args":
argsToken = run.Value.AssertSequence("args");
break;
case "entrypoint":
entrypointToken = run.Value.AssertString("entrypoint");
break;
case "env":
envToken = run.Value.AssertMapping("env");
break;
case "main":
mainToken = run.Value.AssertString("main");
break;
case "plugin":
pluginToken = run.Value.AssertString("plugin");
break;
case "post":
postToken = run.Value.AssertString("post");
break;
case "post-entrypoint":
postEntrypointToken = run.Value.AssertString("post-entrypoint");
break;
case "post-if":
postIfToken = run.Value.AssertString("post-if");
break;
default:
Trace.Info($"Ignore run property {runsKey}.");
break;
}
}
if (usingToken != null)
{
if (string.Equals(usingToken.Value, "docker", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrEmpty(imageToken?.Value))
{
throw new ArgumentNullException($"Image is not provided.");
}
else
{
return new ContainerActionExecutionData()
{
Image = imageToken.Value,
Arguments = argsToken,
EntryPoint = entrypointToken?.Value,
Environment = envToken,
Cleanup = postEntrypointToken?.Value,
CleanupCondition = postIfToken?.Value
};
}
}
else if (string.Equals(usingToken.Value, "node12", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrEmpty(mainToken?.Value))
{
throw new ArgumentNullException($"Entry javascript fils is not provided.");
}
else
{
return new NodeJSActionExecutionData()
{
Script = mainToken.Value,
Cleanup = postToken?.Value,
CleanupCondition = postIfToken?.Value
};
}
}
else
{
throw new ArgumentOutOfRangeException($"'using: {usingToken.Value}' is not supported, use 'docker' or 'node12' instead.");
}
}
else if (pluginToken != null)
{
return new PluginActionExecutionData()
{
Plugin = pluginToken.Value
};
}
throw new NotSupportedException(nameof(ConvertRuns));
}
private void ConvertInputs(
TemplateContext context,
TemplateToken inputsToken,
ActionDefinitionData actionDefinition)
{
actionDefinition.Inputs = new MappingToken(null, null, null);
var inputsMapping = inputsToken.AssertMapping("inputs");
foreach (var input in inputsMapping)
{
bool hasDefault = false;
var inputName = input.Key.AssertString("input name");
var inputMetadata = input.Value.AssertMapping("input metadata");
foreach (var metadata in inputMetadata)
{
var metadataName = metadata.Key.AssertString("input metadata").Value;
if (string.Equals(metadataName, "default", StringComparison.OrdinalIgnoreCase))
{
hasDefault = true;
actionDefinition.Inputs.Add(inputName, metadata.Value);
}
else if (string.Equals(metadataName, "deprecationMessage", StringComparison.OrdinalIgnoreCase))
{
if (actionDefinition.Deprecated == null)
{
actionDefinition.Deprecated = new Dictionary<String, String>();
}
var message = metadata.Value.AssertString("input deprecationMessage");
actionDefinition.Deprecated.Add(inputName.Value, message.Value);
}
}
if (!hasDefault)
{
actionDefinition.Inputs.Add(inputName, new StringToken(null, null, null, string.Empty));
}
}
}
}
/// <summary>
/// Converts a YAML file into a TemplateToken
/// </summary>
internal sealed class YamlObjectReader : IObjectReader
{
internal YamlObjectReader(
Int32? fileId,
TextReader input)
{
m_fileId = fileId;
m_parser = new Parser(input);
}
public Boolean AllowLiteral(out LiteralToken value)
{
if (EvaluateCurrent() is Scalar scalar)
{
// Tag specified
if (!string.IsNullOrEmpty(scalar.Tag))
{
// String tag
if (string.Equals(scalar.Tag, c_stringTag, StringComparison.Ordinal))
{
value = new StringToken(m_fileId, scalar.Start.Line, scalar.Start.Column, scalar.Value);
MoveNext();
return true;
}
// Not plain style
if (scalar.Style != ScalarStyle.Plain)
{
throw new NotSupportedException($"The scalar style '{scalar.Style}' on line {scalar.Start.Line} and column {scalar.Start.Column} is not valid with the tag '{scalar.Tag}'");
}
// Boolean, Float, Integer, or Null
switch (scalar.Tag)
{
case c_booleanTag:
value = ParseBoolean(scalar);
break;
case c_floatTag:
value = ParseFloat(scalar);
break;
case c_integerTag:
value = ParseInteger(scalar);
break;
case c_nullTag:
value = ParseNull(scalar);
break;
default:
throw new NotSupportedException($"Unexpected tag '{scalar.Tag}'");
}
MoveNext();
return true;
}
// Plain style, determine type using YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923
if (scalar.Style == ScalarStyle.Plain)
{
if (MatchNull(scalar, out var nullToken))
{
value = nullToken;
}
else if (MatchBoolean(scalar, out var booleanToken))
{
value = booleanToken;
}
else if (MatchInteger(scalar, out var numberToken) ||
MatchFloat(scalar, out numberToken))
{
value = numberToken;
}
else
{
value = new StringToken(m_fileId, scalar.Start.Line, scalar.Start.Column, scalar.Value);
}
MoveNext();
return true;
}
// Otherwise assume string
value = new StringToken(m_fileId, scalar.Start.Line, scalar.Start.Column, scalar.Value);
MoveNext();
return true;
}
value = default;
return false;
}
public Boolean AllowSequenceStart(out SequenceToken value)
{
if (EvaluateCurrent() is SequenceStart sequenceStart)
{
value = new SequenceToken(m_fileId, sequenceStart.Start.Line, sequenceStart.Start.Column);
MoveNext();
return true;
}
value = default;
return false;
}
public Boolean AllowSequenceEnd()
{
if (EvaluateCurrent() is SequenceEnd)
{
MoveNext();
return true;
}
return false;
}
public Boolean AllowMappingStart(out MappingToken value)
{
if (EvaluateCurrent() is MappingStart mappingStart)
{
value = new MappingToken(m_fileId, mappingStart.Start.Line, mappingStart.Start.Column);
MoveNext();
return true;
}
value = default;
return false;
}
public Boolean AllowMappingEnd()
{
if (EvaluateCurrent() is MappingEnd)
{
MoveNext();
return true;
}
return false;
}
/// <summary>
/// Consumes the last parsing events, which are expected to be DocumentEnd and StreamEnd.
/// </summary>
public void ValidateEnd()
{
if (EvaluateCurrent() is DocumentEnd)
{
MoveNext();
}
else
{
throw new InvalidOperationException("Expected document end parse event");
}
if (EvaluateCurrent() is StreamEnd)
{
MoveNext();
}
else
{
throw new InvalidOperationException("Expected stream end parse event");
}
if (MoveNext())
{
throw new InvalidOperationException("Expected end of parse events");
}
}
/// <summary>
/// Consumes the first parsing events, which are expected to be StreamStart and DocumentStart.
/// </summary>
public void ValidateStart()
{
if (EvaluateCurrent() != null)
{
throw new InvalidOperationException("Unexpected parser state");
}
if (!MoveNext())
{
throw new InvalidOperationException("Expected a parse event");
}
if (EvaluateCurrent() is StreamStart)
{
MoveNext();
}
else
{
throw new InvalidOperationException("Expected stream start parse event");
}
if (EvaluateCurrent() is DocumentStart)
{
MoveNext();
}
else
{
throw new InvalidOperationException("Expected document start parse event");
}
}
private ParsingEvent EvaluateCurrent()
{
if (m_current == null)
{
m_current = m_parser.Current;
if (m_current != null)
{
if (m_current is Scalar scalar)
{
// Verify not using achors
if (scalar.Anchor != null)
{
throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{scalar.Anchor}'");
}
}
else if (m_current is MappingStart mappingStart)
{
// Verify not using achors
if (mappingStart.Anchor != null)
{
throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{mappingStart.Anchor}'");
}
}
else if (m_current is SequenceStart sequenceStart)
{
// Verify not using achors
if (sequenceStart.Anchor != null)
{
throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{sequenceStart.Anchor}'");
}
}
else if (!(m_current is MappingEnd) &&
!(m_current is SequenceEnd) &&
!(m_current is DocumentStart) &&
!(m_current is DocumentEnd) &&
!(m_current is StreamStart) &&
!(m_current is StreamEnd))
{
throw new InvalidOperationException($"Unexpected parsing event type: {m_current.GetType().Name}");
}
}
}
return m_current;
}
private Boolean MoveNext()
{
m_current = null;
return m_parser.MoveNext();
}
private BooleanToken ParseBoolean(Scalar scalar)
{
if (MatchBoolean(scalar, out var token))
{
return token;
}
ThrowInvalidValue(scalar, c_booleanTag); // throws
return default;
}
private NumberToken ParseFloat(Scalar scalar)
{
if (MatchFloat(scalar, out var token))
{
return token;
}
ThrowInvalidValue(scalar, c_floatTag); // throws
return default;
}
private NumberToken ParseInteger(Scalar scalar)
{
if (MatchInteger(scalar, out var token))
{
return token;
}
ThrowInvalidValue(scalar, c_integerTag); // throws
return default;
}
private NullToken ParseNull(Scalar scalar)
{
if (MatchNull(scalar, out var token))
{
return token;
}
ThrowInvalidValue(scalar, c_nullTag); // throws
return default;
}
private Boolean MatchBoolean(
Scalar scalar,
out BooleanToken value)
{
// YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923
switch (scalar.Value ?? string.Empty)
{
case "true":
case "True":
case "TRUE":
value = new BooleanToken(m_fileId, scalar.Start.Line, scalar.Start.Column, true);
return true;
case "false":
case "False":
case "FALSE":
value = new BooleanToken(m_fileId, scalar.Start.Line, scalar.Start.Column, false);
return true;
}
value = default;
return false;
}
private Boolean MatchFloat(
Scalar scalar,
out NumberToken value)
{
// YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923
var str = scalar.Value;
if (!string.IsNullOrEmpty(str))
{
// Check for [-+]?(\.inf|\.Inf|\.INF)|\.nan|\.NaN|\.NAN
switch (str)
{
case ".inf":
case ".Inf":
case ".INF":
case "+.inf":
case "+.Inf":
case "+.INF":
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, Double.PositiveInfinity);
return true;
case "-.inf":
case "-.Inf":
case "-.INF":
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, Double.NegativeInfinity);
return true;
case ".nan":
case ".NaN":
case ".NAN":
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, Double.NaN);
return true;
}
// Otherwise check [-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?
// Skip leading sign
var index = str[0] == '-' || str[0] == '+' ? 1 : 0;
// Check for integer portion
var length = str.Length;
var hasInteger = false;
while (index < length && str[index] >= '0' && str[index] <= '9')
{
hasInteger = true;
index++;
}
// Check for decimal point
var hasDot = false;
if (index < length && str[index] == '.')
{
hasDot = true;
index++;
}
// Check for decimal portion
var hasDecimal = false;
while (index < length && str[index] >= '0' && str[index] <= '9')
{
hasDecimal = true;
index++;
}
// Check [-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)
if ((hasDot && hasDecimal) || hasInteger)
{
// Check for end
if (index == length)
{
// Try parse
if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var doubleValue))
{
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, doubleValue);
return true;
}
// Otherwise exceeds range
else
{
ThrowInvalidValue(scalar, c_floatTag); // throws
}
}
// Check [eE][-+]?[0-9]
else if (index < length && (str[index] == 'e' || str[index] == 'E'))
{
index++;
// Skip sign
if (index < length && (str[index] == '-' || str[index] == '+'))
{
index++;
}
// Check for exponent
var hasExponent = false;
while (index < length && str[index] >= '0' && str[index] <= '9')
{
hasExponent = true;
index++;
}
// Check for end
if (hasExponent && index == length)
{
// Try parse
if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var doubleValue))
{
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, (Double)doubleValue);
return true;
}
// Otherwise exceeds range
else
{
ThrowInvalidValue(scalar, c_floatTag); // throws
}
}
}
}
}
value = default;
return false;
}
private Boolean MatchInteger(
Scalar scalar,
out NumberToken value)
{
// YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923
var str = scalar.Value;
if (!string.IsNullOrEmpty(str))
{
// Check for [0-9]+
var firstChar = str[0];
if (firstChar >= '0' && firstChar <= '9' &&
str.Skip(1).All(x => x >= '0' && x <= '9'))
{
// Try parse
if (Double.TryParse(str, NumberStyles.None, CultureInfo.InvariantCulture, out var doubleValue))
{
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, doubleValue);
return true;
}
// Otherwise exceeds range
ThrowInvalidValue(scalar, c_integerTag); // throws
}
// Check for (-|+)[0-9]+
else if ((firstChar == '-' || firstChar == '+') &&
str.Length > 1 &&
str.Skip(1).All(x => x >= '0' && x <= '9'))
{
// Try parse
if (Double.TryParse(str, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var doubleValue))
{
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, doubleValue);
return true;
}
// Otherwise exceeds range
ThrowInvalidValue(scalar, c_integerTag); // throws
}
// Check for 0x[0-9a-fA-F]+
else if (firstChar == '0' &&
str.Length > 2 &&
str[1] == 'x' &&
str.Skip(2).All(x => (x >= '0' && x <= '9') || (x >= 'a' && x <= 'f') || (x >= 'A' && x <= 'F')))
{
// Try parse
if (Int32.TryParse(str.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var integerValue))
{
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, integerValue);
return true;
}
// Otherwise exceeds range
ThrowInvalidValue(scalar, c_integerTag); // throws
}
// Check for 0o[0-9]+
else if (firstChar == '0' &&
str.Length > 2 &&
str[1] == 'o' &&
str.Skip(2).All(x => x >= '0' && x <= '7'))
{
// Try parse
var integerValue = default(Int32);
try
{
integerValue = Convert.ToInt32(str.Substring(2), 8);
}
// Otherwise exceeds range
catch (Exception)
{
ThrowInvalidValue(scalar, c_integerTag); // throws
}
value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, integerValue);
return true;
}
}
value = default;
return false;
}
private Boolean MatchNull(
Scalar scalar,
out NullToken value)
{
// YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923
switch (scalar.Value ?? string.Empty)
{
case "":
case "null":
case "Null":
case "NULL":
case "~":
value = new NullToken(m_fileId, scalar.Start.Line, scalar.Start.Column);
return true;
}
value = default;
return false;
}
private void ThrowInvalidValue(
Scalar scalar,
String tag)
{
throw new NotSupportedException($"The value '{scalar.Value}' on line {scalar.Start.Line} and column {scalar.Start.Column} is invalid for the type '{scalar.Tag}'");
}
private const String c_booleanTag = "tag:yaml.org,2002:bool";
private const String c_floatTag = "tag:yaml.org,2002:float";
private const String c_integerTag = "tag:yaml.org,2002:int";
private const String c_nullTag = "tag:yaml.org,2002:null";
private const String c_stringTag = "tag:yaml.org,2002:string";
private readonly Int32? m_fileId;
private readonly Parser m_parser;
private ParsingEvent m_current;
}
}

View File

@@ -0,0 +1,324 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using GitHub.DistributedTask.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker.Handlers;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using System.Collections.Generic;
namespace GitHub.Runner.Worker
{
public enum ActionRunStage
{
Main,
Post,
}
[ServiceLocator(Default = typeof(ActionRunner))]
public interface IActionRunner : IStep, IRunnerService
{
ActionRunStage Stage { get; set; }
Boolean TryEvaluateDisplayName(DictionaryContextData contextData, IExecutionContext context);
Pipelines.ActionStep Action { get; set; }
}
public sealed class ActionRunner : RunnerService, IActionRunner
{
private bool _didFullyEvaluateDisplayName = false;
private string _displayName;
public ActionRunStage Stage { get; set; }
public string Condition { get; set; }
public TemplateToken ContinueOnError => Action?.ContinueOnError;
public string DisplayName
{
get
{
// TODO: remove the Action.DisplayName check post m158 deploy, it is done for back compat for older servers
if (!string.IsNullOrEmpty(Action?.DisplayName))
{
return Action?.DisplayName;
}
return string.IsNullOrEmpty(_displayName) ? "run" : _displayName;
}
set
{
_displayName = value;
}
}
public IExecutionContext ExecutionContext { get; set; }
public Pipelines.ActionStep Action { get; set; }
public TemplateToken Timeout => Action?.TimeoutInMinutes;
public async Task RunAsync()
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
ArgUtil.NotNull(Action, nameof(Action));
var taskManager = HostContext.GetService<IActionManager>();
var handlerFactory = HostContext.GetService<IHandlerFactory>();
// Load the task definition and choose the handler.
Definition definition = taskManager.LoadAction(ExecutionContext, Action);
ArgUtil.NotNull(definition, nameof(definition));
ActionExecutionData handlerData = definition.Data?.Execution;
ArgUtil.NotNull(handlerData, nameof(handlerData));
// The action has post cleanup defined.
// we need to create timeline record for them and add them to the step list that StepRunner is using
if (handlerData.HasCleanup && Stage == ActionRunStage.Main)
{
string postDisplayName = null;
if (this.DisplayName.StartsWith(PipelineTemplateConstants.RunDisplayPrefix))
{
postDisplayName = $"Post {this.DisplayName.Substring(PipelineTemplateConstants.RunDisplayPrefix.Length)}";
}
else
{
postDisplayName = $"Post {this.DisplayName}";
}
ExecutionContext.RegisterPostJobAction(postDisplayName, handlerData.CleanupCondition, Action);
}
IStepHost stepHost = HostContext.CreateService<IDefaultStepHost>();
// Makes directory for event_path data
var tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
var workflowDirectory = Path.Combine(tempDirectory, "_github_workflow");
Directory.CreateDirectory(workflowDirectory);
var gitHubEvent = ExecutionContext.GetGitHubContext("event");
// adds the GitHub event path/file if the event exists
if (gitHubEvent != null)
{
var workflowFile = Path.Combine(workflowDirectory, "event.json");
Trace.Info($"Write event payload to {workflowFile}");
File.WriteAllText(workflowFile, gitHubEvent, new UTF8Encoding(false));
ExecutionContext.SetGitHubContext("event_path", workflowFile);
}
// Setup container stephost for running inside the container.
if (ExecutionContext.Container != null)
{
// Make sure required container is already created.
ArgUtil.NotNullOrEmpty(ExecutionContext.Container.ContainerId, nameof(ExecutionContext.Container.ContainerId));
var containerStepHost = HostContext.CreateService<IContainerStepHost>();
containerStepHost.Container = ExecutionContext.Container;
stepHost = containerStepHost;
}
// Load the inputs.
ExecutionContext.Debug("Loading inputs");
var templateTrace = ExecutionContext.ToTemplateTraceWriter();
var schema = new PipelineTemplateSchemaFactory().CreateSchema();
var templateEvaluator = new PipelineTemplateEvaluator(templateTrace, schema);
var inputs = templateEvaluator.EvaluateStepInputs(Action.Inputs, ExecutionContext.ExpressionValues);
foreach (KeyValuePair<string, string> input in inputs)
{
string message = "";
if (definition.Data?.Deprecated?.TryGetValue(input.Key, out message) == true)
{
ExecutionContext.Warning(String.Format("Input '{0}' has been deprecated with message: {1}", input.Key, message));
}
}
// Merge the default inputs from the definition
if (definition.Data?.Inputs != null)
{
var manifestManager = HostContext.GetService<IActionManifestManager>();
foreach (var input in (definition.Data?.Inputs))
{
string key = input.Key.AssertString("action input name").Value;
if (!inputs.ContainsKey(key))
{
var evaluateContext = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
foreach (var data in ExecutionContext.ExpressionValues)
{
evaluateContext[data.Key] = data.Value;
}
inputs[key] = manifestManager.EvaluateDefaultInput(ExecutionContext, key, input.Value, evaluateContext);
}
}
}
// Load the action environment.
ExecutionContext.Debug("Loading env");
var environment = new Dictionary<String, String>(VarUtil.EnvironmentVariableKeyComparer);
// Apply environment set using ##[set-env] first since these are job level env
foreach (var env in ExecutionContext.EnvironmentVariables)
{
environment[env.Key] = env.Value ?? string.Empty;
}
// Apply action's env block later.
var actionEnvironment = templateEvaluator.EvaluateStepEnvironment(Action.Environment, ExecutionContext.ExpressionValues, VarUtil.EnvironmentVariableKeyComparer);
foreach (var env in actionEnvironment)
{
environment[env.Key] = env.Value ?? string.Empty;
}
// Apply action's intra-action state at last
foreach (var state in ExecutionContext.IntraActionState)
{
environment[$"STATE_{state.Key}"] = state.Value ?? string.Empty;
}
// Create the handler.
IHandler handler = handlerFactory.Create(
ExecutionContext,
Action.Reference,
stepHost,
handlerData,
inputs,
environment,
ExecutionContext.Variables,
actionDirectory: definition.Directory);
// Print out action details
handler.PrintActionDetails(Stage);
// Run the task.
await handler.RunAsync(Stage);
}
public bool TryEvaluateDisplayName(DictionaryContextData contextData, IExecutionContext context)
{
ArgUtil.NotNull(context, nameof(context));
ArgUtil.NotNull(Action, nameof(Action));
// If we have already expanded the display name, there is no need to expand it again
// TODO: Remove the ShouldEvaluateDisplayName check and field post m158 deploy, we should do it by default once the server is updated
if (_didFullyEvaluateDisplayName || !string.IsNullOrEmpty(Action.DisplayName))
{
return false;
}
bool didFullyEvaluate;
_displayName = GenerateDisplayName(Action, contextData, context, out didFullyEvaluate);
// If we evaluated fully mask any secrets
if (didFullyEvaluate)
{
_displayName = HostContext.SecretMasker.MaskSecrets(_displayName);
}
context.Debug($"Set step '{Action.Name}' display name to: '{_displayName}'");
_didFullyEvaluateDisplayName = didFullyEvaluate;
return didFullyEvaluate;
}
private string GenerateDisplayName(ActionStep action, DictionaryContextData contextData, IExecutionContext context, out bool didFullyEvaluate)
{
ArgUtil.NotNull(context, nameof(context));
ArgUtil.NotNull(action, nameof(action));
var displayName = string.Empty;
var prefix = string.Empty;
var tokenToParse = default(ScalarToken);
didFullyEvaluate = false;
// Get the token we need to parse
// It could be passed in as the Display Name, or we have to pull it from various parts of the Action.
if (action.DisplayNameToken != null)
{
tokenToParse = action.DisplayNameToken as ScalarToken;
}
else if (action.Reference?.Type == ActionSourceType.Repository)
{
prefix = PipelineTemplateConstants.RunDisplayPrefix;
var repositoryReference = action.Reference as RepositoryPathReference;
var pathString = string.IsNullOrEmpty(repositoryReference.Path) ? string.Empty : $"/{repositoryReference.Path}";
var repoString = string.IsNullOrEmpty(repositoryReference.Ref) ? $"{repositoryReference.Name}{pathString}" :
$"{repositoryReference.Name}{pathString}@{repositoryReference.Ref}";
tokenToParse = new StringToken(null, null, null, repoString);
}
else if (action.Reference?.Type == ActionSourceType.ContainerRegistry)
{
prefix = PipelineTemplateConstants.RunDisplayPrefix;
var containerReference = action.Reference as ContainerRegistryReference;
tokenToParse = new StringToken(null, null, null, containerReference.Image);
}
else if (action.Reference?.Type == ActionSourceType.Script)
{
prefix = PipelineTemplateConstants.RunDisplayPrefix;
var inputs = action.Inputs.AssertMapping(null);
foreach (var pair in inputs)
{
var propertyName = pair.Key.AssertString($"{PipelineTemplateConstants.Steps}");
if (string.Equals(propertyName.Value, "script", StringComparison.OrdinalIgnoreCase))
{
tokenToParse = pair.Value.AssertScalar($"{PipelineTemplateConstants.Steps} item {PipelineTemplateConstants.Run}");
break;
}
}
}
else
{
context.Error($"Encountered an unknown action reference type when evaluating the display name: {action.Reference?.Type}");
return displayName;
}
// If we have nothing to parse, abort
if (tokenToParse == null)
{
return displayName;
}
// Try evaluating fully
var schema = new PipelineTemplateSchemaFactory().CreateSchema();
var templateEvaluator = new PipelineTemplateEvaluator(context.ToTemplateTraceWriter(), schema);
try
{
didFullyEvaluate = templateEvaluator.TryEvaluateStepDisplayName(tokenToParse, contextData, out displayName);
}
catch (TemplateValidationException e)
{
context.Warning($"Encountered an error when evaluating display name {tokenToParse.ToString()}. {e.Message}");
return displayName;
}
// Default to a prettified token if we could not evaluate
if (!didFullyEvaluate)
{
displayName = tokenToParse.ToDisplayString();
}
displayName = FormatStepName(prefix, displayName);
return displayName;
}
private static string FormatStepName(string prefix, string stepName)
{
if (string.IsNullOrEmpty(stepName))
{
return string.Empty;
}
var result = stepName.TrimStart(' ', '\t', '\r', '\n');
var firstNewLine = result.IndexOfAny(new[] { '\r', '\n' });
if (firstNewLine >= 0)
{
result = result.Substring(0, firstNewLine);
}
return $"{prefix}{result}";
}
}
}

View File

@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.IO;
using GitHub.Runner.Common.Util;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Container
{
public class ContainerInfo
{
private IDictionary<string, string> _userMountVolumes;
private List<MountVolume> _mountVolumes;
private IDictionary<string, string> _userPortMappings;
private List<PortMapping> _portMappings;
private IDictionary<string, string> _environmentVariables;
private List<PathMapping> _pathMappings = new List<PathMapping>();
public ContainerInfo()
{
}
public ContainerInfo(IHostContext hostContext, Pipelines.JobContainer container, bool isJobContainer = true, string networkAlias = null)
{
this.ContainerName = container.Alias;
string containerImage = container.Image;
ArgUtil.NotNullOrEmpty(containerImage, nameof(containerImage));
this.ContainerImage = containerImage;
this.ContainerDisplayName = $"{container.Alias}_{Pipelines.Validation.NameValidation.Sanitize(containerImage)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}";
this.ContainerCreateOptions = container.Options;
_environmentVariables = container.Environment;
this.IsJobContainer = isJobContainer;
this.ContainerNetworkAlias = networkAlias;
#if OS_WINDOWS
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Work), "C:\\__w"));
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Tools), "C:\\__t")); // Tool cache folder may come from ENV, so we need a unique folder to avoid collision
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Externals), "C:\\__e"));
// add -v '\\.\pipe\docker_engine:\\.\pipe\docker_engine' when they are available (17.09)
#else
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Work), "/__w"));
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Tools), "/__t")); // Tool cache folder may come from ENV, so we need a unique folder to avoid collision
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Externals), "/__e"));
if (this.IsJobContainer)
{
this.MountVolumes.Add(new MountVolume("/var/run/docker.sock", "/var/run/docker.sock"));
}
#endif
if (container.Ports?.Count > 0)
{
foreach (var port in container.Ports)
{
UserPortMappings[port] = port;
}
}
if (container.Volumes?.Count > 0)
{
foreach (var volume in container.Volumes)
{
UserMountVolumes[volume] = volume;
}
}
}
public string ContainerId { get; set; }
public string ContainerDisplayName { get; set; }
public string ContainerNetwork { get; set; }
public string ContainerNetworkAlias { get; set; }
public string ContainerImage { get; set; }
public string ContainerName { get; set; }
public string ContainerEntryPointArgs { get; set; }
public string ContainerEntryPoint { get; set; }
public string ContainerWorkDirectory { get; set; }
public string ContainerCreateOptions { get; private set; }
public string ContainerRuntimePath { get; set; }
public bool IsJobContainer { get; set; }
public IDictionary<string, string> ContainerEnvironmentVariables
{
get
{
if (_environmentVariables == null)
{
_environmentVariables = new Dictionary<string, string>();
}
return _environmentVariables;
}
}
public IDictionary<string, string> UserMountVolumes
{
get
{
if (_userMountVolumes == null)
{
_userMountVolumes = new Dictionary<string, string>();
}
return _userMountVolumes;
}
}
public List<MountVolume> MountVolumes
{
get
{
if (_mountVolumes == null)
{
_mountVolumes = new List<MountVolume>();
}
return _mountVolumes;
}
}
public IDictionary<string, string> UserPortMappings
{
get
{
if (_userPortMappings == null)
{
_userPortMappings = new Dictionary<string, string>();
}
return _userPortMappings;
}
}
public List<PortMapping> PortMappings
{
get
{
if (_portMappings == null)
{
_portMappings = new List<PortMapping>();
}
return _portMappings;
}
}
public string TranslateToContainerPath(string path)
{
if (!string.IsNullOrEmpty(path))
{
foreach (var mapping in _pathMappings)
{
#if OS_WINDOWS
if (string.Equals(path, mapping.HostPath, StringComparison.OrdinalIgnoreCase))
{
return mapping.ContainerPath;
}
if (path.StartsWith(mapping.HostPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
path.StartsWith(mapping.HostPath + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
return mapping.ContainerPath + path.Remove(0, mapping.HostPath.Length);
}
#else
if (string.Equals(path, mapping.HostPath))
{
return mapping.ContainerPath;
}
if (path.StartsWith(mapping.HostPath + Path.DirectorySeparatorChar))
{
return mapping.ContainerPath + path.Remove(0, mapping.HostPath.Length);
}
#endif
}
}
return path;
}
public string TranslateToHostPath(string path)
{
if (!string.IsNullOrEmpty(path))
{
foreach (var mapping in _pathMappings)
{
#if OS_WINDOWS
if (string.Equals(path, mapping.ContainerPath, StringComparison.OrdinalIgnoreCase))
{
return mapping.HostPath;
}
if (path.StartsWith(mapping.ContainerPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
path.StartsWith(mapping.ContainerPath + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
return mapping.HostPath + path.Remove(0, mapping.ContainerPath.Length);
}
#else
if (string.Equals(path, mapping.ContainerPath))
{
return mapping.HostPath;
}
if (path.StartsWith(mapping.ContainerPath + Path.DirectorySeparatorChar))
{
return mapping.HostPath + path.Remove(0, mapping.ContainerPath.Length);
}
#endif
}
}
return path;
}
public void AddPortMappings(List<PortMapping> portMappings)
{
foreach (var port in portMappings)
{
PortMappings.Add(port);
}
}
public void AddPathTranslateMapping(string hostCommonPath, string containerCommonPath)
{
_pathMappings.Insert(0, new PathMapping(hostCommonPath, containerCommonPath));
}
}
public class MountVolume
{
public MountVolume(string sourceVolumePath, string targetVolumePath, bool readOnly = false)
{
this.SourceVolumePath = sourceVolumePath;
this.TargetVolumePath = targetVolumePath;
this.ReadOnly = readOnly;
}
public MountVolume(string fromString)
{
ParseVolumeString(fromString);
}
private void ParseVolumeString(string volume)
{
var volumeSplit = volume.Split(":");
if (volumeSplit.Length == 3)
{
// source:target:ro
SourceVolumePath = volumeSplit[0];
TargetVolumePath = volumeSplit[1];
ReadOnly = String.Equals(volumeSplit[2], "ro", StringComparison.OrdinalIgnoreCase);
}
else if (volumeSplit.Length == 2)
{
if (String.Equals(volumeSplit[1], "ro", StringComparison.OrdinalIgnoreCase))
{
// target:ro
TargetVolumePath = volumeSplit[0];
ReadOnly = true;
}
else
{
// source:target
SourceVolumePath = volumeSplit[0];
TargetVolumePath = volumeSplit[1];
ReadOnly = false;
}
}
else
{
// target - or, default to passing straight through
TargetVolumePath = volume;
ReadOnly = false;
}
}
public string SourceVolumePath { get; set; }
public string TargetVolumePath { get; set; }
public bool ReadOnly { get; set; }
}
public class PortMapping
{
public PortMapping(string hostPort, string containerPort, string protocol)
{
this.HostPort = hostPort;
this.ContainerPort = containerPort;
this.Protocol = protocol;
}
public string HostPort { get; set; }
public string ContainerPort { get; set; }
public string Protocol { get; set; }
}
public class DockerVersion
{
public DockerVersion(Version serverVersion, Version clientVersion)
{
this.ServerVersion = serverVersion;
this.ClientVersion = clientVersion;
}
public Version ServerVersion { get; set; }
public Version ClientVersion { get; set; }
}
public class PathMapping
{
public PathMapping(string hostPath, string containerPath)
{
this.HostPath = hostPath;
this.ContainerPath = containerPath;
}
public string HostPath { get; set; }
public string ContainerPath { get; set; }
}
}

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Container
{
[ServiceLocator(Default = typeof(DockerCommandManager))]
public interface IDockerCommandManager : IRunnerService
{
string DockerPath { get; }
string DockerInstanceLabel { get; }
Task<DockerVersion> DockerVersion(IExecutionContext context);
Task<int> DockerPull(IExecutionContext context, string image);
Task<int> DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string tag);
Task<string> DockerCreate(IExecutionContext context, ContainerInfo container);
Task<int> DockerRun(IExecutionContext context, ContainerInfo container, EventHandler<ProcessDataReceivedEventArgs> stdoutDataReceived, EventHandler<ProcessDataReceivedEventArgs> stderrDataReceived);
Task<int> DockerStart(IExecutionContext context, string containerId);
Task<int> DockerLogs(IExecutionContext context, string containerId);
Task<List<string>> DockerPS(IExecutionContext context, string options);
Task<int> DockerRemove(IExecutionContext context, string containerId);
Task<int> DockerNetworkCreate(IExecutionContext context, string network);
Task<int> DockerNetworkRemove(IExecutionContext context, string network);
Task<int> DockerNetworkPrune(IExecutionContext context);
Task<int> DockerExec(IExecutionContext context, string containerId, string options, string command);
Task<int> DockerExec(IExecutionContext context, string containerId, string options, string command, List<string> outputs);
Task<List<string>> DockerInspect(IExecutionContext context, string dockerObject, string options);
Task<List<PortMapping>> DockerPort(IExecutionContext context, string containerId);
}
public class DockerCommandManager : RunnerService, IDockerCommandManager
{
public string DockerPath { get; private set; }
public string DockerInstanceLabel { get; private set; }
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
DockerPath = WhichUtil.Which("docker", true, Trace);
DockerInstanceLabel = IOUtil.GetPathHash(hostContext.GetDirectory(WellKnownDirectory.Root)).Substring(0, 6);
}
public async Task<DockerVersion> DockerVersion(IExecutionContext context)
{
string serverVersionStr = (await ExecuteDockerCommandAsync(context, "version", "--format '{{.Server.APIVersion}}'")).FirstOrDefault();
ArgUtil.NotNullOrEmpty(serverVersionStr, "Docker.Server.Version");
context.Output($"Docker daemon API version: {serverVersionStr}");
string clientVersionStr = (await ExecuteDockerCommandAsync(context, "version", "--format '{{.Client.APIVersion}}'")).FirstOrDefault();
ArgUtil.NotNullOrEmpty(serverVersionStr, "Docker.Client.Version");
context.Output($"Docker client API version: {clientVersionStr}");
// we interested about major.minor.patch version
Regex verRegex = new Regex("\\d+\\.\\d+(\\.\\d+)?", RegexOptions.IgnoreCase);
Version serverVersion = null;
var serverVersionMatchResult = verRegex.Match(serverVersionStr);
if (serverVersionMatchResult.Success && !string.IsNullOrEmpty(serverVersionMatchResult.Value))
{
if (!Version.TryParse(serverVersionMatchResult.Value, out serverVersion))
{
serverVersion = null;
}
}
Version clientVersion = null;
var clientVersionMatchResult = verRegex.Match(serverVersionStr);
if (clientVersionMatchResult.Success && !string.IsNullOrEmpty(clientVersionMatchResult.Value))
{
if (!Version.TryParse(clientVersionMatchResult.Value, out clientVersion))
{
clientVersion = null;
}
}
return new DockerVersion(serverVersion, clientVersion);
}
public async Task<int> DockerPull(IExecutionContext context, string image)
{
return await ExecuteDockerCommandAsync(context, "pull", image, context.CancellationToken);
}
public async Task<int> DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string tag)
{
return await ExecuteDockerCommandAsync(context, "build", $"-t {tag} \"{dockerFile}\"", workingDirectory, context.CancellationToken);
}
public async Task<string> DockerCreate(IExecutionContext context, ContainerInfo container)
{
IList<string> dockerOptions = new List<string>();
// OPTIONS
dockerOptions.Add($"--name {container.ContainerDisplayName}");
dockerOptions.Add($"--label {DockerInstanceLabel}");
if (!string.IsNullOrEmpty(container.ContainerWorkDirectory))
{
dockerOptions.Add($"--workdir {container.ContainerWorkDirectory}");
}
if (!string.IsNullOrEmpty(container.ContainerNetwork))
{
dockerOptions.Add($"--network {container.ContainerNetwork}");
}
if (!string.IsNullOrEmpty(container.ContainerNetworkAlias))
{
dockerOptions.Add($"--network-alias {container.ContainerNetworkAlias}");
}
foreach (var port in container.UserPortMappings)
{
dockerOptions.Add($"-p {port.Value}");
}
dockerOptions.Add($"{container.ContainerCreateOptions}");
foreach (var env in container.ContainerEnvironmentVariables)
{
if (String.IsNullOrEmpty(env.Value))
{
dockerOptions.Add($"-e \"{env.Key}\"");
}
else
{
dockerOptions.Add($"-e \"{env.Key}={env.Value.Replace("\"", "\\\"")}\"");
}
}
// Watermark for GitHub Action environment
dockerOptions.Add("-e GITHUB_ACTIONS=true");
foreach (var volume in container.MountVolumes)
{
// replace `"` with `\"` and add `"{0}"` to all path.
String volumeArg;
if (String.IsNullOrEmpty(volume.SourceVolumePath))
{
// Anonymous docker volume
volumeArg = $"-v \"{volume.TargetVolumePath.Replace("\"", "\\\"")}\"";
}
else
{
// Named Docker volume / host bind mount
volumeArg = $"-v \"{volume.SourceVolumePath.Replace("\"", "\\\"")}\":\"{volume.TargetVolumePath.Replace("\"", "\\\"")}\"";
}
if (volume.ReadOnly)
{
volumeArg += ":ro";
}
dockerOptions.Add(volumeArg);
}
if (!string.IsNullOrEmpty(container.ContainerEntryPoint))
{
dockerOptions.Add($"--entrypoint \"{container.ContainerEntryPoint}\"");
}
// IMAGE
dockerOptions.Add($"{container.ContainerImage}");
// COMMAND
// Intentionally blank. Always overwrite ENTRYPOINT and/or send ARGs
// [ARG...]
dockerOptions.Add($"{container.ContainerEntryPointArgs}");
var optionsString = string.Join(" ", dockerOptions);
List<string> outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString);
return outputStrings.FirstOrDefault();
}
public async Task<int> DockerRun(IExecutionContext context, ContainerInfo container, EventHandler<ProcessDataReceivedEventArgs> stdoutDataReceived, EventHandler<ProcessDataReceivedEventArgs> stderrDataReceived)
{
IList<string> dockerOptions = new List<string>();
// OPTIONS
dockerOptions.Add($"--name {container.ContainerDisplayName}");
dockerOptions.Add($"--label {DockerInstanceLabel}");
dockerOptions.Add($"--workdir {container.ContainerWorkDirectory}");
dockerOptions.Add($"--rm");
foreach (var env in container.ContainerEnvironmentVariables)
{
// e.g. -e MY_SECRET maps the value into the exec'ed process without exposing
// the value directly in the command
dockerOptions.Add($"-e {env.Key}");
}
// Watermark for GitHub Action environment
dockerOptions.Add("-e GITHUB_ACTIONS=true");
if (!string.IsNullOrEmpty(container.ContainerEntryPoint))
{
dockerOptions.Add($"--entrypoint \"{container.ContainerEntryPoint}\"");
}
if (!string.IsNullOrEmpty(container.ContainerNetwork))
{
dockerOptions.Add($"--network {container.ContainerNetwork}");
}
foreach (var volume in container.MountVolumes)
{
// replace `"` with `\"` and add `"{0}"` to all path.
String volumeArg;
if (String.IsNullOrEmpty(volume.SourceVolumePath))
{
// Anonymous docker volume
volumeArg = $"-v \"{volume.TargetVolumePath.Replace("\"", "\\\"")}\"";
}
else
{
// Named Docker volume / host bind mount
volumeArg = $"-v \"{volume.SourceVolumePath.Replace("\"", "\\\"")}\":\"{volume.TargetVolumePath.Replace("\"", "\\\"")}\"";
}
if (volume.ReadOnly)
{
volumeArg += ":ro";
}
dockerOptions.Add(volumeArg);
}
// IMAGE
dockerOptions.Add($"{container.ContainerImage}");
// COMMAND
// Intentionally blank. Always overwrite ENTRYPOINT and/or send ARGs
// [ARG...]
dockerOptions.Add($"{container.ContainerEntryPointArgs}");
var optionsString = string.Join(" ", dockerOptions);
return await ExecuteDockerCommandAsync(context, "run", optionsString, container.ContainerEnvironmentVariables, stdoutDataReceived, stderrDataReceived, context.CancellationToken);
}
public async Task<int> DockerStart(IExecutionContext context, string containerId)
{
return await ExecuteDockerCommandAsync(context, "start", containerId, context.CancellationToken);
}
public async Task<int> DockerRemove(IExecutionContext context, string containerId)
{
return await ExecuteDockerCommandAsync(context, "rm", $"--force {containerId}", context.CancellationToken);
}
public async Task<int> DockerLogs(IExecutionContext context, string containerId)
{
return await ExecuteDockerCommandAsync(context, "logs", $"--details {containerId}", context.CancellationToken);
}
public async Task<List<string>> DockerPS(IExecutionContext context, string options)
{
return await ExecuteDockerCommandAsync(context, "ps", options);
}
public async Task<int> DockerNetworkCreate(IExecutionContext context, string network)
{
#if OS_WINDOWS
return await ExecuteDockerCommandAsync(context, "network", $"create --label {DockerInstanceLabel} {network} --driver nat", context.CancellationToken);
#else
return await ExecuteDockerCommandAsync(context, "network", $"create --label {DockerInstanceLabel} {network}", context.CancellationToken);
#endif
}
public async Task<int> DockerNetworkRemove(IExecutionContext context, string network)
{
return await ExecuteDockerCommandAsync(context, "network", $"rm {network}", context.CancellationToken);
}
public async Task<int> DockerNetworkPrune(IExecutionContext context)
{
return await ExecuteDockerCommandAsync(context, "network", $"prune --force --filter \"label={DockerInstanceLabel}\"", context.CancellationToken);
}
public async Task<int> DockerExec(IExecutionContext context, string containerId, string options, string command)
{
return await ExecuteDockerCommandAsync(context, "exec", $"{options} {containerId} {command}", context.CancellationToken);
}
public async Task<int> DockerExec(IExecutionContext context, string containerId, string options, string command, List<string> output)
{
ArgUtil.NotNull(output, nameof(output));
string arg = $"exec {options} {containerId} {command}".Trim();
context.Command($"{DockerPath} {arg}");
object outputLock = new object();
var processInvoker = HostContext.CreateService<IProcessInvoker>();
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
if (!string.IsNullOrEmpty(message.Data))
{
lock (outputLock)
{
output.Add(message.Data);
}
}
};
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
if (!string.IsNullOrEmpty(message.Data))
{
lock (outputLock)
{
output.Add(message.Data);
}
}
};
#if OS_WINDOWS || OS_OSX
throw new NotSupportedException($"Container operation is only supported on Linux");
#else
return await processInvoker.ExecuteAsync(
workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work),
fileName: DockerPath,
arguments: arg,
environment: null,
requireExitCodeZero: false,
outputEncoding: null,
cancellationToken: CancellationToken.None);
#endif
}
public async Task<List<string>> DockerInspect(IExecutionContext context, string dockerObject, string options)
{
return await ExecuteDockerCommandAsync(context, "inspect", $"{options} {dockerObject}");
}
public async Task<List<PortMapping>> DockerPort(IExecutionContext context, string containerId)
{
List<string> portMappingLines = await ExecuteDockerCommandAsync(context, "port", containerId);
return DockerUtil.ParseDockerPort(portMappingLines);
}
private Task<int> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, CancellationToken cancellationToken = default(CancellationToken))
{
return ExecuteDockerCommandAsync(context, command, options, null, cancellationToken);
}
private async Task<int> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary<string, string> environment, EventHandler<ProcessDataReceivedEventArgs> stdoutDataReceived, EventHandler<ProcessDataReceivedEventArgs> stderrDataReceived, CancellationToken cancellationToken = default(CancellationToken))
{
string arg = $"{command} {options}".Trim();
context.Command($"{DockerPath} {arg}");
var processInvoker = HostContext.CreateService<IProcessInvoker>();
processInvoker.OutputDataReceived += stdoutDataReceived;
processInvoker.ErrorDataReceived += stderrDataReceived;
#if OS_WINDOWS || OS_OSX
throw new NotSupportedException($"Container operation is only supported on Linux");
#else
return await processInvoker.ExecuteAsync(
workingDirectory: context.GetGitHubContext("workspace"),
fileName: DockerPath,
arguments: arg,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: false,
cancellationToken: cancellationToken);
#endif
}
private async Task<int> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, string workingDirectory, CancellationToken cancellationToken = default(CancellationToken))
{
string arg = $"{command} {options}".Trim();
context.Command($"{DockerPath} {arg}");
var processInvoker = HostContext.CreateService<IProcessInvoker>();
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
context.Output(message.Data);
};
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
context.Output(message.Data);
};
#if OS_WINDOWS || OS_OSX
throw new NotSupportedException($"Container operation is only supported on Linux");
#else
return await processInvoker.ExecuteAsync(
workingDirectory: workingDirectory ?? context.GetGitHubContext("workspace"),
fileName: DockerPath,
arguments: arg,
environment: null,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: false,
redirectStandardIn: null,
cancellationToken: cancellationToken);
#endif
}
private async Task<List<string>> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options)
{
string arg = $"{command} {options}".Trim();
context.Command($"{DockerPath} {arg}");
List<string> output = new List<string>();
var processInvoker = HostContext.CreateService<IProcessInvoker>();
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
if (!string.IsNullOrEmpty(message.Data))
{
output.Add(message.Data);
context.Output(message.Data);
}
};
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
if (!string.IsNullOrEmpty(message.Data))
{
context.Output(message.Data);
}
};
await processInvoker.ExecuteAsync(
workingDirectory: context.GetGitHubContext("workspace"),
fileName: DockerPath,
arguments: arg,
environment: null,
requireExitCodeZero: true,
outputEncoding: null,
cancellationToken: CancellationToken.None);
return output;
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace GitHub.Runner.Worker.Container
{
public class DockerUtil
{
public static List<PortMapping> ParseDockerPort(IList<string> portMappingLines)
{
const string targetPort = "targetPort";
const string proto = "proto";
const string host = "host";
const string hostPort = "hostPort";
//"TARGET_PORT/PROTO -> HOST:HOST_PORT"
string pattern = $"^(?<{targetPort}>\\d+)/(?<{proto}>\\w+) -> (?<{host}>.+):(?<{hostPort}>\\d+)$";
List<PortMapping> portMappings = new List<PortMapping>();
foreach(var line in portMappingLines)
{
Match m = Regex.Match(line, pattern, RegexOptions.None, TimeSpan.FromSeconds(1));
if (m.Success)
{
portMappings.Add(new PortMapping(
m.Groups[hostPort].Value,
m.Groups[targetPort].Value,
m.Groups[proto].Value
));
}
}
return portMappings;
}
public static string ParsePathFromConfigEnv(IList<string> configEnvLines)
{
// Config format is VAR=value per line
foreach (var line in configEnvLines)
{
var keyValue = line.Split("=", 2);
if (keyValue.Length == 2 && string.Equals(keyValue[0], "PATH"))
{
return keyValue[1];
}
}
return "";
}
}
}

View File

@@ -0,0 +1,414 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.ServiceProcess;
using System.Threading.Tasks;
using System.Linq;
using System.Threading;
using GitHub.Runner.Worker.Container;
using GitHub.Services.Common;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.DistributedTask.Pipelines.ContextData;
using Microsoft.Win32;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(ContainerOperationProvider))]
public interface IContainerOperationProvider : IRunnerService
{
Task StartContainersAsync(IExecutionContext executionContext, object data);
Task StopContainersAsync(IExecutionContext executionContext, object data);
}
public class ContainerOperationProvider : RunnerService, IContainerOperationProvider
{
private IDockerCommandManager _dockerManger;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_dockerManger = HostContext.GetService<IDockerCommandManager>();
}
public async Task StartContainersAsync(IExecutionContext executionContext, object data)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
List<ContainerInfo> containers = data as List<ContainerInfo>;
ArgUtil.NotNull(containers, nameof(containers));
// 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
// 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.");
}
#elif OS_RHEL6
// Red Hat and CentOS 6 do not support the container feature
throw new NotSupportedException("Runner does not support the container feature on Red Hat Enterprise Linux 6 or CentOS 6.");
#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
// 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");
}
#endif
// Check docker client/server version
DockerVersion dockerVersion = await _dockerManger.DockerVersion(executionContext);
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 ('{_dockerManger.DockerPath}') server version is '{dockerVersion.ServerVersion}'");
}
if (dockerVersion.ClientVersion < requiredDockerEngineAPIVersion)
{
throw new NotSupportedException($"Min required docker engine API client version is '{requiredDockerEngineAPIVersion}', your docker ('{_dockerManger.DockerPath}') client version is '{dockerVersion.ClientVersion}'");
}
// Clean up containers left by previous runs
executionContext.Debug($"Delete stale containers from previous jobs");
var staleContainers = await _dockerManger.DockerPS(executionContext, $"--all --quiet --no-trunc --filter \"label={_dockerManger.DockerInstanceLabel}\"");
foreach (var staleContainer in staleContainers)
{
int containerRemoveExitCode = await _dockerManger.DockerRemove(executionContext, staleContainer);
if (containerRemoveExitCode != 0)
{
executionContext.Warning($"Delete stale containers failed, docker rm fail with exit code {containerRemoveExitCode} for container {staleContainer}");
}
}
executionContext.Debug($"Delete stale container networks from previous jobs");
int networkPruneExitCode = await _dockerManger.DockerNetworkPrune(executionContext);
if (networkPruneExitCode != 0)
{
executionContext.Warning($"Delete stale container networks failed, docker network prune fail with exit code {networkPruneExitCode}");
}
// Create local docker network for this job to avoid port conflict when multiple agents run on same machine.
// All containers within a job join the same network
var containerNetwork = $"github_network_{Guid.NewGuid().ToString("N")}";
await CreateContainerNetworkAsync(executionContext, containerNetwork);
executionContext.JobContext.Container["network"] = new StringContextData(containerNetwork);
foreach (var container in containers)
{
container.ContainerNetwork = containerNetwork;
await StartContainerAsync(executionContext, container);
}
foreach (var container in containers.Where(c => !c.IsJobContainer))
{
await ContainerHealthcheck(executionContext, container);
}
}
public async Task StopContainersAsync(IExecutionContext executionContext, object data)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
List<ContainerInfo> containers = data as List<ContainerInfo>;
ArgUtil.NotNull(containers, nameof(containers));
foreach (var container in containers)
{
await StopContainerAsync(executionContext, container);
}
// Remove the container network
await RemoveContainerNetworkAsync(executionContext, containers.First().ContainerNetwork);
}
private async Task StartContainerAsync(IExecutionContext executionContext, ContainerInfo container)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
ArgUtil.NotNull(container, nameof(container));
ArgUtil.NotNullOrEmpty(container.ContainerImage, nameof(container.ContainerImage));
Trace.Info($"Container name: {container.ContainerName}");
Trace.Info($"Container image: {container.ContainerImage}");
Trace.Info($"Container options: {container.ContainerCreateOptions}");
foreach (var port in container.UserPortMappings)
{
Trace.Info($"User provided port: {port.Value}");
}
foreach (var volume in container.UserMountVolumes)
{
Trace.Info($"User provided volume: {volume.Value}");
}
// Pull down docker image with retry up to 3 times
int retryCount = 0;
int pullExitCode = 0;
while (retryCount < 3)
{
pullExitCode = await _dockerManger.DockerPull(executionContext, container.ContainerImage);
if (pullExitCode == 0)
{
break;
}
else
{
retryCount++;
if (retryCount < 3)
{
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
executionContext.Warning($"Docker pull failed with exit code {pullExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
await Task.Delay(backOff);
}
}
}
if (retryCount == 3 && pullExitCode != 0)
{
throw new InvalidOperationException($"Docker pull failed with exit code {pullExitCode}");
}
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\"";
}
container.ContainerId = await _dockerManger.DockerCreate(executionContext, container);
ArgUtil.NotNullOrEmpty(container.ContainerId, nameof(container.ContainerId));
// Start container
int startExitCode = await _dockerManger.DockerStart(executionContext, container.ContainerId);
if (startExitCode != 0)
{
throw new InvalidOperationException($"Docker start fail with exit code {startExitCode}");
}
try
{
// Make sure container is up and running
var psOutputs = await _dockerManger.DockerPS(executionContext, $"--all --filter id={container.ContainerId} --filter status=running --no-trunc --format \"{{{{.ID}}}} {{{{.Status}}}}\"");
if (psOutputs.FirstOrDefault(x => !string.IsNullOrEmpty(x))?.StartsWith(container.ContainerId) != true)
{
// container is not up and running, pull docker log for this container.
await _dockerManger.DockerPS(executionContext, $"--all --filter id={container.ContainerId} --no-trunc --format \"{{{{.ID}}}} {{{{.Status}}}}\"");
int logsExitCode = await _dockerManger.DockerLogs(executionContext, container.ContainerId);
if (logsExitCode != 0)
{
executionContext.Warning($"Docker logs fail with exit code {logsExitCode}");
}
executionContext.Warning($"Docker container {container.ContainerId} is not in running state.");
}
}
catch (Exception ex)
{
// pull container log is best effort.
Trace.Error("Catch exception when check container log and container status.");
Trace.Error(ex);
}
// Gather runtime container information
if (!container.IsJobContainer)
{
var service = new DictionaryContextData()
{
["id"] = new StringContextData(container.ContainerId),
["ports"] = new DictionaryContextData(),
["network"] = new StringContextData(container.ContainerNetwork)
};
container.AddPortMappings(await _dockerManger.DockerPort(executionContext, container.ContainerId));
foreach (var port in container.PortMappings)
{
(service["ports"] as DictionaryContextData)[port.ContainerPort] = new StringContextData(port.HostPort);
}
executionContext.JobContext.Services[container.ContainerNetworkAlias] = service;
}
else
{
var configEnvFormat = "--format \"{{range .Config.Env}}{{println .}}{{end}}\"";
var containerEnv = await _dockerManger.DockerInspect(executionContext, container.ContainerId, configEnvFormat);
container.ContainerRuntimePath = DockerUtil.ParsePathFromConfigEnv(containerEnv);
executionContext.JobContext.Container["id"] = new StringContextData(container.ContainerId);
}
}
private async Task StopContainerAsync(IExecutionContext executionContext, ContainerInfo container)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
ArgUtil.NotNull(container, nameof(container));
if (!string.IsNullOrEmpty(container.ContainerId))
{
executionContext.Output($"Stop and remove container: {container.ContainerDisplayName}");
int rmExitCode = await _dockerManger.DockerRemove(executionContext, container.ContainerId);
if (rmExitCode != 0)
{
executionContext.Warning($"Docker rm fail with exit code {rmExitCode}");
}
}
}
#if !OS_WINDOWS
private async Task<List<string>> ExecuteCommandAsync(IExecutionContext context, string command, string arg)
{
context.Command($"{command} {arg}");
List<string> outputs = new List<string>();
object outputLock = new object();
var processInvoker = HostContext.CreateService<IProcessInvoker>();
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
if (!string.IsNullOrEmpty(message.Data))
{
lock (outputLock)
{
outputs.Add(message.Data);
}
}
};
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
{
if (!string.IsNullOrEmpty(message.Data))
{
lock (outputLock)
{
outputs.Add(message.Data);
}
}
};
await processInvoker.ExecuteAsync(
workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work),
fileName: command,
arguments: arg,
environment: null,
requireExitCodeZero: true,
outputEncoding: null,
cancellationToken: CancellationToken.None);
foreach (var outputLine in outputs)
{
context.Output(outputLine);
}
return outputs;
}
#endif
private async Task CreateContainerNetworkAsync(IExecutionContext executionContext, string network)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
int networkExitCode = await _dockerManger.DockerNetworkCreate(executionContext, network);
if (networkExitCode != 0)
{
throw new InvalidOperationException($"Docker network create failed with exit code {networkExitCode}");
}
}
private async Task RemoveContainerNetworkAsync(IExecutionContext executionContext, string network)
{
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
ArgUtil.NotNull(network, nameof(network));
executionContext.Output($"Remove container network: {network}");
int removeExitCode = await _dockerManger.DockerNetworkRemove(executionContext, network);
if (removeExitCode != 0)
{
executionContext.Warning($"Docker network rm failed with exit code {removeExitCode}");
}
}
private async Task ContainerHealthcheck(IExecutionContext executionContext, ContainerInfo container)
{
string healthCheck = "--format=\"{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}\"";
string serviceHealth = (await _dockerManger.DockerInspect(context: executionContext, dockerObject: container.ContainerId, options: healthCheck)).FirstOrDefault();
if (string.IsNullOrEmpty(serviceHealth))
{
// Container has no HEALTHCHECK
return;
}
var retryCount = 0;
while (string.Equals(serviceHealth, "starting", StringComparison.OrdinalIgnoreCase))
{
TimeSpan backoff = BackoffTimerHelper.GetExponentialBackoff(retryCount, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(32), TimeSpan.FromSeconds(2));
executionContext.Output($"{container.ContainerNetworkAlias} service is starting, waiting {backoff.Seconds} seconds before checking again.");
await Task.Delay(backoff, executionContext.CancellationToken);
serviceHealth = (await _dockerManger.DockerInspect(context: executionContext, dockerObject: container.ContainerId, options: healthCheck)).FirstOrDefault();
retryCount++;
}
if (string.Equals(serviceHealth, "healthy", StringComparison.OrdinalIgnoreCase))
{
executionContext.Output($"{container.ContainerNetworkAlias} service is healthy.");
}
else
{
throw new InvalidOperationException($"Failed to initialize, {container.ContainerNetworkAlias} service is {serviceHealth}.");
}
}
}
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Runtime.InteropServices;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker;
using GitHub.Runner.Common.Capabilities;
using GitHub.Services.WebApi;
using Microsoft.Win32;
using System.Diagnostics;
using System.Linq;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(DiagnosticLogManager))]
public interface IDiagnosticLogManager : IRunnerService
{
Task UploadDiagnosticLogsAsync(IExecutionContext executionContext,
IExecutionContext parentContext,
Pipelines.AgentJobRequestMessage message,
DateTime jobStartTimeUtc);
}
// This class manages gathering data for support logs, zipping the data, and uploading it.
// The files are created with the following folder structure:
// ..\_layout\_work\_temp
// \[job name]-support (supportRootFolder)
// \files (supportFolder)
// ...
// support.zip
public sealed class DiagnosticLogManager : RunnerService, IDiagnosticLogManager
{
private static string DateTimeFormat = "yyyyMMdd-HHmmss";
public async Task UploadDiagnosticLogsAsync(IExecutionContext executionContext,
IExecutionContext parentContext,
Pipelines.AgentJobRequestMessage message,
DateTime jobStartTimeUtc)
{
executionContext.Debug("Starting diagnostic file upload.");
// Setup folders
// \_layout\_work\_temp\[jobname-support]
executionContext.Debug("Setting up diagnostic log folders.");
string tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
ArgUtil.Directory(tempDirectory, nameof(tempDirectory));
string supportRootFolder = Path.Combine(tempDirectory, message.JobName + "-support");
Directory.CreateDirectory(supportRootFolder);
// \_layout\_work\_temp\[jobname-support]\files
executionContext.Debug("Creating diagnostic log files folder.");
string supportFilesFolder = Path.Combine(supportRootFolder, "files");
Directory.CreateDirectory(supportFilesFolder);
// Create the environment file
// \_layout\_work\_temp\[jobname-support]\files\environment.txt
var configurationStore = HostContext.GetService<IConfigurationStore>();
RunnerSettings settings = configurationStore.GetSettings();
int runnerId = settings.AgentId;
string runnerName = settings.AgentName;
int poolId = settings.PoolId;
// Copy worker diagnostic log files
List<string> workerDiagnosticLogFiles = GetWorkerDiagnosticLogFiles(HostContext.GetDirectory(WellKnownDirectory.Diag), jobStartTimeUtc);
executionContext.Debug($"Copying {workerDiagnosticLogFiles.Count()} worker diagnostic logs.");
foreach (string workerLogFile in workerDiagnosticLogFiles)
{
ArgUtil.File(workerLogFile, nameof(workerLogFile));
string destination = Path.Combine(supportFilesFolder, Path.GetFileName(workerLogFile));
File.Copy(workerLogFile, destination);
}
// Copy runner diag log files
List<string> runnerDiagnosticLogFiles = GetRunnerDiagnosticLogFiles(HostContext.GetDirectory(WellKnownDirectory.Diag), jobStartTimeUtc);
executionContext.Debug($"Copying {runnerDiagnosticLogFiles.Count()} runner diagnostic logs.");
foreach (string runnerLogFile in runnerDiagnosticLogFiles)
{
ArgUtil.File(runnerLogFile, nameof(runnerLogFile));
string destination = Path.Combine(supportFilesFolder, Path.GetFileName(runnerLogFile));
File.Copy(runnerLogFile, destination);
}
executionContext.Debug("Zipping diagnostic files.");
string buildNumber = executionContext.Variables.Build_Number ?? "UnknownBuildNumber";
string buildName = $"Build {buildNumber}";
string phaseName = executionContext.Variables.System_PhaseDisplayName ?? "UnknownPhaseName";
// zip the files
string diagnosticsZipFileName = $"{buildName}-{phaseName}.zip";
string diagnosticsZipFilePath = Path.Combine(supportRootFolder, diagnosticsZipFileName);
ZipFile.CreateFromDirectory(supportFilesFolder, diagnosticsZipFilePath);
// upload the json metadata file
executionContext.Debug("Uploading diagnostic metadata file.");
string metadataFileName = $"diagnostics-{buildName}-{phaseName}.json";
string metadataFilePath = Path.Combine(supportFilesFolder, metadataFileName);
string phaseResult = GetTaskResultAsString(executionContext.Result);
IOUtil.SaveObject(new DiagnosticLogMetadata(runnerName, runnerId, poolId, phaseName, diagnosticsZipFileName, phaseResult), metadataFilePath);
// TODO: Remove the parentContext Parameter and replace this with executioncontext. Currently a bug exists where these files do not upload correctly using that context.
parentContext.QueueAttachFile(type: CoreAttachmentType.DiagnosticLog, name: metadataFileName, filePath: metadataFilePath);
parentContext.QueueAttachFile(type: CoreAttachmentType.DiagnosticLog, name: diagnosticsZipFileName, filePath: diagnosticsZipFilePath);
executionContext.Debug("Diagnostic file upload complete.");
}
private string GetTaskResultAsString(TaskResult? taskResult)
{
if (!taskResult.HasValue) { return "Unknown"; }
return taskResult.ToString();
}
// The current solution is a hack. We need to rethink this and find a better one.
// The list of worker log files isn't available from the logger. It's also nested several levels deep.
// For this solution we deduce the applicable worker log files by comparing their create time to the start time of the job.
private List<string> GetWorkerDiagnosticLogFiles(string diagnosticFolder, DateTime jobStartTimeUtc)
{
// Get all worker log files with a timestamp equal or greater than the start of the job
var workerLogFiles = new List<string>();
var directoryInfo = new DirectoryInfo(diagnosticFolder);
// Sometimes the timing is off between the job start time and the time the worker log file is created.
// This adds a small buffer that provides some leeway in case the worker log file was created slightly
// before the time we log as job start time.
int bufferInSeconds = -30;
DateTime searchTimeUtc = jobStartTimeUtc.AddSeconds(bufferInSeconds);
foreach (FileInfo file in directoryInfo.GetFiles().Where(f => f.Name.StartsWith(Constants.Path.WorkerDiagnosticLogPrefix)))
{
// The format of the logs is:
// Worker_20171003-143110-utc.log
DateTime fileCreateTime = DateTime.ParseExact(
s: file.Name.Substring(startIndex: Constants.Path.WorkerDiagnosticLogPrefix.Length, length: DateTimeFormat.Length),
format: DateTimeFormat,
provider: CultureInfo.InvariantCulture);
if (fileCreateTime >= searchTimeUtc)
{
workerLogFiles.Add(file.FullName);
}
}
return workerLogFiles;
}
private List<string> GetRunnerDiagnosticLogFiles(string diagnosticFolder, DateTime jobStartTimeUtc)
{
// Get the newest runner log file that created just before the start of the job
var runnerLogFiles = new List<string>();
var directoryInfo = new DirectoryInfo(diagnosticFolder);
// The runner log that record the start point of the job should created before the job start time.
// The runner log may get paged if it reach size limit.
// We will only need upload 1 runner log file in 99%.
// There might be 1% we need to upload 2 runner log files.
String recentLog = null;
DateTime recentTimeUtc = DateTime.MinValue;
foreach (FileInfo file in directoryInfo.GetFiles().Where(f => f.Name.StartsWith(Constants.Path.RunnerDiagnosticLogPrefix)))
{
// The format of the logs is:
// Runner_20171003-143110-utc.log
if (DateTime.TryParseExact(
s: file.Name.Substring(startIndex: Constants.Path.RunnerDiagnosticLogPrefix.Length, length: DateTimeFormat.Length),
format: DateTimeFormat,
provider: CultureInfo.InvariantCulture,
style: DateTimeStyles.None,
result: out DateTime fileCreateTime))
{
// always add log file created after the job start.
if (fileCreateTime >= jobStartTimeUtc)
{
runnerLogFiles.Add(file.FullName);
}
else if (fileCreateTime > recentTimeUtc)
{
recentLog = file.FullName;
recentTimeUtc = fileCreateTime;
}
}
}
if (!String.IsNullOrEmpty(recentLog))
{
runnerLogFiles.Add(recentLog);
}
return runnerLogFiles;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2.Sdk;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using ObjectTemplating = GitHub.DistributedTask.ObjectTemplating;
using PipelineTemplateConstants = GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(ExpressionManager))]
public interface IExpressionManager : IRunnerService
{
ConditionResult Evaluate(IExecutionContext context, string condition, bool hostTracingOnly = false);
}
public sealed class ExpressionManager : RunnerService, IExpressionManager
{
public ConditionResult Evaluate(IExecutionContext executionContext, string condition, bool hostTracingOnly = false)
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
ConditionResult result = new ConditionResult();
var expressionTrace = new TraceWriter(Trace, hostTracingOnly ? null : executionContext);
var tree = Parse(executionContext, expressionTrace, condition);
var expressionResult = tree.Evaluate(expressionTrace, HostContext.SecretMasker, state: executionContext, options: null);
result.Value = expressionResult.IsTruthy;
result.Trace = expressionTrace.Trace;
return result;
}
private static IExpressionNode Parse(IExecutionContext executionContext, TraceWriter expressionTrace, string condition)
{
ArgUtil.NotNull(executionContext, nameof(executionContext));
if (string.IsNullOrWhiteSpace(condition))
{
condition = $"{PipelineTemplateConstants.Success}()";
}
var parser = new ExpressionParser();
var namedValues = executionContext.ExpressionValues.Keys.Select(x => new NamedValueInfo<ContextValueNode>(x)).ToArray();
var functions = new IFunctionInfo[]
{
new FunctionInfo<AlwaysNode>(name: Constants.Expressions.Always, minParameters: 0, maxParameters: 0),
new FunctionInfo<CancelledNode>(name: Constants.Expressions.Cancelled, minParameters: 0, maxParameters: 0),
new FunctionInfo<FailureNode>(name: Constants.Expressions.Failure, minParameters: 0, maxParameters: 0),
new FunctionInfo<SuccessNode>(name: Constants.Expressions.Success, minParameters: 0, maxParameters: 0),
};
return parser.CreateTree(condition, expressionTrace, namedValues, functions) ?? new SuccessNode();
}
private sealed class TraceWriter : DistributedTask.Expressions2.ITraceWriter
{
private readonly IExecutionContext _executionContext;
private readonly Tracing _trace;
private readonly StringBuilder _traceBuilder = new StringBuilder();
public string Trace => _traceBuilder.ToString();
public TraceWriter(Tracing trace, IExecutionContext executionContext)
{
ArgUtil.NotNull(trace, nameof(trace));
_trace = trace;
_executionContext = executionContext;
}
public void Info(string message)
{
_trace.Info(message);
_executionContext?.Debug(message);
_traceBuilder.AppendLine(message);
}
public void Verbose(string message)
{
_trace.Verbose(message);
_executionContext?.Debug(message);
}
}
private sealed class AlwaysNode : Function
{
protected override Object EvaluateCore(EvaluationContext context, out ResultMemory resultMemory)
{
resultMemory = null;
return true;
}
}
private sealed class CancelledNode : Function
{
protected sealed override object EvaluateCore(EvaluationContext evaluationContext, out ResultMemory resultMemory)
{
resultMemory = null;
var executionContext = evaluationContext.State as IExecutionContext;
ArgUtil.NotNull(executionContext, nameof(executionContext));
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
return jobStatus == ActionResult.Cancelled;
}
}
private sealed class FailureNode : Function
{
protected sealed override object EvaluateCore(EvaluationContext evaluationContext, out ResultMemory resultMemory)
{
resultMemory = null;
var executionContext = evaluationContext.State as IExecutionContext;
ArgUtil.NotNull(executionContext, nameof(executionContext));
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
return jobStatus == ActionResult.Failure;
}
}
private sealed class SuccessNode : Function
{
protected sealed override object EvaluateCore(EvaluationContext evaluationContext, out ResultMemory resultMemory)
{
resultMemory = null;
var executionContext = evaluationContext.State as IExecutionContext;
ArgUtil.NotNull(executionContext, nameof(executionContext));
ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success;
return jobStatus == ActionResult.Success;
}
}
private sealed class ContextValueNode : NamedValue
{
protected override Object EvaluateCore(EvaluationContext evaluationContext, out ResultMemory resultMemory)
{
resultMemory = null;
var jobContext = evaluationContext.State as IExecutionContext;
ArgUtil.NotNull(jobContext, nameof(jobContext));
return jobContext.ExpressionValues[Name];
}
}
}
public class ConditionResult
{
public ConditionResult(bool value = false, string trace = null)
{
this.Value = value;
this.Trace = trace;
}
public bool Value { get; set; }
public string Trace { get; set; }
public static implicit operator ConditionResult(bool value)
{
return new ConditionResult(value);
}
}
}

View File

@@ -0,0 +1,35 @@
using GitHub.DistributedTask.Pipelines.ContextData;
using System;
using System.Collections.Generic;
namespace GitHub.Runner.Worker
{
public sealed class GitHubContext : DictionaryContextData, IEnvironmentContextData
{
private readonly HashSet<string> _contextEnvWhitelist = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"action",
"actor",
"base_ref",
"event_name",
"event_path",
"head_ref",
"ref",
"repository",
"sha",
"workflow",
"workspace",
};
public IEnumerable<KeyValuePair<string, string>> GetRuntimeEnvironmentVariables()
{
foreach (var data in this)
{
if (_contextEnvWhitelist.Contains(data.Key) && data.Value is StringContextData value)
{
yield return new KeyValuePair<string, string>($"GITHUB_{data.Key.ToUpperInvariant()}", value);
}
}
}
}
}

View File

@@ -0,0 +1,203 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System;
using GitHub.Runner.Worker.Container;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.DistributedTask.WebApi;
using GitHub.DistributedTask.Pipelines.ContextData;
using System.Linq;
namespace GitHub.Runner.Worker.Handlers
{
[ServiceLocator(Default = typeof(ContainerActionHandler))]
public interface IContainerActionHandler : IHandler
{
ContainerActionExecutionData Data { get; set; }
}
public sealed class ContainerActionHandler : Handler, IContainerActionHandler
{
public ContainerActionExecutionData Data { get; set; }
public async Task RunAsync(ActionRunStage stage)
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(Data, nameof(Data));
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
#if OS_WINDOWS || OS_OSX
throw new NotSupportedException($"Container action is only supported on Linux");
#else
// Update the env dictionary.
AddInputsToEnvironment();
var dockerManger = HostContext.GetService<IDockerCommandManager>();
// container image haven't built/pull
if (Data.Image.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
{
Data.Image = Data.Image.Substring("docker://".Length);
}
else if (Data.Image.EndsWith("Dockerfile") || Data.Image.EndsWith("dockerfile"))
{
// ensure docker file exist
var dockerFile = Path.Combine(ActionDirectory, Data.Image);
ArgUtil.File(dockerFile, nameof(Data.Image));
ExecutionContext.Output($"Dockerfile for action: '{dockerFile}'.");
var imageName = $"{dockerManger.DockerInstanceLabel}:{ExecutionContext.Id.ToString("N")}";
var buildExitCode = await dockerManger.DockerBuild(ExecutionContext, ExecutionContext.GetGitHubContext("workspace"), Directory.GetParent(dockerFile).FullName, imageName);
if (buildExitCode != 0)
{
throw new InvalidOperationException($"Docker build failed with exit code {buildExitCode}");
}
Data.Image = imageName;
}
// run container
var container = new ContainerInfo()
{
ContainerImage = Data.Image,
ContainerName = ExecutionContext.Id.ToString("N"),
ContainerDisplayName = $"{Pipelines.Validation.NameValidation.Sanitize(Data.Image)}_{Guid.NewGuid().ToString("N").Substring(0, 6)}",
};
if (stage == ActionRunStage.Main)
{
if (!string.IsNullOrEmpty(Data.EntryPoint))
{
// use entrypoint from action.yml
container.ContainerEntryPoint = Data.EntryPoint;
}
else
{
// use entrypoint input, this is for action v1 which doesn't have action.yml
container.ContainerEntryPoint = Inputs.GetValueOrDefault("entryPoint");
}
}
else if (stage == ActionRunStage.Post)
{
container.ContainerEntryPoint = Data.Cleanup;
}
// create inputs context for template evaluation
var inputsContext = new DictionaryContextData();
if (this.Inputs != null)
{
foreach (var input in Inputs)
{
inputsContext.Add(input.Key, new StringContextData(input.Value));
}
}
var evaluateContext = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
evaluateContext["inputs"] = inputsContext;
var manifestManager = HostContext.GetService<IActionManifestManager>();
if (Data.Arguments != null)
{
container.ContainerEntryPointArgs = "";
var evaluatedArgs = manifestManager.EvaluateContainerArguments(ExecutionContext, Data.Arguments, evaluateContext);
foreach (var arg in evaluatedArgs)
{
if (!string.IsNullOrEmpty(arg))
{
container.ContainerEntryPointArgs = container.ContainerEntryPointArgs + $" \"{arg.Replace("\"", "\\\"")}\"";
}
else
{
container.ContainerEntryPointArgs = container.ContainerEntryPointArgs + " \"\"";
}
}
}
else
{
container.ContainerEntryPointArgs = Inputs.GetValueOrDefault("args");
}
if (Data.Environment != null)
{
var evaluatedEnv = manifestManager.EvaluateContainerEnvironment(ExecutionContext, Data.Environment, evaluateContext);
foreach (var env in evaluatedEnv)
{
if (!this.Environment.ContainsKey(env.Key))
{
this.Environment[env.Key] = env.Value;
}
}
}
if (ExecutionContext.JobContext.Container.TryGetValue("network", out var networkContextData) && networkContextData is StringContextData networkStringData)
{
container.ContainerNetwork = networkStringData.ToString();
}
var defaultWorkingDirectory = ExecutionContext.GetGitHubContext("workspace");
var tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
ArgUtil.NotNullOrEmpty(defaultWorkingDirectory, nameof(defaultWorkingDirectory));
ArgUtil.NotNullOrEmpty(tempDirectory, nameof(tempDirectory));
var tempHomeDirectory = Path.Combine(tempDirectory, "_github_home");
Directory.CreateDirectory(tempHomeDirectory);
this.Environment["HOME"] = tempHomeDirectory;
var tempWorkflowDirectory = Path.Combine(tempDirectory, "_github_workflow");
ArgUtil.Directory(tempWorkflowDirectory, nameof(tempWorkflowDirectory));
container.MountVolumes.Add(new MountVolume("/var/run/docker.sock", "/var/run/docker.sock"));
container.MountVolumes.Add(new MountVolume(tempHomeDirectory, "/github/home"));
container.MountVolumes.Add(new MountVolume(tempWorkflowDirectory, "/github/workflow"));
container.MountVolumes.Add(new MountVolume(defaultWorkingDirectory, "/github/workspace"));
container.AddPathTranslateMapping(tempHomeDirectory, "/github/home");
container.AddPathTranslateMapping(tempWorkflowDirectory, "/github/workflow");
container.AddPathTranslateMapping(defaultWorkingDirectory, "/github/workspace");
container.ContainerWorkDirectory = "/github/workspace";
// expose context to environment
foreach (var context in ExecutionContext.ExpressionValues)
{
if (context.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
{
foreach (var env in runtimeContext.GetRuntimeEnvironmentVariables())
{
Environment[env.Key] = env.Value;
}
}
}
// Add Actions Runtime server info
var systemConnection = ExecutionContext.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
Environment["ACTIONS_RUNTIME_URL"] = systemConnection.Url.AbsoluteUri;
Environment["ACTIONS_RUNTIME_TOKEN"] = systemConnection.Authorization.Parameters[EndpointAuthorizationParameters.AccessToken];
if (systemConnection.Data.TryGetValue("CacheServerUrl", out var cacheUrl) && !string.IsNullOrEmpty(cacheUrl))
{
Environment["ACTIONS_CACHE_URL"] = cacheUrl;
}
foreach (var variable in this.Environment)
{
container.ContainerEnvironmentVariables[variable.Key] = container.TranslateToContainerPath(variable.Value);
}
using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager))
using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager))
{
var runExitCode = await dockerManger.DockerRun(ExecutionContext, container, stdoutManager.OnDataReceived, stderrManager.OnDataReceived);
if (runExitCode != 0)
{
ExecutionContext.Error($"Docker run failed with exit code {runExitCode}");
ExecutionContext.Result = TaskResult.Failed;
}
}
#endif
}
}
}

View File

@@ -0,0 +1,177 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using System.IO;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Handlers
{
public interface IHandler : IRunnerService
{
Pipelines.ActionStepDefinitionReference Action { get; set; }
Dictionary<string, string> Environment { get; set; }
IExecutionContext ExecutionContext { get; set; }
Variables RuntimeVariables { get; set; }
IStepHost StepHost { get; set; }
Dictionary<string, string> Inputs { get; set; }
string ActionDirectory { get; set; }
Task RunAsync(ActionRunStage stage);
void PrintActionDetails(ActionRunStage stage);
}
public abstract class Handler : RunnerService
{
#if OS_WINDOWS
// In windows OS the maximum supported size of a environment variable value is 32k.
// You can set environment variable greater then 32K, but that variable will not be able to read in node.exe.
private const int _environmentVariableMaximumSize = 32766;
#endif
protected IActionCommandManager ActionCommandManager { get; private set; }
public Pipelines.ActionStepDefinitionReference Action { get; set; }
public Dictionary<string, string> Environment { get; set; }
public Variables RuntimeVariables { get; set; }
public IExecutionContext ExecutionContext { get; set; }
public IStepHost StepHost { get; set; }
public Dictionary<string, string> Inputs { get; set; }
public string ActionDirectory { get; set; }
public virtual void PrintActionDetails(ActionRunStage stage)
{
if (stage == ActionRunStage.Post)
{
ExecutionContext.Output($"Post job cleanup.");
return;
}
string groupName = "";
if (Action.Type == Pipelines.ActionSourceType.ContainerRegistry)
{
var registryAction = Action as Pipelines.ContainerRegistryReference;
groupName = $"Run docker://{registryAction.Image}";
}
else if (Action.Type == Pipelines.ActionSourceType.Repository)
{
var repoAction = Action as Pipelines.RepositoryPathReference;
if (string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
{
groupName = $"Run {repoAction.Path}";
}
else
{
if (string.IsNullOrEmpty(repoAction.Path))
{
groupName = $"Run {repoAction.Name}@{repoAction.Ref}";
}
else
{
groupName = $"Run {repoAction.Name}/{repoAction.Path}@{repoAction.Ref}";
}
}
}
else
{
// this should never happen
Trace.Error($"Can't generate default folding group name for action {Action.Type.ToString()}");
groupName = "Action details";
}
ExecutionContext.Output($"##[group]{groupName}");
if (this.Inputs?.Count > 0)
{
ExecutionContext.Output("with:");
foreach (var input in this.Inputs)
{
if (!string.IsNullOrEmpty(input.Value))
{
ExecutionContext.Output($" {input.Key}: {input.Value}");
}
}
}
if (this.Environment?.Count > 0)
{
ExecutionContext.Output("env:");
foreach (var env in this.Environment)
{
ExecutionContext.Output($" {env.Key}: {env.Value}");
}
}
ExecutionContext.Output("##[endgroup]");
}
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
ActionCommandManager = hostContext.CreateService<IActionCommandManager>();
}
protected void AddInputsToEnvironment()
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(Inputs, nameof(Inputs));
// Add the inputs to the environment variable dictionary.
foreach (KeyValuePair<string, string> pair in Inputs)
{
AddEnvironmentVariable(
key: $"INPUT_{pair.Key?.Replace(' ', '_').ToUpperInvariant()}",
value: pair.Value);
}
}
protected void AddEnvironmentVariable(string key, string value)
{
ArgUtil.NotNullOrEmpty(key, nameof(key));
Trace.Verbose($"Setting env '{key}' to '{value}'.");
Environment[key] = value ?? string.Empty;
#if OS_WINDOWS
if (Environment[key].Length > _environmentVariableMaximumSize)
{
ExecutionContext.Warning($"Environment variable '{key}' exceeds the maximum supported length. Environment variable length: {value.Length} , Maximum supported length: {_environmentVariableMaximumSize}");
}
#endif
}
protected void AddPrependPathToEnvironment()
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(ExecutionContext.PrependPath, nameof(ExecutionContext.PrependPath));
if (ExecutionContext.PrependPath.Count == 0)
{
return;
}
// Prepend path.
string prepend = string.Join(Path.PathSeparator.ToString(), ExecutionContext.PrependPath.Reverse<string>());
var containerStepHost = StepHost as ContainerStepHost;
if (containerStepHost != null)
{
containerStepHost.PrependPath = prepend;
}
else
{
string taskEnvPATH;
Environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
string originalPath = RuntimeVariables.Get(Constants.PathVariable) ?? // Prefer a job variable.
taskEnvPATH ?? // Then a task-environment variable.
System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
string.Empty;
string newPath = PathUtil.PrependPath(prepend, originalPath);
AddEnvironmentVariable(Constants.PathVariable, newPath);
}
}
}
}

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Handlers
{
[ServiceLocator(Default = typeof(HandlerFactory))]
public interface IHandlerFactory : IRunnerService
{
IHandler Create(
IExecutionContext executionContext,
Pipelines.ActionStepDefinitionReference action,
IStepHost stepHost,
ActionExecutionData data,
Dictionary<string, string> inputs,
Dictionary<string, string> environment,
Variables runtimeVariables,
string actionDirectory);
}
public sealed class HandlerFactory : RunnerService, IHandlerFactory
{
public IHandler Create(
IExecutionContext executionContext,
Pipelines.ActionStepDefinitionReference action,
IStepHost stepHost,
ActionExecutionData data,
Dictionary<string, string> inputs,
Dictionary<string, string> environment,
Variables runtimeVariables,
string actionDirectory)
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
ArgUtil.NotNull(stepHost, nameof(stepHost));
ArgUtil.NotNull(data, nameof(data));
ArgUtil.NotNull(inputs, nameof(inputs));
ArgUtil.NotNull(environment, nameof(environment));
ArgUtil.NotNull(runtimeVariables, nameof(runtimeVariables));
// Create the handler.
IHandler handler;
if (data.ExecutionType == ActionExecutionType.Container)
{
handler = HostContext.CreateService<IContainerActionHandler>();
(handler as IContainerActionHandler).Data = data as ContainerActionExecutionData;
}
else if (data.ExecutionType == ActionExecutionType.NodeJS)
{
handler = HostContext.CreateService<INodeScriptActionHandler>();
(handler as INodeScriptActionHandler).Data = data as NodeJSActionExecutionData;
}
else if (data.ExecutionType == ActionExecutionType.Script)
{
handler = HostContext.CreateService<IScriptHandler>();
(handler as IScriptHandler).Data = data as ScriptActionExecutionData;
}
else if (data.ExecutionType == ActionExecutionType.Plugin)
{
// Agent plugin
handler = HostContext.CreateService<IRunnerPluginHandler>();
(handler as IRunnerPluginHandler).Data = data as PluginActionExecutionData;
}
else
{
// This should never happen.
throw new NotSupportedException(data.ExecutionType.ToString());
}
handler.Action = action;
handler.Environment = environment;
handler.RuntimeVariables = runtimeVariables;
handler.ExecutionContext = executionContext;
handler.StepHost = stepHost;
handler.Inputs = inputs;
handler.ActionDirectory = actionDirectory;
return handler;
}
}
}

View File

@@ -0,0 +1,134 @@
using System.IO;
using System.Text;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using System;
using System.Linq;
namespace GitHub.Runner.Worker.Handlers
{
[ServiceLocator(Default = typeof(NodeScriptActionHandler))]
public interface INodeScriptActionHandler : IHandler
{
NodeJSActionExecutionData Data { get; set; }
}
public sealed class NodeScriptActionHandler : Handler, INodeScriptActionHandler
{
public NodeJSActionExecutionData Data { get; set; }
public async Task RunAsync(ActionRunStage stage)
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(Data, nameof(Data));
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
ArgUtil.NotNull(Inputs, nameof(Inputs));
ArgUtil.Directory(ActionDirectory, nameof(ActionDirectory));
// Update the env dictionary.
AddInputsToEnvironment();
AddPrependPathToEnvironment();
// expose context to environment
foreach (var context in ExecutionContext.ExpressionValues)
{
if (context.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
{
foreach (var env in runtimeContext.GetRuntimeEnvironmentVariables())
{
Environment[env.Key] = env.Value;
}
}
}
// Add Actions Runtime server info
var systemConnection = ExecutionContext.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
Environment["ACTIONS_RUNTIME_URL"] = systemConnection.Url.AbsoluteUri;
Environment["ACTIONS_RUNTIME_TOKEN"] = systemConnection.Authorization.Parameters[EndpointAuthorizationParameters.AccessToken];
if (systemConnection.Data.TryGetValue("CacheServerUrl", out var cacheUrl) && !string.IsNullOrEmpty(cacheUrl))
{
Environment["ACTIONS_CACHE_URL"] = cacheUrl;
}
// Resolve the target script.
string target = null;
if (stage == ActionRunStage.Main)
{
target = Data.Script;
}
else if (stage == ActionRunStage.Post)
{
target = Data.Cleanup;
}
ArgUtil.NotNullOrEmpty(target, nameof(target));
target = Path.Combine(ActionDirectory, target);
ArgUtil.File(target, nameof(target));
// Resolve the working directory.
string workingDirectory = ExecutionContext.GetGitHubContext("workspace");
if (string.IsNullOrEmpty(workingDirectory))
{
workingDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
}
var nodeRuntimeVersion = await StepHost.DetermineNodeRuntimeVersion(ExecutionContext);
string file = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Externals), nodeRuntimeVersion, "bin", $"node{IOUtil.ExeExtension}");
// Format the arguments passed to node.
// 1) Wrap the script file path in double quotes.
// 2) Escape double quotes within the script file path. Double-quote is a valid
// file name character on Linux.
string arguments = StepHost.ResolvePathForStepHost(StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
#if OS_WINDOWS
// It appears that node.exe outputs UTF8 when not in TTY mode.
Encoding outputEncoding = Encoding.UTF8;
#else
// Let .NET choose the default.
Encoding outputEncoding = null;
#endif
using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager))
using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager))
{
StepHost.OutputDataReceived += stdoutManager.OnDataReceived;
StepHost.ErrorDataReceived += stderrManager.OnDataReceived;
// Execute the process. Exit code 0 should always be returned.
// A non-zero exit code indicates infrastructural failure.
// Task failure should be communicated over STDOUT using ## commands.
Task<int> step = StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory),
fileName: StepHost.ResolvePathForStepHost(file),
arguments: arguments,
environment: Environment,
requireExitCodeZero: false,
outputEncoding: outputEncoding,
killProcessOnCancel: false,
inheritConsoleHandler: !ExecutionContext.Variables.Retain_Default_Encoding,
cancellationToken: ExecutionContext.CancellationToken);
// Wait for either the node exit or force finish through ##vso command
await System.Threading.Tasks.Task.WhenAny(step, ExecutionContext.ForceCompleted);
if (ExecutionContext.ForceCompleted.IsCompleted)
{
ExecutionContext.Debug("The task was marked as \"done\", but the process has not closed after 5 seconds. Treating the task as complete.");
}
else
{
var exitCode = await step;
if (exitCode != 0)
{
ExecutionContext.Error($"Node run failed with exit code {exitCode}");
ExecutionContext.Result = TaskResult.Failed;
}
}
}
}
}
}

View File

@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using DTWebApi = GitHub.DistributedTask.WebApi;
namespace GitHub.Runner.Worker.Handlers
{
public sealed class OutputManager : IDisposable
{
private const string _colorCodePrefix = "\033[";
private const int _maxAttempts = 3;
private const string _timeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT";
private static readonly Regex _colorCodeRegex = new Regex(@"\x0033\[[0-9;]*m?", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IActionCommandManager _commandManager;
private readonly IExecutionContext _executionContext;
private readonly object _matchersLock = new object();
private readonly TimeSpan _timeout;
private IssueMatcher[] _matchers = Array.Empty<IssueMatcher>();
public OutputManager(IExecutionContext executionContext, IActionCommandManager commandManager)
{
//executionContext.Debug("ENTERING OutputManager ctor");
_executionContext = executionContext;
_commandManager = commandManager;
//_executionContext.Debug("OutputManager ctor - determine timeout from variable");
// Determine the timeout
var timeoutStr = _executionContext.Variables.Get(_timeoutKey);
if (string.IsNullOrEmpty(timeoutStr) ||
!TimeSpan.TryParse(timeoutStr, CultureInfo.InvariantCulture, out _timeout) ||
_timeout <= TimeSpan.Zero)
{
//_executionContext.Debug("OutputManager ctor - determine timeout from env var");
timeoutStr = Environment.GetEnvironmentVariable(_timeoutKey);
if (string.IsNullOrEmpty(timeoutStr) ||
!TimeSpan.TryParse(timeoutStr, CultureInfo.InvariantCulture, out _timeout) ||
_timeout <= TimeSpan.Zero)
{
//_executionContext.Debug("OutputManager ctor - set timeout to default");
_timeout = TimeSpan.FromSeconds(1);
}
}
//_executionContext.Debug("OutputManager ctor - adding matchers");
// Lock
lock (_matchersLock)
{
//_executionContext.Debug("OutputManager ctor - adding OnMatcherChanged");
_executionContext.Add(OnMatcherChanged);
//_executionContext.Debug("OutputManager ctor - getting matchers");
_matchers = _executionContext.GetMatchers().Select(x => new IssueMatcher(x, _timeout)).ToArray();
}
//_executionContext.Debug("LEAVING OutputManager ctor");
}
public void Dispose()
{
try
{
_executionContext.Remove(OnMatcherChanged);
}
catch
{
}
}
public void OnDataReceived(object sender, ProcessDataReceivedEventArgs e)
{
//_executionContext.Debug("ENTERING OutputManager OnDataReceived");
var line = e.Data;
// ## commands
if (!String.IsNullOrEmpty(line) &&
(line.IndexOf(ActionCommand.Prefix) >= 0 || line.IndexOf(ActionCommand._commandKey) >= 0))
{
// This does not need to be inside of a critical section.
// The logging queues and command handlers are thread-safe.
if (_commandManager.TryProcessCommand(_executionContext, line))
{
//_executionContext.Debug("LEAVING OutputManager OnDataReceived - command processed");
return;
}
}
// Problem matchers
if (_matchers.Length > 0)
{
// Copy the reference
var matchers = _matchers;
// Strip color codes
var stripped = line.Contains(_colorCodePrefix) ? _colorCodeRegex.Replace(line, string.Empty) : line;
foreach (var matcher in matchers)
{
IssueMatch match = null;
for (var attempt = 1; attempt <= _maxAttempts; attempt++)
{
// Match
try
{
match = matcher.Match(stripped);
break;
}
catch (RegexMatchTimeoutException ex)
{
if (attempt < _maxAttempts)
{
// Debug
_executionContext.Debug($"Timeout processing issue matcher '{matcher.Owner}' against line '{stripped}'. Exception: {ex.ToString()}");
}
else
{
// Warn
_executionContext.Warning($"Removing issue matcher '{matcher.Owner}'. Matcher failed {_maxAttempts} times. Error: {ex.Message}");
// Remove
Remove(matcher);
}
}
}
if (match != null)
{
// Reset other matchers
foreach (var otherMatcher in matchers.Where(x => !object.ReferenceEquals(x, matcher)))
{
otherMatcher.Reset();
}
// Convert to issue
var issue = ConvertToIssue(match);
if (issue != null)
{
// Log issue
_executionContext.AddIssue(issue, stripped);
//_executionContext.Debug("LEAVING OutputManager OnDataReceived - issue logged");
return;
}
}
}
}
// Regular output
_executionContext.Output(line);
//_executionContext.Debug("LEAVING OutputManager OnDataReceived");
}
private void OnMatcherChanged(object sender, MatcherChangedEventArgs e)
{
// Lock
lock (_matchersLock)
{
var newMatchers = new List<IssueMatcher>();
// Prepend
if (e.Config.Patterns.Length > 0)
{
newMatchers.Add(new IssueMatcher(e.Config, _timeout));
}
// Add existing non-matching
newMatchers.AddRange(_matchers.Where(x => !string.Equals(x.Owner, e.Config.Owner, StringComparison.OrdinalIgnoreCase)));
// Store
_matchers = newMatchers.ToArray();
}
}
private void Remove(IssueMatcher matcher)
{
// Lock
lock (_matchersLock)
{
var newMatchers = new List<IssueMatcher>();
// Match by object reference, not by owner name
newMatchers.AddRange(_matchers.Where(x => !object.ReferenceEquals(x, matcher)));
// Store
_matchers = newMatchers.ToArray();
}
}
private DTWebApi.Issue ConvertToIssue(IssueMatch match)
{
// Validate the message
if (string.IsNullOrWhiteSpace(match.Message))
{
_executionContext.Debug("Skipping logging an issue for the matched line because the message is empty.");
return null;
}
// Validate the severity
DTWebApi.IssueType issueType;
if (string.IsNullOrEmpty(match.Severity) || string.Equals(match.Severity, "error", StringComparison.OrdinalIgnoreCase))
{
issueType = DTWebApi.IssueType.Error;
}
else if (string.Equals(match.Severity, "warning", StringComparison.OrdinalIgnoreCase))
{
issueType = DTWebApi.IssueType.Warning;
}
else
{
_executionContext.Debug($"Skipped logging an issue for the matched line because the severity '{match.Severity}' is not supported.");
return null;
}
var issue = new DTWebApi.Issue
{
Message = match.Message,
Type = issueType,
};
// Line
if (!string.IsNullOrEmpty(match.Line))
{
if (int.TryParse(match.Line, NumberStyles.None, CultureInfo.InvariantCulture, out var line))
{
issue.Data["line"] = line.ToString(CultureInfo.InvariantCulture);
}
else
{
_executionContext.Debug($"Unable to parse line number '{match.Line}'");
}
}
// Column
if (!string.IsNullOrEmpty(match.Column))
{
if (int.TryParse(match.Column, NumberStyles.None, CultureInfo.InvariantCulture, out var column))
{
issue.Data["col"] = column.ToString(CultureInfo.InvariantCulture);
}
else
{
_executionContext.Debug($"Unable to parse column number '{match.Column}'");
}
}
// Code
if (!string.IsNullOrWhiteSpace(match.Code))
{
issue.Data["code"] = match.Code.Trim();
}
// File
try
{
if (!string.IsNullOrWhiteSpace(match.File))
{
var file = match.File;
// Root using fromPath
if (!string.IsNullOrWhiteSpace(match.FromPath) && !Path.IsPathRooted(file))
{
file = Path.Combine(match.FromPath, file);
}
// Root using system.defaultWorkingDirectory
if (!Path.IsPathRooted(file))
{
var githubContext = _executionContext.ExpressionValues["github"] as GitHubContext;
ArgUtil.NotNull(githubContext, nameof(githubContext));
var workspace = githubContext["workspace"].ToString();
ArgUtil.NotNullOrEmpty(workspace, "workspace");
file = Path.Combine(workspace, file);
}
// Normalize slashes
file = file.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
// File exists
if (File.Exists(file))
{
// Repository path
var repositoryPath = _executionContext.GetGitHubContext("workspace");
ArgUtil.NotNullOrEmpty(repositoryPath, nameof(repositoryPath));
// Normalize slashes
repositoryPath = repositoryPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!file.StartsWith(repositoryPath, IOUtil.FilePathStringComparison))
{
// File is not under repo
_executionContext.Debug($"Dropping file value '{file}'. Path is not under the repo.");
}
else
{
// prefer `/` on all platforms
issue.Data["file"] = file.Substring(repositoryPath.Length).TrimStart(Path.DirectorySeparatorChar).Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}
// File does not exist
else
{
_executionContext.Debug($"Dropping file value '{file}'. Path does not exist");
}
}
}
catch (Exception ex)
{
_executionContext.Debug($"Dropping file value '{match.File}' and fromPath value '{match.FromPath}'. Exception during validation: {ex.ToString()}");
}
return issue;
}
}
}

View File

@@ -0,0 +1,58 @@
using System.Threading.Tasks;
using System;
using GitHub.Runner.Sdk;
using GitHub.Runner.Common;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Handlers
{
[ServiceLocator(Default = typeof(RunnerPluginHandler))]
public interface IRunnerPluginHandler : IHandler
{
PluginActionExecutionData Data { get; set; }
}
public sealed class RunnerPluginHandler : Handler, IRunnerPluginHandler
{
public PluginActionExecutionData Data { get; set; }
public async Task RunAsync(ActionRunStage stage)
{
// Validate args.
Trace.Entering();
ArgUtil.NotNull(Data, nameof(Data));
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
ArgUtil.NotNull(Inputs, nameof(Inputs));
string plugin = null;
if (stage == ActionRunStage.Main)
{
plugin = Data.Plugin;
}
else if (stage == ActionRunStage.Post)
{
plugin = Data.Cleanup;
}
ArgUtil.NotNullOrEmpty(plugin, nameof(plugin));
// Update the env dictionary.
AddPrependPathToEnvironment();
// Make sure only particular task get run as runner plugin.
var runnerPlugin = HostContext.GetService<IRunnerPluginManager>();
using (var outputManager = new OutputManager(ExecutionContext, ActionCommandManager))
{
ActionCommandManager.EnablePluginInternalCommand();
try
{
await runnerPlugin.RunPluginActionAsync(ExecutionContext, plugin, Inputs, Environment, RuntimeVariables, outputManager.OnDataReceived);
}
finally
{
ActionCommandManager.DisablePluginInternalCommand();
}
}
}
}
}

View File

@@ -0,0 +1,241 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Handlers
{
[ServiceLocator(Default = typeof(ScriptHandler))]
public interface IScriptHandler : IHandler
{
ScriptActionExecutionData Data { get; set; }
}
public sealed class ScriptHandler : Handler, IScriptHandler
{
public ScriptActionExecutionData Data { get; set; }
public override void PrintActionDetails(ActionRunStage stage)
{
if (stage == ActionRunStage.Post)
{
throw new NotSupportedException("Script action should not have 'Post' job action.");
}
Inputs.TryGetValue("script", out string contents);
contents = contents ?? string.Empty;
if (Action.Type == Pipelines.ActionSourceType.Script)
{
var firstLine = contents.TrimStart(' ', '\t', '\r', '\n');
var firstNewLine = firstLine.IndexOfAny(new[] { '\r', '\n' });
if (firstNewLine >= 0)
{
firstLine = firstLine.Substring(0, firstNewLine);
}
ExecutionContext.Output($"##[group]Run {firstLine}");
}
else
{
throw new InvalidOperationException($"Invalid action type {Action.Type} for {nameof(ScriptHandler)}");
}
var multiLines = contents.Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
foreach (var line in multiLines)
{
// Bright Cyan color
ExecutionContext.Output($"\x1b[36;1m{line}\x1b[0m");
}
string argFormat;
string shellCommand;
string shellCommandPath = null;
bool validateShellOnHost = !(StepHost is ContainerStepHost);
Inputs.TryGetValue("shell", out var shell);
if (string.IsNullOrEmpty(shell))
{
#if OS_WINDOWS
shellCommand = "cmd";
if(validateShellOnHost)
{
shellCommandPath = System.Environment.GetEnvironmentVariable("ComSpec");
}
#else
shellCommand = "sh";
if (validateShellOnHost)
{
shellCommandPath = WhichUtil.Which("bash") ?? WhichUtil.Which("sh", true, Trace);
}
#endif
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
else
{
var parsed = ScriptHandlerHelpers.ParseShellOptionString(shell);
shellCommand = parsed.shellCommand;
if (validateShellOnHost)
{
shellCommandPath = WhichUtil.Which(parsed.shellCommand, true, Trace);
}
argFormat = $"{parsed.shellArgs}".TrimStart();
if (string.IsNullOrEmpty(argFormat))
{
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
}
if (!string.IsNullOrEmpty(shellCommandPath))
{
ExecutionContext.Output($"shell: {shellCommandPath} {argFormat}");
}
else
{
ExecutionContext.Output($"shell: {shellCommand} {argFormat}");
}
if (this.Environment?.Count > 0)
{
ExecutionContext.Output("env:");
foreach (var env in this.Environment)
{
ExecutionContext.Output($" {env.Key}: {env.Value}");
}
}
ExecutionContext.Output("##[endgroup]");
}
public async Task RunAsync(ActionRunStage stage)
{
if (stage == ActionRunStage.Post)
{
throw new NotSupportedException("Script action should not have 'Post' job action.");
}
// Validate args
Trace.Entering();
ArgUtil.NotNull(ExecutionContext, nameof(ExecutionContext));
ArgUtil.NotNull(Inputs, nameof(Inputs));
var githubContext = ExecutionContext.ExpressionValues["github"] as GitHubContext;
ArgUtil.NotNull(githubContext, nameof(githubContext));
var tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
Inputs.TryGetValue("script", out var contents);
contents = contents ?? string.Empty;
Inputs.TryGetValue("workingDirectory", out var workingDirectory);
var workspaceDir = githubContext["workspace"] as StringContextData;
workingDirectory = Path.Combine(workspaceDir, workingDirectory ?? string.Empty);
Inputs.TryGetValue("shell", out var shell);
var isContainerStepHost = StepHost is ContainerStepHost;
string commandPath, argFormat, shellCommand;
// Set up default command and arguments
if (string.IsNullOrEmpty(shell))
{
#if OS_WINDOWS
shellCommand = "cmd";
commandPath = System.Environment.GetEnvironmentVariable("ComSpec");
ArgUtil.NotNullOrEmpty(commandPath, "%ComSpec%");
#else
shellCommand = "sh";
commandPath = WhichUtil.Which("bash", false, Trace) ?? WhichUtil.Which("sh", true, Trace);
#endif
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
else
{
var parsed = ScriptHandlerHelpers.ParseShellOptionString(shell);
shellCommand = parsed.shellCommand;
// For non-ContainerStepHost, the command must be located on the host by Which
commandPath = WhichUtil.Which(parsed.shellCommand, !isContainerStepHost, Trace);
argFormat = $"{parsed.shellArgs}".TrimStart();
if (string.IsNullOrEmpty(argFormat))
{
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
}
// No arg format was given, shell must be a built-in
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
{
throw new ArgumentException("Invalid shell option. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{0}'");
}
// We do not not the full path until we know what shell is being used, so that we can determine the file extension
var scriptFilePath = Path.Combine(tempDirectory, $"{Guid.NewGuid()}{ScriptHandlerHelpers.GetScriptFileExtension(shellCommand)}");
var resolvedScriptPath = $"{StepHost.ResolvePathForStepHost(scriptFilePath).Replace("\"", "\\\"")}";
// Format arg string with script path
var arguments = string.Format(argFormat, resolvedScriptPath);
// Fix up and write the script
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
#if OS_WINDOWS
// Normalize Windows line endings
contents = contents.Replace("\r\n", "\n").Replace("\n", "\r\n");
var encoding = ExecutionContext.Variables.Retain_Default_Encoding && Console.InputEncoding.CodePage != 65001
? Console.InputEncoding
: new UTF8Encoding(false);
#else
// Don't add a BOM. It causes the script to fail on some operating systems (e.g. on Ubuntu 14).
var encoding = new UTF8Encoding(false);
#endif
// Script is written to local path (ie host) but executed relative to the StepHost, which may be a container
File.WriteAllText(scriptFilePath, contents, encoding);
// Prepend PATH
AddPrependPathToEnvironment();
// expose context to environment
foreach (var context in ExecutionContext.ExpressionValues)
{
if (context.Value is IEnvironmentContextData runtimeContext && runtimeContext != null)
{
foreach (var env in runtimeContext.GetRuntimeEnvironmentVariables())
{
Environment[env.Key] = env.Value;
}
}
}
// dump out the command
var fileName = isContainerStepHost ? shellCommand : commandPath;
ExecutionContext.Debug($"{fileName} {arguments}");
using (var stdoutManager = new OutputManager(ExecutionContext, ActionCommandManager))
using (var stderrManager = new OutputManager(ExecutionContext, ActionCommandManager))
{
StepHost.OutputDataReceived += stdoutManager.OnDataReceived;
StepHost.ErrorDataReceived += stderrManager.OnDataReceived;
// Execute
int exitCode = await StepHost.ExecuteAsync(workingDirectory: StepHost.ResolvePathForStepHost(workingDirectory),
fileName: fileName,
arguments: arguments,
environment: Environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: false,
inheritConsoleHandler: !ExecutionContext.Variables.Retain_Default_Encoding,
cancellationToken: ExecutionContext.CancellationToken);
// Error
if (exitCode != 0)
{
ExecutionContext.Error($"Process completed with exit code {exitCode}.");
ExecutionContext.Result = TaskResult.Failed;
}
}
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
namespace GitHub.Runner.Worker.Handlers
{
internal class ScriptHandlerHelpers
{
private static readonly Dictionary<string, string> _defaultArguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["cmd"] = "/D /E:ON /V:OFF /S /C \"CALL \"{0}\"\"",
["pwsh"] = "-command \". '{0}'\"",
["powershell"] = "-command \". '{0}'\"",
["bash"] = "--noprofile --norc -e -o pipefail {0}",
["sh"] = "-e {0}",
["python"] = "{0}"
};
private static readonly Dictionary<string, string> _extensions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["cmd"] = ".cmd",
["pwsh"] = ".ps1",
["powershell"] = ".ps1",
["bash"] = ".sh",
["sh"] = ".sh",
["python"] = ".py"
};
internal static string GetScriptArgumentsFormat(string scriptType)
{
if (_defaultArguments.TryGetValue(scriptType, out var argFormat))
{
return argFormat;
}
return "";
}
internal static string GetScriptFileExtension(string scriptType)
{
if (_extensions.TryGetValue(scriptType, out var extension))
{
return extension;
}
return "";
}
internal static string FixUpScriptContents(string scriptType, string contents)
{
switch (scriptType)
{
case "cmd":
// Note, use @echo off instead of using the /Q command line switch.
// When /Q is used, echo can't be turned on.
contents = $"@echo off{Environment.NewLine}{contents}";
break;
case "powershell":
case "pwsh":
var prepend = "$ErrorActionPreference = 'stop'";
var append = @"if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }";
contents = $"{prepend}{Environment.NewLine}{contents}{Environment.NewLine}{append}";
break;
}
return contents;
}
internal static (string shellCommand, string shellArgs) ParseShellOptionString(string shellOption)
{
var shellStringParts = shellOption.Split(" ", 2);
if (shellStringParts.Length == 2)
{
return (shellCommand: shellStringParts[0], shellArgs: shellStringParts[1]);
}
else if (shellStringParts.Length == 1)
{
return (shellCommand: shellStringParts[0], shellArgs: "");
}
else
{
throw new ArgumentException($"Failed to parse COMMAND [..ARGS] from {shellOption}");
}
}
}
}

View File

@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Worker.Container;
using GitHub.Services.WebApi;
using Newtonsoft.Json;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using System.Linq;
namespace GitHub.Runner.Worker.Handlers
{
public interface IStepHost : IRunnerService
{
event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
string ResolvePathForStepHost(string path);
Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext);
Task<int> ExecuteAsync(string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> environment,
bool requireExitCodeZero,
Encoding outputEncoding,
bool killProcessOnCancel,
bool inheritConsoleHandler,
CancellationToken cancellationToken);
}
[ServiceLocator(Default = typeof(ContainerStepHost))]
public interface IContainerStepHost : IStepHost
{
ContainerInfo Container { get; set; }
string PrependPath { get; set; }
}
[ServiceLocator(Default = typeof(DefaultStepHost))]
public interface IDefaultStepHost : IStepHost
{
}
public sealed class DefaultStepHost : RunnerService, IDefaultStepHost
{
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
public string ResolvePathForStepHost(string path)
{
return path;
}
public Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext)
{
return Task.FromResult<string>("node12");
}
public async Task<int> ExecuteAsync(string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> environment,
bool requireExitCodeZero,
Encoding outputEncoding,
bool killProcessOnCancel,
bool inheritConsoleHandler,
CancellationToken cancellationToken)
{
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
{
processInvoker.OutputDataReceived += OutputDataReceived;
processInvoker.ErrorDataReceived += ErrorDataReceived;
return await processInvoker.ExecuteAsync(workingDirectory: workingDirectory,
fileName: fileName,
arguments: arguments,
environment: environment,
requireExitCodeZero: requireExitCodeZero,
outputEncoding: outputEncoding,
killProcessOnCancel: killProcessOnCancel,
redirectStandardIn: null,
inheritConsoleHandler: inheritConsoleHandler,
cancellationToken: cancellationToken);
}
}
}
public sealed class ContainerStepHost : RunnerService, IContainerStepHost
{
public ContainerInfo Container { get; set; }
public string PrependPath { get; set; }
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
public string ResolvePathForStepHost(string path)
{
// make sure container exist.
ArgUtil.NotNull(Container, nameof(Container));
ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId));
// remove double quotes around the path
path = path.Trim('\"');
// try to resolve path inside container if the request path is part of the mount volume
#if OS_WINDOWS
if (Container.MountVolumes.Exists(x => path.StartsWith(x.SourceVolumePath, StringComparison.OrdinalIgnoreCase)))
#else
if (Container.MountVolumes.Exists(x => path.StartsWith(x.SourceVolumePath)))
#endif
{
return Container.TranslateToContainerPath(path);
}
else
{
return path;
}
}
public async Task<string> DetermineNodeRuntimeVersion(IExecutionContext executionContext)
{
// Best effort to determine a compatible node runtime
// There may be more variation in which libraries are linked than just musl/glibc,
// so determine based on known distribtutions instead
var osReleaseIdCmd = "sh -c \"cat /etc/*release | grep ^ID\"";
var dockerManager = HostContext.GetService<IDockerCommandManager>();
var output = new List<string>();
var execExitCode = await dockerManager.DockerExec(executionContext, Container.ContainerId, string.Empty, osReleaseIdCmd, output);
string nodeExternal;
if (execExitCode == 0)
{
foreach (var line in output)
{
executionContext.Debug(line);
if (line.ToLower().Contains("alpine"))
{
nodeExternal = "node12_alpine";
executionContext.Output($"Container distribution is alpine. Running JavaScript Action with external tool: {nodeExternal}");
return nodeExternal;
}
}
}
// Optimistically use the default
nodeExternal = "node12";
executionContext.Output($"Running JavaScript Action with default external tool: {nodeExternal}");
return nodeExternal;
}
public async Task<int> ExecuteAsync(string workingDirectory,
string fileName,
string arguments,
IDictionary<string, string> environment,
bool requireExitCodeZero,
Encoding outputEncoding,
bool killProcessOnCancel,
bool inheritConsoleHandler,
CancellationToken cancellationToken)
{
// make sure container exist.
ArgUtil.NotNull(Container, nameof(Container));
ArgUtil.NotNullOrEmpty(Container.ContainerId, nameof(Container.ContainerId));
var dockerManager = HostContext.GetService<IDockerCommandManager>();
string dockerClientPath = dockerManager.DockerPath;
// Usage: docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
IList<string> dockerCommandArgs = new List<string>();
dockerCommandArgs.Add($"exec");
// [OPTIONS]
dockerCommandArgs.Add($"-i");
dockerCommandArgs.Add($"--workdir {workingDirectory}");
foreach (var env in environment)
{
// e.g. -e MY_SECRET maps the value into the exec'ed process without exposing
// the value directly in the command
dockerCommandArgs.Add($"-e {env.Key}");
}
if (!string.IsNullOrEmpty(PrependPath))
{
// Prepend tool paths to container's PATH
var fullPath = !string.IsNullOrEmpty(Container.ContainerRuntimePath) ? $"{PrependPath}:{Container.ContainerRuntimePath}" : PrependPath;
dockerCommandArgs.Add($"-e PATH=\"{fullPath}\"");
}
// CONTAINER
dockerCommandArgs.Add($"{Container.ContainerId}");
// COMMAND
dockerCommandArgs.Add(fileName);
// [ARG...]
dockerCommandArgs.Add(arguments);
string dockerCommandArgstring = string.Join(" ", dockerCommandArgs);
// make sure all env are using container path
foreach (var envKey in environment.Keys.ToList())
{
environment[envKey] = this.Container.TranslateToContainerPath(environment[envKey]);
}
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
{
processInvoker.OutputDataReceived += OutputDataReceived;
processInvoker.ErrorDataReceived += ErrorDataReceived;
#if OS_WINDOWS
// It appears that node.exe outputs UTF8 when not in TTY mode.
outputEncoding = Encoding.UTF8;
#else
// Let .NET choose the default.
outputEncoding = null;
#endif
return await processInvoker.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work),
fileName: dockerClientPath,
arguments: dockerCommandArgstring,
environment: environment,
requireExitCodeZero: requireExitCodeZero,
outputEncoding: outputEncoding,
killProcessOnCancel: killProcessOnCancel,
redirectStandardIn: null,
inheritConsoleHandler: inheritConsoleHandler,
cancellationToken: cancellationToken);
}
}
}
}

View File

@@ -0,0 +1,7 @@
using System;
using System.Collections.Generic;
public interface IEnvironmentContextData
{
IEnumerable<KeyValuePair<string, string>> GetRuntimeEnvironmentVariables();
}

View File

@@ -0,0 +1,445 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Text.RegularExpressions;
namespace GitHub.Runner.Worker
{
public delegate void OnMatcherChanged(object sender, MatcherChangedEventArgs e);
public sealed class MatcherChangedEventArgs : EventArgs
{
public MatcherChangedEventArgs(IssueMatcherConfig config)
{
Config = config;
}
public IssueMatcherConfig Config { get; }
}
public sealed class IssueMatcher
{
private string _owner;
private IssuePattern[] _patterns;
private IssueMatch[] _state;
public IssueMatcher(IssueMatcherConfig config, TimeSpan timeout)
{
_owner = config.Owner;
_patterns = config.Patterns.Select(x => new IssuePattern(x , timeout)).ToArray();
Reset();
}
public string Owner
{
get
{
if (_owner == null)
{
_owner = String.Empty;
}
return _owner;
}
}
public IssueMatch Match(string line)
{
// Single pattern
if (_patterns.Length == 1)
{
var pattern = _patterns[0];
var regexMatch = pattern.Regex.Match(line);
if (regexMatch.Success)
{
return new IssueMatch(null, pattern, regexMatch.Groups);
}
return null;
}
// Multiple patterns
else
{
// Each pattern (iterate in reverse)
for (int i = _patterns.Length - 1; i >= 0; i--)
{
var runningMatch = i > 0 ? _state[i - 1] : null;
// First pattern or a running match
if (i == 0 || runningMatch != null)
{
var pattern = _patterns[i];
var isLast = i == _patterns.Length - 1;
var regexMatch = pattern.Regex.Match(line);
// Matched
if (regexMatch.Success)
{
// Last pattern
if (isLast)
{
// Loop
if (pattern.Loop)
{
// Clear most state, but preserve the running match
Reset();
_state[i - 1] = runningMatch;
}
// Not loop
else
{
// Clear the state
Reset();
}
// Return
return new IssueMatch(runningMatch, pattern, regexMatch.Groups);
}
// Not the last pattern
else
{
// Store the match
_state[i] = new IssueMatch(runningMatch, pattern, regexMatch.Groups);
}
}
// Not matched
else
{
// Last pattern
if (isLast)
{
// Break the running match
_state[i - 1] = null;
}
// Not the last pattern
else
{
// Record not matched
_state[i] = null;
}
}
}
}
return null;
}
}
public void Reset()
{
_state = new IssueMatch[_patterns.Length - 1];
}
}
public sealed class IssuePattern
{
public IssuePattern(IssuePatternConfig config, TimeSpan timeout)
{
File = config.File;
Line = config.Line;
Column = config.Column;
Severity = config.Severity;
Code = config.Code;
Message = config.Message;
FromPath = config.FromPath;
Loop = config.Loop;
Regex = new Regex(config.Pattern ?? string.Empty, IssuePatternConfig.RegexOptions, timeout);
}
public int? File { get; }
public int? Line { get; }
public int? Column { get; }
public int? Severity { get; }
public int? Code { get; }
public int? Message { get; }
public int? FromPath { get; }
public bool Loop { get; }
public Regex Regex { get; }
}
public sealed class IssueMatch
{
public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups)
{
File = runningMatch?.File ?? GetValue(groups, pattern.File);
Line = runningMatch?.Line ?? GetValue(groups, pattern.Line);
Column = runningMatch?.Column ?? GetValue(groups, pattern.Column);
Severity = runningMatch?.Severity ?? GetValue(groups, pattern.Severity);
Code = runningMatch?.Code ?? GetValue(groups, pattern.Code);
Message = runningMatch?.Message ?? GetValue(groups, pattern.Message);
FromPath = runningMatch?.FromPath ?? GetValue(groups, pattern.FromPath);
}
public string File { get; }
public string Line { get; }
public string Column { get; }
public string Severity { get; }
public string Code { get; }
public string Message { get; }
public string FromPath { get; }
private string GetValue(GroupCollection groups, int? index)
{
if (index.HasValue && index.Value < groups.Count)
{
var group = groups[index.Value];
return group.Value;
}
return null;
}
}
[DataContract]
public sealed class IssueMatchersConfig
{
[DataMember(Name = "problemMatcher")]
private List<IssueMatcherConfig> _matchers;
public List<IssueMatcherConfig> Matchers
{
get
{
if (_matchers == null)
{
_matchers = new List<IssueMatcherConfig>();
}
return _matchers;
}
set
{
_matchers = value;
}
}
public void Validate()
{
var distinctOwners = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_matchers?.Count > 0)
{
foreach (var matcher in _matchers)
{
matcher.Validate();
if (!distinctOwners.Add(matcher.Owner))
{
// Not localized since this is a programming contract
throw new ArgumentException($"Duplicate owner name '{matcher.Owner}'");
}
}
}
}
}
[DataContract]
public sealed class IssueMatcherConfig
{
[DataMember(Name = "owner")]
private string _owner;
[DataMember(Name = "pattern")]
private IssuePatternConfig[] _patterns;
public string Owner
{
get
{
if (_owner == null)
{
_owner = String.Empty;
}
return _owner;
}
set
{
_owner = value;
}
}
public IssuePatternConfig[] Patterns
{
get
{
if (_patterns == null)
{
_patterns = new IssuePatternConfig[0];
}
return _patterns;
}
set
{
_patterns = value;
}
}
public void Validate()
{
// Validate owner
if (string.IsNullOrEmpty(_owner))
{
throw new ArgumentException("Owner must not be empty");
}
// Validate at least one pattern
if (_patterns == null || _patterns.Length == 0)
{
throw new ArgumentException($"Matcher '{_owner}' does not contain any patterns");
}
int? file = null;
int? line = null;
int? column = null;
int? severity = null;
int? code = null;
int? message = null;
int? fromPath = null;
// Validate each pattern config
for (var i = 0; i < _patterns.Length; i++)
{
var isFirst = i == 0;
var isLast = i == _patterns.Length - 1;
var pattern = _patterns[i];
pattern.Validate(isFirst,
isLast,
ref file,
ref line,
ref column,
ref severity,
ref code,
ref message,
ref fromPath);
}
if (message == null)
{
throw new ArgumentException($"At least one pattern must set 'message'");
}
}
}
[DataContract]
public sealed class IssuePatternConfig
{
private const string _filePropertyName = "file";
private const string _linePropertyName = "line";
private const string _columnPropertyName = "column";
private const string _severityPropertyName = "severity";
private const string _codePropertyName = "code";
private const string _messagePropertyName = "message";
private const string _fromPathPropertyName = "fromPath";
private const string _loopPropertyName = "loop";
private const string _regexpPropertyName = "regexp";
internal static readonly RegexOptions RegexOptions = RegexOptions.CultureInvariant | RegexOptions.ECMAScript;
[DataMember(Name = _filePropertyName)]
public int? File { get; set; }
[DataMember(Name = _linePropertyName)]
public int? Line { get; set; }
[DataMember(Name = _columnPropertyName)]
public int? Column { get; set; }
[DataMember(Name = _severityPropertyName)]
public int? Severity { get; set; }
[DataMember(Name = _codePropertyName)]
public int? Code { get; set; }
[DataMember(Name = _messagePropertyName)]
public int? Message { get; set; }
[DataMember(Name = _fromPathPropertyName)]
public int? FromPath { get; set; }
[DataMember(Name = _loopPropertyName)]
public bool Loop { get; set; }
[DataMember(Name = _regexpPropertyName)]
public string Pattern { get; set; }
public void Validate(
bool isFirst,
bool isLast,
ref int? file,
ref int? line,
ref int? column,
ref int? severity,
ref int? code,
ref int? message,
ref int? fromPath)
{
// Only the last pattern in a multiline matcher may set 'loop'
if (Loop && (isFirst || !isLast))
{
throw new ArgumentException($"Only the last pattern in a multiline matcher may set '{_loopPropertyName}'");
}
if (Loop && Message == null)
{
throw new ArgumentException($"The {_loopPropertyName} pattern must set '{_messagePropertyName}'");
}
var regex = new Regex(Pattern ?? string.Empty, RegexOptions);
var groupCount = regex.GetGroupNumbers().Length;
Validate(_filePropertyName, groupCount, File, ref file);
Validate(_linePropertyName, groupCount, Line, ref line);
Validate(_columnPropertyName, groupCount, Column, ref column);
Validate(_severityPropertyName, groupCount, Severity, ref severity);
Validate(_codePropertyName, groupCount, Code, ref code);
Validate(_messagePropertyName, groupCount, Message, ref message);
Validate(_fromPathPropertyName, groupCount, FromPath, ref fromPath);
}
private void Validate(string propertyName, int groupCount, int? newValue, ref int? trackedValue)
{
if (newValue == null)
{
return;
}
// The property '___' is set twice
if (trackedValue != null)
{
throw new ArgumentException($"The property '{propertyName}' is set twice");
}
// Out of range
if (newValue.Value < 0 || newValue >= groupCount)
{
throw new ArgumentException($"The property '{propertyName}' is set to {newValue} which is out of range");
}
// Record the value
if (newValue != null)
{
trackedValue = newValue;
}
}
}
}

View File

@@ -0,0 +1,60 @@
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker
{
public sealed class JobContext : DictionaryContextData
{
public ActionResult? Status
{
get
{
if (this.TryGetValue("status", out var status) && status is StringContextData statusString)
{
return EnumUtil.TryParse<ActionResult>(statusString);
}
else
{
return null;
}
}
set
{
this["status"] = new StringContextData(value.ToString());
}
}
public DictionaryContextData Services
{
get
{
if (this.TryGetValue("services", out var services) && services is DictionaryContextData servicesDictionary)
{
return servicesDictionary;
}
else
{
this["services"] = new DictionaryContextData();
return this["services"] as DictionaryContextData;
}
}
}
public DictionaryContextData Container
{
get
{
if (this.TryGetValue("container", out var container) && container is DictionaryContextData containerDictionary)
{
return containerDictionary;
}
else
{
this["container"] = new DictionaryContextData();
return this["container"] as DictionaryContextData;
}
}
}
}
}

View File

@@ -0,0 +1,399 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using Pipelines = GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(JobExtension))]
public interface IJobExtension : IRunnerService
{
Task<List<IStep>> InitializeJob(IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message);
Task FinalizeJob(IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message, DateTime jobStartTimeUtc);
}
public sealed class JobExtension : RunnerService, IJobExtension
{
private readonly HashSet<string> _existingProcesses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private bool _processCleanup;
private string _processLookupId = $"github_{Guid.NewGuid()}";
// Download all required actions.
// Make sure all condition inputs are valid.
// Build up three list of steps for jobrunner (pre-job, job, post-job).
public async Task<List<IStep>> InitializeJob(IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message)
{
Trace.Entering();
ArgUtil.NotNull(jobContext, nameof(jobContext));
ArgUtil.NotNull(message, nameof(message));
// Create a new timeline record for 'Set up job'
IExecutionContext context = jobContext.CreateChild(Guid.NewGuid(), "Set up job", $"{nameof(JobExtension)}_Init", null, null);
List<IStep> preJobSteps = new List<IStep>();
List<IStep> jobSteps = new List<IStep>();
List<IStep> postJobSteps = new List<IStep>();
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
{
try
{
context.Start();
context.Debug($"Starting: Set up job");
context.Output($"Current runner version: '{BuildConstants.RunnerPackage.Version}'");
var repoFullName = context.GetGitHubContext("repository");
ArgUtil.NotNull(repoFullName, nameof(repoFullName));
context.Debug($"Primary repository: {repoFullName}");
// Print proxy setting information for better diagnostic experience
var runnerWebProxy = HostContext.GetService<IRunnerWebProxy>();
if (!string.IsNullOrEmpty(runnerWebProxy.ProxyAddress))
{
context.Output($"Runner is running behind proxy server: '{runnerWebProxy.ProxyAddress}'");
}
// Prepare the workflow directory
context.Output("Prepare workflow directory");
var directoryManager = HostContext.GetService<IPipelineDirectoryManager>();
TrackingConfig trackingConfig = directoryManager.PrepareDirectory(
context,
message.Workspace);
// Set the directory variables
context.Debug("Update context data");
string _workDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
context.SetRunnerContext("workspace", Path.Combine(_workDirectory, trackingConfig.PipelineDirectory));
context.SetGitHubContext("workspace", Path.Combine(_workDirectory, trackingConfig.WorkspaceDirectory));
// Evaluate the job-level environment variables
context.Debug("Evaluating job-level environment variables");
var templateTrace = context.ToTemplateTraceWriter();
var schema = new PipelineTemplateSchemaFactory().CreateSchema();
var templateEvaluator = new PipelineTemplateEvaluator(templateTrace, schema);
foreach (var token in message.EnvironmentVariables)
{
var environmentVariables = templateEvaluator.EvaluateStepEnvironment(token, jobContext.ExpressionValues, VarUtil.EnvironmentVariableKeyComparer);
foreach (var pair in environmentVariables)
{
context.EnvironmentVariables[pair.Key] = pair.Value ?? string.Empty;
context.SetEnvContext(pair.Key, pair.Value ?? string.Empty);
}
}
// Evaluate the job container
context.Debug("Evaluating job container");
var container = templateEvaluator.EvaluateJobContainer(message.JobContainer, jobContext.ExpressionValues);
if (container != null)
{
jobContext.Container = new Container.ContainerInfo(HostContext, container);
}
// Evaluate the job service containers
context.Debug("Evaluating job service containers");
var serviceContainers = templateEvaluator.EvaluateJobServiceContainers(message.JobServiceContainers, jobContext.ExpressionValues);
if (serviceContainers?.Count > 0)
{
foreach (var pair in serviceContainers)
{
var networkAlias = pair.Key;
var serviceContainer = pair.Value;
jobContext.ServiceContainers.Add(new Container.ContainerInfo(HostContext, serviceContainer, false, networkAlias));
}
}
// Build up 3 lists of steps, pre-job, job, post-job
var postJobStepsBuilder = new Stack<IStep>();
// Download actions not already in the cache
Trace.Info("Downloading actions");
var actionManager = HostContext.GetService<IActionManager>();
var prepareSteps = await actionManager.PrepareActionsAsync(context, message.Steps);
preJobSteps.AddRange(prepareSteps);
// Add start-container steps, record and stop-container steps
if (jobContext.Container != null || jobContext.ServiceContainers.Count > 0)
{
var containerProvider = HostContext.GetService<IContainerOperationProvider>();
var containers = new List<Container.ContainerInfo>();
if (jobContext.Container != null)
{
containers.Add(jobContext.Container);
}
containers.AddRange(jobContext.ServiceContainers);
preJobSteps.Add(new JobExtensionRunner(runAsync: containerProvider.StartContainersAsync,
condition: $"{PipelineTemplateConstants.Success}()",
displayName: "Initialize containers",
data: (object)containers));
postJobStepsBuilder.Push(new JobExtensionRunner(runAsync: containerProvider.StopContainersAsync,
condition: $"{PipelineTemplateConstants.Always}()",
displayName: "Stop containers",
data: (object)containers));
}
// Add action steps
foreach (var step in message.Steps)
{
if (step.Type == Pipelines.StepType.Action)
{
var action = step as Pipelines.ActionStep;
Trace.Info($"Adding {action.DisplayName}.");
var actionRunner = HostContext.CreateService<IActionRunner>();
actionRunner.Action = action;
actionRunner.Stage = ActionRunStage.Main;
actionRunner.Condition = step.Condition;
var contextData = new Pipelines.ContextData.DictionaryContextData();
if (message.ContextData?.Count > 0)
{
foreach (var pair in message.ContextData)
{
contextData[pair.Key] = pair.Value;
}
}
actionRunner.TryEvaluateDisplayName(contextData, context);
jobSteps.Add(actionRunner);
}
}
// Create execution context for pre-job steps
foreach (var step in preJobSteps)
{
if (step is JobExtensionRunner)
{
JobExtensionRunner extensionStep = step as JobExtensionRunner;
ArgUtil.NotNull(extensionStep, extensionStep.DisplayName);
Guid stepId = Guid.NewGuid();
extensionStep.ExecutionContext = jobContext.CreateChild(stepId, extensionStep.DisplayName, null, null, stepId.ToString("N"));
}
}
// Create execution context for job steps
foreach (var step in jobSteps)
{
if (step is IActionRunner actionStep)
{
ArgUtil.NotNull(actionStep, step.DisplayName);
actionStep.ExecutionContext = jobContext.CreateChild(actionStep.Action.Id, actionStep.DisplayName, actionStep.Action.Name, actionStep.Action.ScopeName, actionStep.Action.ContextName);
}
}
// Add post-job steps
Trace.Info("Adding post-job steps");
while (postJobStepsBuilder.Count > 0)
{
postJobSteps.Add(postJobStepsBuilder.Pop());
}
// Create execution context for post-job steps
foreach (var step in postJobSteps)
{
if (step is JobExtensionRunner)
{
JobExtensionRunner extensionStep = step as JobExtensionRunner;
ArgUtil.NotNull(extensionStep, extensionStep.DisplayName);
Guid stepId = Guid.NewGuid();
extensionStep.ExecutionContext = jobContext.CreateChild(stepId, extensionStep.DisplayName, stepId.ToString("N"), null, null);
}
}
List<IStep> steps = new List<IStep>();
steps.AddRange(preJobSteps);
steps.AddRange(jobSteps);
steps.AddRange(postJobSteps);
// Start agent log plugin host process
// var logPlugin = HostContext.GetService<IAgentLogPlugin>();
// await logPlugin.StartAsync(context, steps, jobContext.CancellationToken);
// Prepare for orphan process cleanup
_processCleanup = jobContext.Variables.GetBoolean("process.clean") ?? true;
if (_processCleanup)
{
// Set the RUNNER_TRACKING_ID env variable.
Environment.SetEnvironmentVariable(Constants.ProcessTrackingId, _processLookupId);
context.Debug("Collect running processes for tracking orphan processes.");
// Take a snapshot of current running processes
Dictionary<int, Process> processes = SnapshotProcesses();
foreach (var proc in processes)
{
// Pid_ProcessName
_existingProcesses.Add($"{proc.Key}_{proc.Value.ProcessName}");
}
}
return steps;
}
catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested)
{
// Log the exception and cancel the JobExtension Initialization.
Trace.Error($"Caught cancellation exception from JobExtension Initialization: {ex}");
context.Error(ex);
context.Result = TaskResult.Canceled;
throw;
}
catch (Exception ex)
{
// Log the error and fail the JobExtension Initialization.
Trace.Error($"Caught exception from JobExtension Initialization: {ex}");
context.Error(ex);
context.Result = TaskResult.Failed;
throw;
}
finally
{
context.Debug("Finishing: Set up job");
context.Complete();
}
}
}
public async Task FinalizeJob(IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message, DateTime jobStartTimeUtc)
{
Trace.Entering();
ArgUtil.NotNull(jobContext, nameof(jobContext));
// create a new timeline record node for 'Finalize job'
IExecutionContext context = jobContext.CreateChild(Guid.NewGuid(), "Complete job", $"{nameof(JobExtension)}_Final", null, null);
using (var register = jobContext.CancellationToken.Register(() => { context.CancelToken(); }))
{
try
{
context.Start();
context.Debug("Starting: Complete job");
// Wait for agent log plugin process exits
// var logPlugin = HostContext.GetService<IAgentLogPlugin>();
// try
// {
// await logPlugin.WaitAsync(context);
// }
// catch (Exception ex)
// {
// // Log and ignore the error from log plugin finalization.
// Trace.Error($"Caught exception from log plugin finalization: {ex}");
// context.Output(ex.Message);
// }
if (context.Variables.GetBoolean(Constants.Variables.Actions.RunnerDebug) ?? false)
{
Trace.Info("Support log upload starting.");
context.Output("Uploading runner diagnostic logs");
IDiagnosticLogManager diagnosticLogManager = HostContext.GetService<IDiagnosticLogManager>();
try
{
await diagnosticLogManager.UploadDiagnosticLogsAsync(executionContext: context, parentContext: jobContext, message: message, jobStartTimeUtc: jobStartTimeUtc);
Trace.Info("Support log upload complete.");
context.Output("Completed runner diagnostic log upload");
}
catch (Exception ex)
{
// Log the error but make sure we continue gracefully.
Trace.Info("Error uploading support logs.");
context.Output("Error uploading runner diagnostic logs");
Trace.Error(ex);
}
}
if (_processCleanup)
{
context.Output("Cleaning up orphan processes");
// Only check environment variable for any process that doesn't run before we invoke our process.
Dictionary<int, Process> currentProcesses = SnapshotProcesses();
foreach (var proc in currentProcesses)
{
if (proc.Key == Process.GetCurrentProcess().Id)
{
// skip for current process.
continue;
}
if (_existingProcesses.Contains($"{proc.Key}_{proc.Value.ProcessName}"))
{
Trace.Verbose($"Skip existing process. PID: {proc.Key} ({proc.Value.ProcessName})");
}
else
{
Trace.Info($"Inspecting process environment variables. PID: {proc.Key} ({proc.Value.ProcessName})");
string lookupId = null;
try
{
lookupId = proc.Value.GetEnvironmentVariable(HostContext, Constants.ProcessTrackingId);
}
catch (Exception ex)
{
Trace.Warning($"Ignore exception during read process environment variables: {ex.Message}");
Trace.Verbose(ex.ToString());
}
if (string.Equals(lookupId, _processLookupId, StringComparison.OrdinalIgnoreCase))
{
context.Output($"Terminate orphan process: pid ({proc.Key}) ({proc.Value.ProcessName})");
try
{
proc.Value.Kill();
}
catch (Exception ex)
{
Trace.Error("Catch exception during orphan process cleanup.");
Trace.Error(ex);
}
}
}
}
}
}
catch (Exception ex)
{
// Log and ignore the error from JobExtension finalization.
Trace.Error($"Caught exception from JobExtension finalization: {ex}");
context.Output(ex.Message);
}
finally
{
context.Debug("Finishing: Complete job");
context.Complete();
}
}
}
private Dictionary<int, Process> SnapshotProcesses()
{
Dictionary<int, Process> snapshot = new Dictionary<int, Process>();
foreach (var proc in Process.GetProcesses())
{
try
{
// On Windows, this will throw exception on error.
// On Linux, this will be NULL on error.
if (!string.IsNullOrEmpty(proc.ProcessName))
{
snapshot[proc.Id] = proc;
}
}
catch (Exception ex)
{
Trace.Verbose($"Ignore any exception during taking process snapshot of process pid={proc.Id}: '{ex.Message}'.");
}
}
Trace.Info($"Total accessible running process: {snapshot.Count}.");
return snapshot;
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
namespace GitHub.Runner.Worker
{
public sealed class JobExtensionRunner : IStep
{
private readonly object _data;
private readonly Func<IExecutionContext, object, Task> _runAsync;
public JobExtensionRunner(
Func<IExecutionContext, object, Task> runAsync,
string condition,
string displayName,
object data)
{
_runAsync = runAsync;
Condition = condition;
DisplayName = displayName;
_data = data;
}
public string Condition { get; set; }
public TemplateToken ContinueOnError => new BooleanToken(null, null, null, false);
public string DisplayName { get; set; }
public IExecutionContext ExecutionContext { get; set; }
public TemplateToken Timeout => new NumberToken(null, null, null, 0);
public object Data => _data;
public async Task RunAsync()
{
await _runAsync(ExecutionContext, _data);
}
}
}

View File

@@ -0,0 +1,292 @@
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common.Util;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using System.Text;
using System.IO.Compression;
using System.Diagnostics;
using Newtonsoft.Json.Linq;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.ObjectTemplating;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(JobRunner))]
public interface IJobRunner : IRunnerService
{
Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message, CancellationToken jobRequestCancellationToken);
}
public sealed class JobRunner : RunnerService, IJobRunner
{
private IJobServerQueue _jobServerQueue;
private ITempDirectoryManager _tempDirectoryManager;
public async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message, CancellationToken jobRequestCancellationToken)
{
// Validate parameters.
Trace.Entering();
ArgUtil.NotNull(message, nameof(message));
ArgUtil.NotNull(message.Resources, nameof(message.Resources));
ArgUtil.NotNull(message.Variables, nameof(message.Variables));
ArgUtil.NotNull(message.Steps, nameof(message.Steps));
Trace.Info("Job ID {0}", message.JobId);
DateTime jobStartTimeUtc = DateTime.UtcNow;
ServiceEndpoint systemConnection = message.Resources.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
// Setup the job server and job server queue.
var jobServer = HostContext.GetService<IJobServer>();
VssCredentials jobServerCredential = VssUtil.GetVssCredential(systemConnection);
Uri jobServerUrl = systemConnection.Url;
Trace.Info($"Creating job server with URL: {jobServerUrl}");
// jobServerQueue is the throttling reporter.
_jobServerQueue = HostContext.GetService<IJobServerQueue>();
VssConnection jobConnection = VssUtil.CreateConnection(jobServerUrl, jobServerCredential, new DelegatingHandler[] { new ThrottlingReportHandler(_jobServerQueue) });
await jobServer.ConnectAsync(jobConnection);
_jobServerQueue.Start(message);
HostContext.WritePerfCounter($"WorkerJobServerQueueStarted_{message.RequestId.ToString()}");
IExecutionContext jobContext = null;
CancellationTokenRegistration? runnerShutdownRegistration = null;
try
{
// Create the job execution context.
jobContext = HostContext.CreateService<IExecutionContext>();
jobContext.InitializeJob(message, jobRequestCancellationToken);
Trace.Info("Starting the job execution context.");
jobContext.Start();
jobContext.Debug($"Starting: {message.JobDisplayName}");
runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() =>
{
// log an issue, then runner get shutdown by Ctrl-C or Ctrl-Break.
// the server will use Ctrl-Break to tells the runner that operating system is shutting down.
string errorMessage;
switch (HostContext.RunnerShutdownReason)
{
case ShutdownReason.UserCancelled:
errorMessage = "The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled.";
break;
case ShutdownReason.OperatingSystemShutdown:
errorMessage = $"Operating system is shutting down for computer '{Environment.MachineName}'";
break;
default:
throw new ArgumentException(HostContext.RunnerShutdownReason.ToString(), nameof(HostContext.RunnerShutdownReason));
}
jobContext.AddIssue(new Issue() { Type = IssueType.Error, Message = errorMessage });
});
// Validate directory permissions.
string workDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
Trace.Info($"Validating directory permissions for: '{workDirectory}'");
try
{
Directory.CreateDirectory(workDirectory);
IOUtil.ValidateExecutePermission(workDirectory);
}
catch (Exception ex)
{
Trace.Error(ex);
jobContext.Error(ex);
return await CompleteJobAsync(jobServer, jobContext, message, TaskResult.Failed);
}
jobContext.SetRunnerContext("os", VarUtil.OS);
string toolsDirectory = HostContext.GetDirectory(WellKnownDirectory.Tools);
Directory.CreateDirectory(toolsDirectory);
jobContext.SetRunnerContext("tool_cache", toolsDirectory);
// remove variable from env
Environment.SetEnvironmentVariable("AGENT_TOOLSDIRECTORY", null);
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ToolsDirectory, null);
// Setup TEMP directories
_tempDirectoryManager = HostContext.GetService<ITempDirectoryManager>();
_tempDirectoryManager.InitializeTempDirectory(jobContext);
// // Expand container properties
// jobContext.Container?.ExpandProperties(jobContext.Variables);
// foreach (var sidecar in jobContext.SidecarContainers)
// {
// sidecar.ExpandProperties(jobContext.Variables);
// }
// Get the job extension.
Trace.Info("Getting job extension.");
IJobExtension jobExtension = HostContext.CreateService<IJobExtension>();
List<IStep> jobSteps = null;
try
{
Trace.Info("Initialize job. Getting all job steps.");
jobSteps = await jobExtension.InitializeJob(jobContext, message);
}
catch (OperationCanceledException ex) when (jobContext.CancellationToken.IsCancellationRequested)
{
// set the job to canceled
// don't log error issue to job ExecutionContext, since server owns the job level issue
Trace.Error($"Job is canceled during initialize.");
Trace.Error($"Caught exception: {ex}");
return await CompleteJobAsync(jobServer, jobContext, message, TaskResult.Canceled);
}
catch (Exception ex)
{
// set the job to failed.
// don't log error issue to job ExecutionContext, since server owns the job level issue
Trace.Error($"Job initialize failed.");
Trace.Error($"Caught exception from {nameof(jobExtension.InitializeJob)}: {ex}");
return await CompleteJobAsync(jobServer, jobContext, message, TaskResult.Failed);
}
// trace out all steps
Trace.Info($"Total job steps: {jobSteps.Count}.");
Trace.Verbose($"Job steps: '{string.Join(", ", jobSteps.Select(x => x.DisplayName))}'");
HostContext.WritePerfCounter($"WorkerJobInitialized_{message.RequestId.ToString()}");
// Run all job steps
Trace.Info("Run all job steps.");
var stepsRunner = HostContext.GetService<IStepsRunner>();
try
{
foreach (var step in jobSteps)
{
jobContext.JobSteps.Enqueue(step);
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)
{
// StepRunner should never throw exception out.
// End up here mean there is a bug in StepRunner
// Log the error and fail the job.
Trace.Error($"Caught exception from job steps {nameof(StepsRunner)}: {ex}");
jobContext.Error(ex);
return await CompleteJobAsync(jobServer, jobContext, message, TaskResult.Failed);
}
finally
{
Trace.Info("Finalize job.");
await jobExtension.FinalizeJob(jobContext, message, jobStartTimeUtc);
}
Trace.Info($"Job result after all job steps finish: {jobContext.Result ?? TaskResult.Succeeded}");
Trace.Info("Completing the job execution context.");
return await CompleteJobAsync(jobServer, jobContext, message);
}
finally
{
if (runnerShutdownRegistration != null)
{
runnerShutdownRegistration.Value.Dispose();
runnerShutdownRegistration = null;
}
await ShutdownQueue(throwOnFailure: false);
}
}
private async Task<TaskResult> CompleteJobAsync(IJobServer jobServer, IExecutionContext jobContext, Pipelines.AgentJobRequestMessage message, TaskResult? taskResult = null)
{
jobContext.Debug($"Finishing: {message.JobDisplayName}");
TaskResult result = jobContext.Complete(taskResult);
try
{
await ShutdownQueue(throwOnFailure: true);
}
catch (Exception ex)
{
Trace.Error($"Caught exception from {nameof(JobServerQueue)}.{nameof(_jobServerQueue.ShutdownAsync)}");
Trace.Error("This indicate a failure during publish output variables. Fail the job to prevent unexpected job outputs.");
Trace.Error(ex);
result = TaskResultUtil.MergeTaskResults(result, TaskResult.Failed);
}
// Clean TEMP after finish process jobserverqueue, since there might be a pending fileupload still use the TEMP dir.
_tempDirectoryManager?.CleanupTempDirectory();
if (!jobContext.Features.HasFlag(PlanFeatures.JobCompletedPlanEvent))
{
Trace.Info($"Skip raise job completed event call from worker because Plan version is {message.Plan.Version}");
return result;
}
Trace.Info("Raising job completed event.");
var jobCompletedEvent = new JobCompletedEvent(message.RequestId, message.JobId, result);
var completeJobRetryLimit = 5;
var exceptions = new List<Exception>();
while (completeJobRetryLimit-- > 0)
{
try
{
await jobServer.RaisePlanEventAsync(message.Plan.ScopeIdentifier, message.Plan.PlanType, message.Plan.PlanId, jobCompletedEvent, default(CancellationToken));
return result;
}
catch (TaskOrchestrationPlanNotFoundException ex)
{
Trace.Error($"TaskOrchestrationPlanNotFoundException received, while attempting to raise JobCompletedEvent for job {message.JobId}.");
Trace.Error(ex);
return TaskResult.Failed;
}
catch (TaskOrchestrationPlanSecurityException ex)
{
Trace.Error($"TaskOrchestrationPlanSecurityException received, while attempting to raise JobCompletedEvent for job {message.JobId}.");
Trace.Error(ex);
return TaskResult.Failed;
}
catch (Exception ex)
{
Trace.Error($"Catch exception while attempting to raise JobCompletedEvent for job {message.JobId}, job request {message.RequestId}.");
Trace.Error(ex);
exceptions.Add(ex);
}
// delay 5 seconds before next retry.
await Task.Delay(TimeSpan.FromSeconds(5));
}
// rethrow exceptions from all attempts.
throw new AggregateException(exceptions);
}
private async Task ShutdownQueue(bool throwOnFailure)
{
if (_jobServerQueue != null)
{
try
{
Trace.Info("Shutting down the job server queue.");
await _jobServerQueue.ShutdownAsync();
}
catch (Exception ex) when (!throwOnFailure)
{
Trace.Error($"Caught exception from {nameof(JobServerQueue)}.{nameof(_jobServerQueue.ShutdownAsync)}");
Trace.Error(ex);
}
finally
{
_jobServerQueue = null; // Prevent multiple attempts.
}
}
}
}
}

View File

@@ -0,0 +1,211 @@
using System;
using System.IO;
using System.Linq;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(PipelineDirectoryManager))]
public interface IPipelineDirectoryManager : IRunnerService
{
TrackingConfig PrepareDirectory(
IExecutionContext executionContext,
WorkspaceOptions workspace);
TrackingConfig UpdateRepositoryDirectory(
IExecutionContext executionContext,
string repositoryFullName,
string repositoryPath,
bool workspaceRepository);
}
public sealed class PipelineDirectoryManager : RunnerService, IPipelineDirectoryManager
{
public TrackingConfig PrepareDirectory(
IExecutionContext executionContext,
WorkspaceOptions workspace)
{
// Validate parameters.
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
var trackingManager = HostContext.GetService<ITrackingManager>();
var repoFullName = executionContext.GetGitHubContext("repository");
ArgUtil.NotNullOrEmpty(repoFullName, nameof(repoFullName));
// Load the existing tracking file if one already exists.
string trackingFile = Path.Combine(
HostContext.GetDirectory(WellKnownDirectory.Work),
Constants.Pipeline.Path.PipelineMappingDirectory,
repoFullName,
Constants.Pipeline.Path.TrackingConfigFile);
Trace.Info($"Loading tracking config if exists: {trackingFile}");
TrackingConfig trackingConfig = trackingManager.LoadIfExists(executionContext, trackingFile);
// Create a new tracking config if required.
if (trackingConfig == null)
{
Trace.Info("Creating a new tracking config file.");
trackingConfig = trackingManager.Create(
executionContext,
trackingFile);
ArgUtil.NotNull(trackingConfig, nameof(trackingConfig));
}
else
{
// For existing tracking config files, update the job run properties.
Trace.Info("Updating job run properties.");
trackingConfig.LastRunOn = DateTimeOffset.Now;
trackingManager.Update(executionContext, trackingConfig, trackingFile);
}
// Prepare the pipeline directory.
if (string.Equals(workspace?.Clean, PipelineConstants.WorkspaceCleanOptions.All, StringComparison.OrdinalIgnoreCase))
{
CreateDirectory(
executionContext,
description: "pipeline directory",
path: Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), trackingConfig.PipelineDirectory),
deleteExisting: true);
CreateDirectory(
executionContext,
description: "workspace directory",
path: Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), trackingConfig.WorkspaceDirectory),
deleteExisting: true);
}
else if (string.Equals(workspace?.Clean, PipelineConstants.WorkspaceCleanOptions.Resources, StringComparison.OrdinalIgnoreCase))
{
foreach (var repository in trackingConfig.Repositories)
{
CreateDirectory(
executionContext,
description: $"directory {repository.Value.RepositoryPath} for repository {repository.Key}",
path: Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), repository.Value.RepositoryPath),
deleteExisting: true);
}
}
else if (string.Equals(workspace?.Clean, PipelineConstants.WorkspaceCleanOptions.Outputs, StringComparison.OrdinalIgnoreCase))
{
var allDirectories = Directory.GetDirectories(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), trackingConfig.PipelineDirectory)).ToList();
foreach (var repository in trackingConfig.Repositories)
{
allDirectories.Remove(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), repository.Value.RepositoryPath));
}
foreach (var deleteDir in allDirectories)
{
executionContext.Debug($"Delete existing untracked directory '{deleteDir}'");
DeleteDirectory(executionContext, "untracked dir", deleteDir);
}
}
else
{
CreateDirectory(
executionContext,
description: "pipeline directory",
path: Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), trackingConfig.PipelineDirectory),
deleteExisting: false);
CreateDirectory(
executionContext,
description: "workspace directory",
path: Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), trackingConfig.WorkspaceDirectory),
deleteExisting: false);
}
return trackingConfig;
}
public TrackingConfig UpdateRepositoryDirectory(
IExecutionContext executionContext,
string repositoryFullName,
string repositoryPath,
bool workspaceRepository)
{
// Validate parameters.
Trace.Entering();
ArgUtil.NotNull(executionContext, nameof(executionContext));
ArgUtil.NotNullOrEmpty(repositoryFullName, nameof(repositoryFullName));
ArgUtil.NotNullOrEmpty(repositoryPath, nameof(repositoryPath));
// we need the repository for the pipeline, since the tracking file is based on the workflow repository
var pipelineRepoFullName = executionContext.GetGitHubContext("repository");
ArgUtil.NotNullOrEmpty(pipelineRepoFullName, nameof(pipelineRepoFullName));
// Load the existing tracking file.
string trackingFile = Path.Combine(
HostContext.GetDirectory(WellKnownDirectory.Work),
Constants.Pipeline.Path.PipelineMappingDirectory,
pipelineRepoFullName,
Constants.Pipeline.Path.TrackingConfigFile);
Trace.Verbose($"Loading tracking config if exists: {trackingFile}");
var trackingManager = HostContext.GetService<ITrackingManager>();
TrackingConfig existingConfig = trackingManager.LoadIfExists(executionContext, trackingFile);
ArgUtil.NotNull(existingConfig, nameof(existingConfig));
Trace.Info($"Update repository {repositoryFullName}'s path to '{repositoryPath}'");
string pipelineDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), existingConfig.PipelineDirectory);
if (repositoryPath.StartsWith(pipelineDirectory + Path.DirectorySeparatorChar) || repositoryPath.StartsWith(pipelineDirectory + Path.AltDirectorySeparatorChar))
{
// The workspaceDirectory in tracking file is a relative path to runner's pipeline directory.
var repositoryRelativePath = repositoryPath.Substring(HostContext.GetDirectory(WellKnownDirectory.Work).Length + 1).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (!existingConfig.Repositories.ContainsKey(repositoryFullName))
{
existingConfig.Repositories[repositoryFullName] = new RepositoryTrackingConfig();
}
existingConfig.Repositories[repositoryFullName].RepositoryPath = repositoryRelativePath;
existingConfig.Repositories[repositoryFullName].LastRunOn = DateTimeOffset.Now;
if (workspaceRepository)
{
Trace.Info($"Update workspace to '{repositoryPath}'");
existingConfig.WorkspaceDirectory = repositoryRelativePath;
executionContext.SetGitHubContext("workspace", repositoryPath);
}
// Update the tracking config files.
Trace.Info("Updating repository tracking.");
trackingManager.Update(executionContext, existingConfig, trackingFile);
return existingConfig;
}
else
{
throw new ArgumentException($"Repository path '{repositoryPath}' should be located under runner's pipeline directory '{pipelineDirectory}'.");
}
}
private void CreateDirectory(IExecutionContext executionContext, string description, string path, bool deleteExisting)
{
// Delete.
if (deleteExisting)
{
executionContext.Debug($"Delete existing {description}: '{path}'");
DeleteDirectory(executionContext, description, path);
}
// Create.
if (!Directory.Exists(path))
{
executionContext.Debug($"Creating {description}: '{path}'");
Trace.Info($"Creating {description}.");
Directory.CreateDirectory(path);
}
}
private void DeleteDirectory(IExecutionContext executionContext, string description, string path)
{
Trace.Info($"Checking if {description} exists: '{path}'");
if (Directory.Exists(path))
{
executionContext.Debug($"Deleting {description}: '{path}'");
IOUtil.DeleteDirectory(path, executionContext.CancellationToken);
}
}
}
}

View File

@@ -0,0 +1,68 @@
using GitHub.Runner.Common.Util;
using System;
using System.Globalization;
using System.Threading.Tasks;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
public static class Program
{
public static int Main(string[] args)
{
using (HostContext context = new HostContext("Worker"))
{
return MainAsync(context, args).GetAwaiter().GetResult();
}
}
public static async Task<int> MainAsync(IHostContext context, string[] args)
{
// We may want to consider registering this handler in Worker.cs, similiar to the unloading/SIGTERM handler
//ITerminal registers a CTRL-C handler, which keeps the Runner.Worker process running
//and lets the Runner.Listener handle gracefully the exit.
var term = context.GetService<ITerminal>();
Tracing trace = context.GetTrace(nameof(GitHub.Runner.Worker));
try
{
trace.Info($"Version: {BuildConstants.RunnerPackage.Version}");
trace.Info($"Commit: {BuildConstants.Source.CommitHash}");
trace.Info($"Culture: {CultureInfo.CurrentCulture.Name}");
trace.Info($"UI Culture: {CultureInfo.CurrentUICulture.Name}");
context.WritePerfCounter("WorkerProcessStarted");
// Validate args.
ArgUtil.NotNull(args, nameof(args));
ArgUtil.Equal(3, args.Length, nameof(args.Length));
ArgUtil.NotNullOrEmpty(args[0], $"{nameof(args)}[0]");
ArgUtil.Equal("spawnclient", args[0].ToLowerInvariant(), $"{nameof(args)}[0]");
ArgUtil.NotNullOrEmpty(args[1], $"{nameof(args)}[1]");
ArgUtil.NotNullOrEmpty(args[2], $"{nameof(args)}[2]");
var worker = context.GetService<IWorker>();
// Run the worker.
return await worker.RunAsync(
pipeIn: args[1],
pipeOut: args[2]);
}
catch (Exception ex)
{
// Populate any exception that cause worker failure back to runner.
Console.WriteLine(ex.ToString());
try
{
trace.Error(ex);
}
catch (Exception e)
{
// make sure we don't crash the app on trace error.
// since IOException will throw when we run out of disk space.
Console.WriteLine(e.ToString());
}
}
return 1;
}
}
}

View File

@@ -0,0 +1,74 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<OutputType>Exe</OutputType>
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
<NoWarn>NU1701;NU1603</NoWarn>
<Version>$(Version)</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Sdk\Sdk.csproj" />
<ProjectReference Include="..\Runner.Common\Runner.Common.csproj" />
<ProjectReference Include="..\Runner.Sdk\Runner.Sdk.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.4.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="4.4.0" />
<PackageReference Include="System.Threading.Channels" Version="4.4.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="action_yaml.json">
<LogicalName>GitHub.Runner.Worker.action_yaml.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DebugType>portable</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,17 @@
using GitHub.DistributedTask.Pipelines.ContextData;
using System;
using System.Collections.Generic;
namespace GitHub.Runner.Worker
{
public sealed class RunnerContext : DictionaryContextData, IEnvironmentContextData
{
public IEnumerable<KeyValuePair<string, string>> GetRuntimeEnvironmentVariables()
{
foreach (var data in this)
{
yield return new KeyValuePair<string, string>($"RUNNER_{data.Key.ToUpperInvariant()}", data.Value as StringContextData);
}
}
}
}

View File

@@ -0,0 +1,149 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Sdk;
using GitHub.Runner.Common.Util;
using GitHub.Services.WebApi;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading;
using System.Threading.Tasks;
using System.Text;
using GitHub.DistributedTask.Pipelines.ContextData;
using System.Threading.Channels;
using GitHub.Runner.Common;
using System.Linq;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(RunnerPluginManager))]
public interface IRunnerPluginManager : IRunnerService
{
RunnerPluginActionInfo GetPluginAction(string plugin);
Task RunPluginActionAsync(IExecutionContext context, string plugin, Dictionary<string, string> inputs, Dictionary<string, string> environment, Variables runtimeVariables, EventHandler<ProcessDataReceivedEventArgs> outputHandler);
}
public sealed class RunnerPluginManager : RunnerService, IRunnerPluginManager
{
private readonly Dictionary<string, RunnerPluginActionInfo> _actionPlugins = new Dictionary<string, RunnerPluginActionInfo>(StringComparer.OrdinalIgnoreCase)
{
{
"checkout",
new RunnerPluginActionInfo()
{
Description = "Get sources from a Git repository",
FriendlyName = "Get sources",
PluginTypeName = "GitHub.Runner.Plugins.Repository.v1_0.CheckoutTask, Runner.Plugins"
}
},
{
"checkoutV1_1",
new RunnerPluginActionInfo()
{
Description = "Get sources from a Git repository",
FriendlyName = "Get sources",
PluginTypeName = "GitHub.Runner.Plugins.Repository.v1_1.CheckoutTask, Runner.Plugins",
PostPluginTypeName = "GitHub.Runner.Plugins.Repository.v1_1.CleanupTask, Runner.Plugins"
}
},
{
"publish",
new RunnerPluginActionInfo()
{
PluginTypeName = "GitHub.Runner.Plugins.Artifact.PublishArtifact, Runner.Plugins"
}
},
{
"download",
new RunnerPluginActionInfo()
{
PluginTypeName = "GitHub.Runner.Plugins.Artifact.DownloadArtifact, Runner.Plugins"
}
}
};
public RunnerPluginActionInfo GetPluginAction(string plugin)
{
if (_actionPlugins.ContainsKey(plugin))
{
return _actionPlugins[plugin];
}
else
{
return null;
}
}
public async Task RunPluginActionAsync(IExecutionContext context, string plugin, Dictionary<string, string> inputs, Dictionary<string, string> environment, Variables runtimeVariables, EventHandler<ProcessDataReceivedEventArgs> outputHandler)
{
ArgUtil.NotNullOrEmpty(plugin, nameof(plugin));
// Only allow plugins we defined
if (!_actionPlugins.Any(x => x.Value.PluginTypeName == plugin || x.Value.PostPluginTypeName == plugin))
{
throw new NotSupportedException(plugin);
}
// Resolve the working directory.
string workingDirectory = HostContext.GetDirectory(WellKnownDirectory.Work);
ArgUtil.Directory(workingDirectory, nameof(workingDirectory));
// Runner.PluginHost
string file = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), $"Runner.PluginHost{IOUtil.ExeExtension}");
ArgUtil.File(file, $"Runner.PluginHost{IOUtil.ExeExtension}");
// Runner.PluginHost's arguments
string arguments = $"action \"{plugin}\"";
// construct plugin context
RunnerActionPluginExecutionContext pluginContext = new RunnerActionPluginExecutionContext
{
Inputs = inputs,
Endpoints = context.Endpoints,
Context = context.ExpressionValues
};
// variables
foreach (var variable in context.Variables.AllVariables)
{
pluginContext.Variables[variable.Name] = new VariableValue(variable.Value, variable.Secret);
}
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
{
var redirectStandardIn = Channel.CreateUnbounded<string>(new UnboundedChannelOptions() { SingleReader = true, SingleWriter = true });
redirectStandardIn.Writer.TryWrite(JsonUtility.ToString(pluginContext));
processInvoker.OutputDataReceived += outputHandler;
processInvoker.ErrorDataReceived += outputHandler;
// 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.
await processInvoker.ExecuteAsync(workingDirectory: workingDirectory,
fileName: file,
arguments: arguments,
environment: environment,
requireExitCodeZero: true,
outputEncoding: Encoding.UTF8,
killProcessOnCancel: false,
redirectStandardIn: redirectStandardIn,
cancellationToken: context.CancellationToken);
}
}
private Assembly ResolveAssembly(AssemblyLoadContext context, AssemblyName assembly)
{
string assemblyFilename = assembly.Name + ".dll";
return context.LoadFromAssemblyPath(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), assemblyFilename));
}
}
public class RunnerPluginActionInfo
{
public string Description { get; set; }
public string FriendlyName { get; set; }
public string PluginTypeName { get; set; }
public string PostPluginTypeName { get; set; }
}
}

View File

@@ -0,0 +1,88 @@
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
public sealed class StepsContext
{
private static readonly Regex _propertyRegex = new Regex("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled);
private readonly DictionaryContextData _contextData = new DictionaryContextData();
public DictionaryContextData GetScope(string scopeName)
{
if (scopeName == null)
{
scopeName = string.Empty;
}
var scope = default(DictionaryContextData);
if (_contextData.TryGetValue(scopeName, out var scopeValue))
{
scope = scopeValue.AssertDictionary("scope");
}
else
{
scope = new DictionaryContextData();
_contextData.Add(scopeName, scope);
}
return scope;
}
public void SetOutput(
string scopeName,
string stepName,
string outputName,
string value,
out string reference)
{
var step = GetStep(scopeName, stepName);
var outputs = step["outputs"].AssertDictionary("outputs");
outputs[outputName] = new StringContextData(value);
if (_propertyRegex.IsMatch(outputName))
{
reference = $"steps.{stepName}.outputs.{outputName}";
}
else
{
reference = $"steps['{stepName}']['outputs']['{outputName}']";
}
}
public void SetResult(
string scopeName,
string stepName,
string result)
{
var step = GetStep(scopeName, stepName);
step["result"] = new StringContextData(result);
}
private DictionaryContextData GetStep(string scopeName, string stepName)
{
var scope = GetScope(scopeName);
var step = default(DictionaryContextData);
if (scope.TryGetValue(stepName, out var stepValue))
{
step = stepValue.AssertDictionary("step");
}
else
{
step = new DictionaryContextData
{
{ "outputs", new DictionaryContextData() },
};
scope.Add(stepName, step);
}
return step;
}
}
}

View File

@@ -0,0 +1,465 @@
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common.Util;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.Pipelines.ObjectTemplating;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
public interface IStep
{
string Condition { get; set; }
TemplateToken ContinueOnError { get; }
string DisplayName { get; set; }
IExecutionContext ExecutionContext { get; set; }
TemplateToken Timeout { get; }
Task RunAsync();
}
[ServiceLocator(Default = typeof(StepsRunner))]
public interface IStepsRunner : IRunnerService
{
Task RunAsync(IExecutionContext Context);
}
public sealed class StepsRunner : RunnerService, IStepsRunner
{
// StepsRunner should never throw exception to caller
public async Task RunAsync(IExecutionContext jobContext)
{
ArgUtil.NotNull(jobContext, nameof(jobContext));
ArgUtil.NotNull(jobContext.JobSteps, nameof(jobContext.JobSteps));
// TaskResult:
// Abandoned (Server set this.)
// Canceled
// Failed
// Skipped
// Succeeded
CancellationTokenRegistration? jobCancelRegister = null;
jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult();
var scopeInputs = new Dictionary<string, PipelineContextData>(StringComparer.OrdinalIgnoreCase);
bool checkPostJobActions = false;
while (jobContext.JobSteps.Count > 0 || !checkPostJobActions)
{
if (jobContext.JobSteps.Count == 0 && !checkPostJobActions)
{
checkPostJobActions = true;
while (jobContext.PostJobSteps.TryPop(out var postStep))
{
jobContext.JobSteps.Enqueue(postStep);
}
continue;
}
var step = jobContext.JobSteps.Dequeue();
IStep nextStep = null;
if (jobContext.JobSteps.Count > 0)
{
nextStep = jobContext.JobSteps.Peek();
}
Trace.Info($"Processing step: DisplayName='{step.DisplayName}'");
ArgUtil.NotNull(step.ExecutionContext, nameof(step.ExecutionContext));
ArgUtil.NotNull(step.ExecutionContext.Variables, nameof(step.ExecutionContext.Variables));
// Start
step.ExecutionContext.Start();
// Set GITHUB_ACTION
if (step is IActionRunner actionStep)
{
step.ExecutionContext.SetGitHubContext("action", actionStep.Action.Name);
}
// Initialize scope
if (InitializeScope(step, scopeInputs))
{
var expressionManager = HostContext.GetService<IExpressionManager>();
try
{
// Register job cancellation call back only if job cancellation token not been fire before each step run
if (!jobContext.CancellationToken.IsCancellationRequested)
{
// Test the condition again. The job was canceled after the condition was originally evaluated.
jobCancelRegister = jobContext.CancellationToken.Register(() =>
{
// mark job as cancelled
jobContext.Result = TaskResult.Canceled;
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
step.ExecutionContext.Debug($"Re-evaluate condition on job cancellation for step: '{step.DisplayName}'.");
ConditionResult conditionReTestResult;
if (HostContext.RunnerShutdownToken.IsCancellationRequested)
{
step.ExecutionContext.Debug($"Skip Re-evaluate condition on runner shutdown.");
conditionReTestResult = false;
}
else
{
try
{
conditionReTestResult = expressionManager.Evaluate(step.ExecutionContext, step.Condition, hostTracingOnly: true);
}
catch (Exception ex)
{
// Cancel the step since we get exception while re-evaluate step condition.
Trace.Info("Caught exception from expression when re-test condition on job cancellation.");
step.ExecutionContext.Error(ex);
conditionReTestResult = false;
}
}
if (!conditionReTestResult.Value)
{
// Cancel the step.
Trace.Info("Cancel current running step.");
step.ExecutionContext.CancelToken();
}
});
}
else
{
if (jobContext.Result != TaskResult.Canceled)
{
// mark job as cancelled
jobContext.Result = TaskResult.Canceled;
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
}
}
// Evaluate condition.
step.ExecutionContext.Debug($"Evaluating condition for step: '{step.DisplayName}'");
Exception conditionEvaluateError = null;
ConditionResult conditionResult;
if (HostContext.RunnerShutdownToken.IsCancellationRequested)
{
step.ExecutionContext.Debug($"Skip evaluate condition on runner shutdown.");
conditionResult = false;
}
else
{
try
{
conditionResult = expressionManager.Evaluate(step.ExecutionContext, step.Condition);
}
catch (Exception ex)
{
Trace.Info("Caught exception from expression.");
Trace.Error(ex);
conditionResult = false;
conditionEvaluateError = ex;
}
}
// no evaluate error but condition is false
if (!conditionResult.Value && conditionEvaluateError == null)
{
// Condition == false
Trace.Info("Skipping step due to condition evaluation.");
CompleteStep(step, nextStep, TaskResult.Skipped, resultCode: conditionResult.Trace);
}
else if (conditionEvaluateError != null)
{
// fail the step since there is an evaluate error.
step.ExecutionContext.Error(conditionEvaluateError);
CompleteStep(step, nextStep, TaskResult.Failed);
}
else
{
// Run the step.
await RunStepAsync(step, jobContext.CancellationToken);
CompleteStep(step, nextStep);
}
}
finally
{
if (jobCancelRegister != null)
{
jobCancelRegister?.Dispose();
jobCancelRegister = null;
}
}
}
// Update the job result.
if (step.ExecutionContext.Result == TaskResult.Failed)
{
Trace.Info($"Update job result with current step result '{step.ExecutionContext.Result}'.");
jobContext.Result = TaskResultUtil.MergeTaskResults(jobContext.Result, step.ExecutionContext.Result.Value);
jobContext.JobContext.Status = jobContext.Result?.ToActionResult();
}
else
{
Trace.Info($"No need for updating job result with current step result '{step.ExecutionContext.Result}'.");
}
Trace.Info($"Current state: job state = '{jobContext.Result}'");
}
}
private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken)
{
// Check to see if we can expand the display name
if (step is IActionRunner actionRunner &&
actionRunner.Stage == ActionRunStage.Main &&
actionRunner.TryEvaluateDisplayName(step.ExecutionContext.ExpressionValues, step.ExecutionContext))
{
step.ExecutionContext.UpdateTimelineRecordDisplayName(actionRunner.DisplayName);
}
// Start the step.
Trace.Info("Starting the step.");
step.ExecutionContext.Debug($"Starting: {step.DisplayName}");
// Set the timeout
var timeoutMinutes = 0;
var templateEvaluator = CreateTemplateEvaluator(step.ExecutionContext);
try
{
timeoutMinutes = templateEvaluator.EvaluateStepTimeout(step.Timeout, step.ExecutionContext.ExpressionValues);
}
catch (Exception ex)
{
Trace.Info("An error occurred when attempting to determine the step timeout.");
Trace.Error(ex);
step.ExecutionContext.Error("An error occurred when attempting to determine the step timeout.");
step.ExecutionContext.Error(ex);
}
if (timeoutMinutes > 0)
{
var timeout = TimeSpan.FromMinutes(timeoutMinutes);
step.ExecutionContext.SetTimeout(timeout);
}
#if OS_WINDOWS
try
{
if (Console.InputEncoding.CodePage != 65001)
{
using (var p = HostContext.CreateService<IProcessInvoker>())
{
// Use UTF8 code page
int exitCode = await p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work),
fileName: WhichUtil.Which("chcp", true, Trace),
arguments: "65001",
environment: null,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: false,
redirectStandardIn: null,
inheritConsoleHandler: true,
cancellationToken: step.ExecutionContext.CancellationToken);
if (exitCode == 0)
{
Trace.Info("Successfully returned to code page 65001 (UTF8)");
}
else
{
Trace.Warning($"'chcp 65001' failed with exit code {exitCode}");
}
}
}
}
catch (Exception ex)
{
Trace.Warning($"'chcp 65001' failed with exception {ex.Message}");
}
#endif
try
{
await step.RunAsync();
}
catch (OperationCanceledException ex)
{
if (step.ExecutionContext.CancellationToken.IsCancellationRequested &&
!jobCancellationToken.IsCancellationRequested)
{
Trace.Error($"Caught timeout exception from step: {ex.Message}");
step.ExecutionContext.Error("The action has timed out.");
step.ExecutionContext.Result = TaskResult.Failed;
}
else
{
// Log the exception and cancel the step.
Trace.Error($"Caught cancellation exception from step: {ex}");
step.ExecutionContext.Error(ex);
step.ExecutionContext.Result = TaskResult.Canceled;
}
}
catch (Exception ex)
{
// Log the error and fail the step.
Trace.Error($"Caught exception from step: {ex}");
step.ExecutionContext.Error(ex);
step.ExecutionContext.Result = TaskResult.Failed;
}
// Merge execution context result with command result
if (step.ExecutionContext.CommandResult != null)
{
step.ExecutionContext.Result = TaskResultUtil.MergeTaskResults(step.ExecutionContext.Result, step.ExecutionContext.CommandResult.Value);
}
// Fixup the step result if ContinueOnError.
if (step.ExecutionContext.Result == TaskResult.Failed)
{
var continueOnError = false;
try
{
continueOnError = templateEvaluator.EvaluateStepContinueOnError(step.ContinueOnError, step.ExecutionContext.ExpressionValues);
}
catch (Exception ex)
{
Trace.Info("The step failed and an error occurred when attempting to determine whether to continue on error.");
Trace.Error(ex);
step.ExecutionContext.Error("The step failed and an error occurred when attempting to determine whether to continue on error.");
step.ExecutionContext.Error(ex);
}
if (continueOnError)
{
step.ExecutionContext.Result = TaskResult.Succeeded;
Trace.Info($"Updated step result (continue on error)");
}
}
Trace.Info($"Step result: {step.ExecutionContext.Result}");
// Complete the step context.
step.ExecutionContext.Debug($"Finishing: {step.DisplayName}");
}
private bool InitializeScope(IStep step, Dictionary<string, PipelineContextData> scopeInputs)
{
var executionContext = step.ExecutionContext;
var stepsContext = executionContext.StepsContext;
if (!string.IsNullOrEmpty(executionContext.ScopeName))
{
// Gather uninitialized current and ancestor scopes
var scope = executionContext.Scopes[executionContext.ScopeName];
var scopesToInitialize = default(Stack<ContextScope>);
while (scope != null && !scopeInputs.ContainsKey(scope.Name))
{
if (scopesToInitialize == null)
{
scopesToInitialize = new Stack<ContextScope>();
}
scopesToInitialize.Push(scope);
scope = string.IsNullOrEmpty(scope.ParentName) ? null : executionContext.Scopes[scope.ParentName];
}
// Initialize current and ancestor scopes
while (scopesToInitialize?.Count > 0)
{
scope = scopesToInitialize.Pop();
executionContext.Debug($"Initializing scope '{scope.Name}'");
executionContext.ExpressionValues["steps"] = stepsContext.GetScope(scope.ParentName);
executionContext.ExpressionValues["inputs"] = !String.IsNullOrEmpty(scope.ParentName) ? scopeInputs[scope.ParentName] : null;
var templateEvaluator = CreateTemplateEvaluator(executionContext);
var inputs = default(DictionaryContextData);
try
{
inputs = templateEvaluator.EvaluateStepScopeInputs(scope.Inputs, executionContext.ExpressionValues);
}
catch (Exception ex)
{
Trace.Info($"Caught exception from initialize scope '{scope.Name}'");
Trace.Error(ex);
executionContext.Error(ex);
executionContext.Complete(TaskResult.Failed);
return false;
}
scopeInputs[scope.Name] = inputs;
}
}
// Setup expression values
var scopeName = executionContext.ScopeName;
executionContext.ExpressionValues["steps"] = stepsContext.GetScope(scopeName);
executionContext.ExpressionValues["inputs"] = string.IsNullOrEmpty(scopeName) ? null : scopeInputs[scopeName];
return true;
}
private void CompleteStep(IStep step, IStep nextStep, TaskResult? result = null, string resultCode = null)
{
var executionContext = step.ExecutionContext;
if (!string.IsNullOrEmpty(executionContext.ScopeName))
{
// Gather current and ancestor scopes to finalize
var scope = executionContext.Scopes[executionContext.ScopeName];
var scopesToFinalize = default(Queue<ContextScope>);
var nextStepScopeName = nextStep?.ExecutionContext.ScopeName;
while (scope != null &&
!string.Equals(nextStepScopeName, scope.Name, StringComparison.OrdinalIgnoreCase) &&
!(nextStepScopeName ?? string.Empty).StartsWith($"{scope.Name}.", StringComparison.OrdinalIgnoreCase))
{
if (scopesToFinalize == null)
{
scopesToFinalize = new Queue<ContextScope>();
}
scopesToFinalize.Enqueue(scope);
scope = string.IsNullOrEmpty(scope.ParentName) ? null : executionContext.Scopes[scope.ParentName];
}
// Finalize current and ancestor scopes
var stepsContext = step.ExecutionContext.StepsContext;
while (scopesToFinalize?.Count > 0)
{
scope = scopesToFinalize.Dequeue();
executionContext.Debug($"Finalizing scope '{scope.Name}'");
executionContext.ExpressionValues["steps"] = stepsContext.GetScope(scope.Name);
executionContext.ExpressionValues["inputs"] = null;
var templateEvaluator = CreateTemplateEvaluator(executionContext);
var outputs = default(DictionaryContextData);
try
{
outputs = templateEvaluator.EvaluateStepScopeOutputs(scope.Outputs, executionContext.ExpressionValues);
}
catch (Exception ex)
{
Trace.Info($"Caught exception from finalize scope '{scope.Name}'");
Trace.Error(ex);
executionContext.Error(ex);
executionContext.Complete(TaskResult.Failed);
return;
}
if (outputs?.Count > 0)
{
var parentScopeName = scope.ParentName;
var contextName = scope.ContextName;
foreach (var pair in outputs)
{
var outputName = pair.Key;
var outputValue = pair.Value.ToString();
stepsContext.SetOutput(parentScopeName, contextName, outputName, outputValue, out var reference);
executionContext.Debug($"{reference}='{outputValue}'");
}
}
}
}
executionContext.Complete(result, resultCode: resultCode);
}
private PipelineTemplateEvaluator CreateTemplateEvaluator(IExecutionContext executionContext)
{
var templateTrace = executionContext.ToTemplateTraceWriter();
var schema = new PipelineTemplateSchemaFactory().CreateSchema();
return new PipelineTemplateEvaluator(templateTrace, schema);
}
}
}

View File

@@ -0,0 +1,62 @@
using GitHub.Runner.Common.Util;
using System;
using System.IO;
using System.Threading;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(TempDirectoryManager))]
public interface ITempDirectoryManager : IRunnerService
{
void InitializeTempDirectory(IExecutionContext jobContext);
void CleanupTempDirectory();
}
public sealed class TempDirectoryManager : RunnerService, ITempDirectoryManager
{
private string _tempDirectory;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_tempDirectory = HostContext.GetDirectory(WellKnownDirectory.Temp);
}
public void InitializeTempDirectory(IExecutionContext jobContext)
{
ArgUtil.NotNull(jobContext, nameof(jobContext));
ArgUtil.NotNullOrEmpty(_tempDirectory, nameof(_tempDirectory));
jobContext.SetRunnerContext("temp", _tempDirectory);
jobContext.Debug($"Cleaning runner temp folder: {_tempDirectory}");
try
{
IOUtil.DeleteDirectory(_tempDirectory, contentsOnly: true, continueOnContentDeleteError: true, cancellationToken: jobContext.CancellationToken);
}
catch (Exception ex)
{
Trace.Error(ex);
}
finally
{
// make sure folder exists
Directory.CreateDirectory(_tempDirectory);
}
}
public void CleanupTempDirectory()
{
ArgUtil.NotNullOrEmpty(_tempDirectory, nameof(_tempDirectory));
Trace.Info($"Cleaning runner temp folder: {_tempDirectory}");
try
{
IOUtil.DeleteDirectory(_tempDirectory, contentsOnly: true, continueOnContentDeleteError: true, cancellationToken: CancellationToken.None);
}
catch (Exception ex)
{
Trace.Error(ex);
}
}
}
}

View File

@@ -0,0 +1,119 @@
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using Newtonsoft.Json;
using System;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using System.Collections.Generic;
namespace GitHub.Runner.Worker
{
public sealed class RepositoryTrackingConfig
{
public string RepositoryPath { get; set; }
[JsonIgnore]
public DateTimeOffset? LastRunOn { get; set; }
[JsonProperty("LastRunOn")]
[EditorBrowsableAttribute(EditorBrowsableState.Never)]
public string LastRunOnString
{
get
{
return string.Format(CultureInfo.InvariantCulture, "{0}", LastRunOn);
}
set
{
if (string.IsNullOrEmpty(value))
{
LastRunOn = null;
return;
}
LastRunOn = DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
}
}
}
public sealed class TrackingConfig
{
// The parameterless constructor is required for deserialization.
public TrackingConfig()
{
}
public TrackingConfig(IExecutionContext executionContext)
{
var repoFullName = executionContext.GetGitHubContext("repository");
ArgUtil.NotNullOrEmpty(repoFullName, nameof(repoFullName));
RepositoryName = repoFullName;
var repoName = repoFullName.Substring(repoFullName.LastIndexOf('/') + 1);
ArgUtil.NotNullOrEmpty(repoName, nameof(repoName));
// Set the directories.
PipelineDirectory = repoName.ToString(CultureInfo.InvariantCulture);
WorkspaceDirectory = Path.Combine(PipelineDirectory, repoName);
Repositories[repoFullName] = new RepositoryTrackingConfig()
{
LastRunOn = DateTimeOffset.Now,
RepositoryPath = WorkspaceDirectory
};
// Set the other properties.
LastRunOn = DateTimeOffset.Now;
}
private Dictionary<string, RepositoryTrackingConfig> _repositories;
public string RepositoryName { get; set; }
public string PipelineDirectory { get; set; }
public string WorkspaceDirectory { get; set; }
public Dictionary<string, RepositoryTrackingConfig> Repositories
{
get
{
if (_repositories == null)
{
_repositories = new Dictionary<string, RepositoryTrackingConfig>(StringComparer.OrdinalIgnoreCase);
}
return _repositories;
}
}
[JsonIgnore]
public DateTimeOffset? LastRunOn { get; set; }
[JsonProperty("LastRunOn")]
[EditorBrowsableAttribute(EditorBrowsableState.Never)]
public string LastRunOnString
{
get
{
return string.Format(CultureInfo.InvariantCulture, "{0}", LastRunOn);
}
set
{
if (string.IsNullOrEmpty(value))
{
LastRunOn = null;
return;
}
LastRunOn = DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
}
}
}
}

View File

@@ -0,0 +1,74 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using Newtonsoft.Json;
using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Globalization;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(TrackingManager))]
public interface ITrackingManager : IRunnerService
{
TrackingConfig Create(IExecutionContext executionContext, string file);
TrackingConfig LoadIfExists(IExecutionContext executionContext, string file);
void Update(IExecutionContext executionContext, TrackingConfig config, string file);
}
public sealed class TrackingManager : RunnerService, ITrackingManager
{
public TrackingConfig Create(
IExecutionContext executionContext,
string file)
{
Trace.Entering();
// Create the new tracking config.
TrackingConfig config = new TrackingConfig(executionContext);
WriteToFile(file, config);
return config;
}
public TrackingConfig LoadIfExists(
IExecutionContext executionContext,
string file)
{
Trace.Entering();
// The tracking config will not exist for a new definition.
if (!File.Exists(file))
{
return null;
}
return IOUtil.LoadObject<TrackingConfig>(file);
}
public void Update(
IExecutionContext executionContext,
TrackingConfig config,
string file)
{
Trace.Entering();
WriteToFile(file, config);
}
private void WriteToFile(string file, object value)
{
Trace.Entering();
Trace.Verbose($"Writing config to file: {file}");
// Create the directory if it does not exist.
Directory.CreateDirectory(Path.GetDirectoryName(file));
IOUtil.SaveObject(value, file);
}
}
}

View File

@@ -0,0 +1,221 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using GitHub.DistributedTask.Logging;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
public sealed class Variables
{
private readonly IHostContext _hostContext;
private readonly ConcurrentDictionary<string, Variable> _variables = new ConcurrentDictionary<string, Variable>(StringComparer.OrdinalIgnoreCase);
private readonly ISecretMasker _secretMasker;
private readonly object _setLock = new object();
private readonly Tracing _trace;
public IEnumerable<Variable> AllVariables
{
get
{
return _variables.Values;
}
}
public Variables(IHostContext hostContext, IDictionary<string, VariableValue> copy)
{
// Store/Validate args.
_hostContext = hostContext;
_secretMasker = _hostContext.SecretMasker;
_trace = _hostContext.GetTrace(nameof(Variables));
ArgUtil.NotNull(hostContext, nameof(hostContext));
// Validate the dictionary, remove any variable with empty variable name.
ArgUtil.NotNull(copy, nameof(copy));
if (copy.Keys.Any(k => string.IsNullOrWhiteSpace(k)))
{
_trace.Info($"Remove {copy.Keys.Count(k => string.IsNullOrWhiteSpace(k))} variables with empty variable name.");
}
// Initialize the variable dictionary.
List<Variable> variables = new List<Variable>();
foreach (var variable in copy)
{
if (!string.IsNullOrWhiteSpace(variable.Key))
{
variables.Add(new Variable(variable.Key, variable.Value.Value, variable.Value.IsSecret));
}
}
foreach (Variable variable in variables)
{
// Store the variable. The initial secret values have already been
// registered by the Worker class.
_variables[variable.Name] = variable;
}
}
// DO NOT add file path variable to here.
// All file path variables needs to be retrive and set through ExecutionContext, so it can handle container file path translation.
public string Build_DefinitionName => Get(Constants.Variables.Build.DefinitionName);
public string Build_Number => Get(Constants.Variables.Build.Number);
#if OS_WINDOWS
public bool Retain_Default_Encoding => false;
#else
public bool Retain_Default_Encoding => true;
#endif
public string System_CollectionId => Get(Constants.Variables.System.CollectionId);
public bool? Step_Debug => GetBoolean(Constants.Variables.Actions.StepDebug);
public string System_DefinitionId => Get(Constants.Variables.System.DefinitionId);
public string System_PhaseDisplayName => Get(Constants.Variables.System.PhaseDisplayName);
public string System_TFCollectionUrl => Get(WellKnownDistributedTaskVariables.TFCollectionUrl);
public static readonly HashSet<string> PiiVariables = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Build.AuthorizeAs",
"Build.QueuedBy",
"Build.RequestedFor",
"Build.RequestedForEmail",
"Build.SourceBranch",
"Build.SourceBranchName",
"Build.SourceTfvcShelveset",
"Build.SourceVersion",
"Build.SourceVersionAuthor",
"Job.AuthorizeAs",
"Release.Deployment.RequestedFor",
"Release.Deployment.RequestedForEmail",
"Release.RequestedFor",
"Release.RequestedForEmail",
};
public static readonly string PiiArtifactVariablePrefix = "Release.Artifacts";
public static readonly List<string> PiiArtifactVariableSuffixes = new List<string>()
{
"SourceBranch",
"SourceBranchName",
"SourceVersion",
"RequestedFor"
};
public string Get(string name)
{
Variable variable;
if (_variables.TryGetValue(name, out variable))
{
_trace.Verbose($"Get '{name}': '{variable.Value}'");
return variable.Value;
}
_trace.Verbose($"Get '{name}' (not found)");
return null;
}
public bool? GetBoolean(string name)
{
bool val;
if (bool.TryParse(Get(name), out val))
{
return val;
}
return null;
}
public T? GetEnum<T>(string name) where T : struct
{
return EnumUtil.TryParse<T>(Get(name));
}
public Guid? GetGuid(string name)
{
Guid val;
if (Guid.TryParse(Get(name), out val))
{
return val;
}
return null;
}
public int? GetInt(string name)
{
int val;
if (int.TryParse(Get(name), out val))
{
return val;
}
return null;
}
public long? GetLong(string name)
{
long val;
if (long.TryParse(Get(name), out val))
{
return val;
}
return null;
}
public bool TryGetValue(string name, out string val)
{
Variable variable;
if (_variables.TryGetValue(name, out variable))
{
val = variable.Value;
_trace.Verbose($"Get '{name}': '{val}'");
return true;
}
val = null;
_trace.Verbose($"Get '{name}' (not found)");
return false;
}
public DictionaryContextData ToSecretsContext()
{
var result = new DictionaryContextData();
foreach (var variable in _variables.Values)
{
if (variable.Secret &&
!string.Equals(variable.Name, Constants.Variables.System.AccessToken, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(variable.Name, "system.github.token", StringComparison.OrdinalIgnoreCase))
{
result[variable.Name] = new StringContextData(variable.Value);
}
}
return result;
}
}
public sealed class Variable
{
public string Name { get; private set; }
public bool Secret { get; private set; }
public string Value { get; private set; }
public Variable(string name, string value, bool secret)
{
ArgUtil.NotNullOrEmpty(name, nameof(name));
Name = name;
Value = value ?? string.Empty;
Secret = secret;
}
}
}

215
src/Runner.Worker/Worker.cs Normal file
View File

@@ -0,0 +1,215 @@
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Common.Util;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Services.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
[ServiceLocator(Default = typeof(Worker))]
public interface IWorker : IRunnerService
{
Task<int> RunAsync(string pipeIn, string pipeOut);
}
public sealed class Worker : RunnerService, IWorker
{
private readonly TimeSpan _workerStartTimeout = TimeSpan.FromSeconds(30);
private ManualResetEvent _completedCommand = new ManualResetEvent(false);
// Do not mask the values of these secrets
private static HashSet<String> SecretVariableMaskWhitelist = new HashSet<String>(StringComparer.OrdinalIgnoreCase){
Constants.Variables.Actions.StepDebug,
Constants.Variables.Actions.RunnerDebug
};
public async Task<int> RunAsync(string pipeIn, string pipeOut)
{
try
{
// Setup way to handle SIGTERM/unloading signals
_completedCommand.Reset();
HostContext.Unloading += Worker_Unloading;
// Validate args.
ArgUtil.NotNullOrEmpty(pipeIn, nameof(pipeIn));
ArgUtil.NotNullOrEmpty(pipeOut, nameof(pipeOut));
var runnerWebProxy = HostContext.GetService<IRunnerWebProxy>();
var runnerCertManager = HostContext.GetService<IRunnerCertificateManager>();
VssUtil.InitializeVssClientSettings(HostContext.UserAgent, runnerWebProxy.WebProxy, runnerCertManager.VssClientCertificateManager);
var jobRunner = HostContext.CreateService<IJobRunner>();
using (var channel = HostContext.CreateService<IProcessChannel>())
using (var jobRequestCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken))
using (var channelTokenSource = new CancellationTokenSource())
{
// Start the channel.
channel.StartClient(pipeIn, pipeOut);
// Wait for up to 30 seconds for a message from the channel.
HostContext.WritePerfCounter("WorkerWaitingForJobMessage");
Trace.Info("Waiting to receive the job message from the channel.");
WorkerMessage channelMessage;
using (var csChannelMessage = new CancellationTokenSource(_workerStartTimeout))
{
channelMessage = await channel.ReceiveAsync(csChannelMessage.Token);
}
// Deserialize the job message.
Trace.Info("Message received.");
ArgUtil.Equal(MessageType.NewJobRequest, channelMessage.MessageType, nameof(channelMessage.MessageType));
ArgUtil.NotNullOrEmpty(channelMessage.Body, nameof(channelMessage.Body));
var jobMessage = StringUtil.ConvertFromJson<Pipelines.AgentJobRequestMessage>(channelMessage.Body);
ArgUtil.NotNull(jobMessage, nameof(jobMessage));
HostContext.WritePerfCounter($"WorkerJobMessageReceived_{jobMessage.RequestId.ToString()}");
// Initialize the secret masker and set the thread culture.
InitializeSecretMasker(jobMessage);
SetCulture(jobMessage);
// Start the job.
Trace.Info($"Job message:{Environment.NewLine} {StringUtil.ConvertToJson(WorkerUtilities.ScrubPiiData(jobMessage))}");
Task<TaskResult> jobRunnerTask = jobRunner.RunAsync(jobMessage, jobRequestCancellationToken.Token);
// Start listening for a cancel message from the channel.
Trace.Info("Listening for cancel message from the channel.");
Task<WorkerMessage> channelTask = channel.ReceiveAsync(channelTokenSource.Token);
// Wait for one of the tasks to complete.
Trace.Info("Waiting for the job to complete or for a cancel message from the channel.");
Task.WaitAny(jobRunnerTask, channelTask);
// Handle if the job completed.
if (jobRunnerTask.IsCompleted)
{
Trace.Info("Job completed.");
channelTokenSource.Cancel(); // Cancel waiting for a message from the channel.
return TaskResultUtil.TranslateToReturnCode(await jobRunnerTask);
}
// Otherwise a cancel message was received from the channel.
Trace.Info("Cancellation/Shutdown message received.");
channelMessage = await channelTask;
switch (channelMessage.MessageType)
{
case MessageType.CancelRequest:
jobRequestCancellationToken.Cancel(); // Expire the host cancellation token.
break;
case MessageType.RunnerShutdown:
HostContext.ShutdownRunner(ShutdownReason.UserCancelled);
break;
case MessageType.OperatingSystemShutdown:
HostContext.ShutdownRunner(ShutdownReason.OperatingSystemShutdown);
break;
default:
throw new ArgumentOutOfRangeException(nameof(channelMessage.MessageType), channelMessage.MessageType, nameof(channelMessage.MessageType));
}
// Await the job.
return TaskResultUtil.TranslateToReturnCode(await jobRunnerTask);
}
}
finally
{
HostContext.Unloading -= Worker_Unloading;
_completedCommand.Set();
}
}
private void InitializeSecretMasker(Pipelines.AgentJobRequestMessage message)
{
Trace.Entering();
ArgUtil.NotNull(message, nameof(message));
ArgUtil.NotNull(message.Resources, nameof(message.Resources));
// Add mask hints for secret variables
foreach (var variable in (message.Variables ?? new Dictionary<string, VariableValue>()))
{
// Need to ignore values on whitelist
if (variable.Value.IsSecret && !SecretVariableMaskWhitelist.Contains(variable.Key))
{
var value = variable.Value.Value?.Trim() ?? string.Empty;
// Add the entire value, even if it contains CR or LF. During expression tracing,
// invidual trace info may contain line breaks.
HostContext.SecretMasker.AddValue(value);
// Also add each individual line. Typically individual lines are processed from STDOUT of child processes.
var split = value.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var item in split)
{
HostContext.SecretMasker.AddValue(item.Trim());
}
}
}
// Add mask hints
foreach (MaskHint maskHint in (message.MaskHints ?? new List<MaskHint>()))
{
if (maskHint.Type == MaskType.Regex)
{
HostContext.SecretMasker.AddRegex(maskHint.Value);
// We need this because the worker will print out the job message JSON to diag log
// and SecretMasker has JsonEscapeEncoder hook up
HostContext.SecretMasker.AddValue(maskHint.Value);
}
else
{
// TODO: Should we fail instead? Do any additional pains need to be taken here? Should the job message not be traced?
Trace.Warning($"Unsupported mask type '{maskHint.Type}'.");
}
}
// TODO: Avoid adding redundant secrets. If the endpoint auth matches the system connection, then it's added as a value secret and as a regex secret. Once as a value secret b/c of the following code that iterates over each endpoint. Once as a regex secret due to the hint sent down in the job message.
// Add masks for service endpoints
foreach (ServiceEndpoint endpoint in message.Resources.Endpoints ?? new List<ServiceEndpoint>())
{
foreach (string value in endpoint.Authorization?.Parameters?.Values ?? new string[0])
{
if (!string.IsNullOrEmpty(value))
{
HostContext.SecretMasker.AddValue(value);
}
}
}
// Add masks for secure file download tickets
foreach (SecureFile file in message.Resources.SecureFiles ?? new List<SecureFile>())
{
if (!string.IsNullOrEmpty(file.Ticket))
{
HostContext.SecretMasker.AddValue(file.Ticket);
}
}
}
private void SetCulture(Pipelines.AgentJobRequestMessage message)
{
// Extract the culture name from the job's variable dictionary.
VariableValue culture;
ArgUtil.NotNull(message, nameof(message));
ArgUtil.NotNull(message.Variables, nameof(message.Variables));
if (message.Variables.TryGetValue(Constants.Variables.System.Culture, out culture))
{
// Set the default thread culture.
HostContext.SetDefaultCulture(culture.Value);
}
}
private void Worker_Unloading(object sender, EventArgs e)
{
if (!HostContext.RunnerShutdownToken.IsCancellationRequested)
{
HostContext.ShutdownRunner(ShutdownReason.UserCancelled);
_completedCommand.WaitOne(Constants.Runner.ExitOnUnloadTimeout);
}
}
}
}

View File

@@ -0,0 +1,92 @@
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using System;
using System.Collections.Generic;
using System.Linq;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker
{
public class WorkerUtilities
{
public static Pipelines.AgentJobRequestMessage ScrubPiiData(Pipelines.AgentJobRequestMessage message)
{
ArgUtil.NotNull(message, nameof(message));
var scrubbedVariables = new Dictionary<string, VariableValue>();
// Scrub the known PII variables
foreach (var variable in message.Variables)
{
if (Variables.PiiVariables.Contains(variable.Key) ||
(variable.Key.StartsWith(Variables.PiiArtifactVariablePrefix, StringComparison.OrdinalIgnoreCase)
&& Variables.PiiArtifactVariableSuffixes.Any(varSuffix => variable.Key.EndsWith(varSuffix, StringComparison.OrdinalIgnoreCase))))
{
scrubbedVariables[variable.Key] = "[PII]";
}
else
{
scrubbedVariables[variable.Key] = variable.Value;
}
}
var scrubbedRepositories = new List<Pipelines.RepositoryResource>();
// Scrub the repository resources
foreach (var repository in message.Resources.Repositories)
{
Pipelines.RepositoryResource scrubbedRepository = repository.Clone();
var versionInfo = repository.Properties.Get<Pipelines.VersionInfo>(Pipelines.RepositoryPropertyNames.VersionInfo);
if (versionInfo != null)
{
scrubbedRepository.Properties.Set(
Pipelines.RepositoryPropertyNames.VersionInfo,
new Pipelines.VersionInfo()
{
Author = "[PII]",
Message = versionInfo.Message
});
}
scrubbedRepositories.Add(scrubbedRepository);
}
var scrubbedJobResources = new Pipelines.JobResources();
scrubbedJobResources.Containers.AddRange(message.Resources.Containers);
scrubbedJobResources.Endpoints.AddRange(message.Resources.Endpoints);
scrubbedJobResources.Repositories.AddRange(scrubbedRepositories);
scrubbedJobResources.SecureFiles.AddRange(message.Resources.SecureFiles);
var contextData = new DictionaryContextData();
if (message.ContextData?.Count > 0)
{
foreach (var pair in message.ContextData)
{
contextData[pair.Key] = pair.Value;
}
}
// Reconstitute a new agent job request message from the scrubbed parts
return new Pipelines.AgentJobRequestMessage(
plan: message.Plan,
timeline: message.Timeline,
jobId: message.JobId,
jobDisplayName: message.JobDisplayName,
jobName: message.JobName,
jobContainer: message.JobContainer,
jobServiceContainers: message.JobServiceContainers,
environmentVariables: message.EnvironmentVariables,
variables: scrubbedVariables,
maskHints: message.MaskHints,
jobResources: scrubbedJobResources,
contextData: contextData,
workspaceOptions: message.Workspace,
steps: message.Steps,
scopes: message.Scopes);
}
}
}

View File

@@ -0,0 +1,106 @@
{
"definitions": {
"action-root": {
"description": "Action file",
"mapping": {
"properties": {
"name": "string",
"description": "string",
"inputs": "inputs",
"runs": "runs"
},
"loose-key-type": "non-empty-string",
"loose-value-type": "any"
}
},
"inputs": {
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "input"
}
},
"input": {
"mapping": {
"properties": {
"default": "input-default-context"
},
"loose-key-type": "non-empty-string",
"loose-value-type": "any"
}
},
"runs": {
"one-of": [
"container-runs",
"node12-runs",
"plugin-runs"
]
},
"container-runs": {
"mapping": {
"properties": {
"using": "non-empty-string",
"image": "non-empty-string",
"entrypoint": "non-empty-string",
"args": "container-runs-args",
"env": "container-runs-env",
"post-entrypoint": "non-empty-string",
"post-if": "non-empty-string"
}
}
},
"container-runs-args": {
"sequence": {
"item-type": "container-runs-context"
}
},
"container-runs-env": {
"context": [
"inputs"
],
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "string"
}
},
"node12-runs": {
"mapping": {
"properties": {
"using": "non-empty-string",
"main": "non-empty-string",
"post": "non-empty-string",
"post-if": "non-empty-string"
}
}
},
"plugin-runs": {
"mapping": {
"properties": {
"plugin": "non-empty-string"
}
}
},
"container-runs-context": {
"context": [
"inputs"
],
"string": {}
},
"input-default-context": {
"context": [
"github",
"strategy",
"matrix",
"steps",
"job",
"runner",
"env"
],
"string": {}
},
"non-empty-string": {
"string": {
"require-non-empty": true
}
}
}
}