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