using System; using System.Collections.Generic; 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; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker.Handlers; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker { public enum ActionRunStage { Pre, Main, Post, } [ServiceLocator(Default = typeof(ActionRunner))] public interface IActionRunner : IStep, IRunnerService { ActionRunStage Stage { get; set; } 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(); var handlerFactory = HostContext.GetService(); // 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)); List localActionContainerSetupSteps = null; // Handle Composite Local Actions // Need to download and expand the tree of referenced actions if (handlerData.ExecutionType == ActionExecutionType.Composite && handlerData is CompositeActionExecutionData compositeHandlerData && Stage == ActionRunStage.Main && Action.Reference is Pipelines.RepositoryPathReference localAction && string.Equals(localAction.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase)) { var actionManager = HostContext.GetService(); var prepareResult = await actionManager.PrepareActionsAsync(ExecutionContext, compositeHandlerData.Steps, ExecutionContext.Id); // Reload definition since post may exist now (from embedded steps that were JIT downloaded) definition = taskManager.LoadAction(ExecutionContext, Action); ArgUtil.NotNull(definition, nameof(definition)); handlerData = definition.Data?.Execution; ArgUtil.NotNull(handlerData, nameof(handlerData)); // Save container setup steps so we can reference them later localActionContainerSetupSteps = prepareResult.ContainerSetupSteps; } if (handlerData.HasPre && Action.Reference is Pipelines.RepositoryPathReference repoAction && string.Equals(repoAction.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase)) { ExecutionContext.Warning($"`pre` execution is not supported for local action from '{repoAction.Path}'"); } // 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.HasPost && (Stage == ActionRunStage.Pre || Stage == ActionRunStage.Main)) { string postDisplayName = $"Post {this.DisplayName}"; if (Stage == ActionRunStage.Pre && this.DisplayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase)) { // Trim the leading `Pre ` from the display name. // Otherwise, we will get `Post Pre xxx` as DisplayName for the Post step. postDisplayName = $"Post {this.DisplayName.Substring("Pre ".Length)}"; } 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}"; ExecutionContext.Debug($"Register post job cleanup for action: {repoString}"); var actionRunner = HostContext.CreateService(); actionRunner.Action = Action; actionRunner.Stage = ActionRunStage.Post; actionRunner.Condition = handlerData.CleanupCondition; actionRunner.DisplayName = postDisplayName; ExecutionContext.RegisterPostJobStep(actionRunner); } IStepHost stepHost = HostContext.CreateService(); ExecutionContext.WriteWebhookPayload(); // Set GITHUB_ACTION_REPOSITORY if this Action is from a repository if (Action.Reference is Pipelines.RepositoryPathReference repoPathReferenceAction && !string.Equals(repoPathReferenceAction.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase)) { ExecutionContext.SetGitHubContext("action_repository", repoPathReferenceAction.Name); ExecutionContext.SetGitHubContext("action_ref", repoPathReferenceAction.Ref); } else { ExecutionContext.SetGitHubContext("action_repository", null); ExecutionContext.SetGitHubContext("action_ref", null); } // Setup container stephost for running inside the container. if (ExecutionContext.Global.Container != null) { // Make sure the required container is already created // Container hooks do not necessarily set 'ContainerId' if (!FeatureManager.IsContainerHooksEnabled(ExecutionContext.Global.Variables)) { ArgUtil.NotNullOrEmpty(ExecutionContext.Global.Container.ContainerId, nameof(ExecutionContext.Global.Container.ContainerId)); } var containerStepHost = HostContext.CreateService(); containerStepHost.Container = ExecutionContext.Global.Container; stepHost = containerStepHost; } // Setup File Command Manager var fileCommandManager = HostContext.CreateService(); fileCommandManager.InitializeFiles(ExecutionContext, null); // Load the inputs. ExecutionContext.Debug("Loading inputs"); Dictionary inputs; if (ExecutionContext.Global.Variables.GetBoolean(Constants.Runner.Features.UseContainerPathForTemplate) ?? false) { inputs = EvaluateStepInputs(stepHost); } else { var templateEvaluator = ExecutionContext.ToPipelineTemplateEvaluator(); inputs = templateEvaluator.EvaluateStepInputs(Action.Inputs, ExecutionContext.ExpressionValues, ExecutionContext.ExpressionFunctions); } var userInputs = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair input in inputs) { userInputs.Add(input.Key); 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)); } } var validInputs = new HashSet(StringComparer.OrdinalIgnoreCase); if (handlerData.ExecutionType == ActionExecutionType.Container) { // container action always accept 'entryPoint' and 'args' as inputs // https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepswithargs validInputs.Add("entryPoint"); validInputs.Add("args"); } // Merge the default inputs from the definition if (definition.Data?.Inputs != null) { var manifestManager = HostContext.GetService(); foreach (var input in definition.Data.Inputs) { string key = input.Key.AssertString("action input name").Value; validInputs.Add(key); if (!inputs.ContainsKey(key)) { inputs[key] = manifestManager.EvaluateDefaultInput(ExecutionContext, key, input.Value); } } } // Validate inputs only for actions with action.yml if (Action.Reference.Type == Pipelines.ActionSourceType.Repository) { var unexpectedInputs = new List(); foreach (var input in userInputs) { if (!validInputs.Contains(input)) { unexpectedInputs.Add(input); } } if (unexpectedInputs.Count > 0) { ExecutionContext.Warning($"Unexpected input(s) '{string.Join("', '", unexpectedInputs)}', valid inputs are ['{string.Join("', '", validInputs)}']"); } } // Load the action environment. ExecutionContext.Debug("Loading env"); var environment = new Dictionary(VarUtil.EnvironmentVariableKeyComparer); #if OS_WINDOWS var envContext = ExecutionContext.ExpressionValues["env"] as DictionaryContextData; #else var envContext = ExecutionContext.ExpressionValues["env"] as CaseSensitiveDictionaryContextData; #endif // Apply environment from env context, env context contains job level env and action's evn block foreach (var env in envContext) { environment[env.Key] = env.Value.ToString(); } // 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.Global.Variables, actionDirectory: definition.Directory, localActionContainerSetupSteps: localActionContainerSetupSteps); // Print out action details and log telemetry handler.PrepareExecution(Stage); // Run the task. try { await handler.RunAsync(Stage); } finally { fileCommandManager.ProcessFiles(ExecutionContext, ExecutionContext.Global.Container); } } /// /// Attempts to update the DisplayName. /// As the "Try..." name implies, this method should never throw an exception. /// Returns true if the DisplayName is already present or it was successfully updated. /// public bool TryUpdateDisplayName(out bool updated) { updated = false; // REVIEW: This try/catch can be removed if some future implementation of EvaluateDisplayName and UpdateTimelineRecordDisplayName // can make reasonable guarantees that they won't throw an exception. try { // This attempt is only worthwhile at the "Main" stage. // When the job starts, there's an initial attempt to evaluate the DisplayName. (see JobExtension::InitializeJob) // During the "Pre" stage, we expect that no contexts will have changed since the initial evaluation. // "Main" stage is handled here. // During the "Post" stage, it no longer matters. if (this.Stage == ActionRunStage.Main && EvaluateDisplayName(this.ExecutionContext.ExpressionValues, this.ExecutionContext, out updated)) { if (updated) { this.ExecutionContext.UpdateTimelineRecordDisplayName(this.DisplayName); } } } catch (Exception ex) { Trace.Warning("Caught exception while attempting to evaulate/update the step's DisplayName. Exception Details: {0}", ex); } // For consistency with other implementations of TryUpdateDisplayName we use !string.IsNullOrEmpty below, // but note that (at the time of this writing) ActionRunner::DisplayName::get always returns a non-empty string due to its fallback logic. // In other words, the net effect is that this particular implementation of TryUpdateDisplayName will always return true. return !string.IsNullOrEmpty(this.DisplayName); } /// /// Attempts to evaluate the DisplayName of this IActionRunner. /// Returns true if the DisplayName is already present or it was successfully evaluated. /// public bool EvaluateDisplayName(DictionaryContextData contextData, IExecutionContext context, out bool updated) { ArgUtil.NotNull(context, nameof(context)); ArgUtil.NotNull(Action, nameof(Action)); updated = false; // If we have already expanded the display name, don't bother attempting [re-]expansion. if (_didFullyEvaluateDisplayName || !string.IsNullOrEmpty(Action.DisplayName)) { return true; } _displayName = GenerateDisplayName(Action, contextData, context, out bool didFullyEvaluate); // If we evaluated, fully mask any secrets if (didFullyEvaluate) { _displayName = HostContext.SecretMasker.MaskSecrets(_displayName); updated = true; } context.Debug($"Set step '{Action.Name}' display name to: '{_displayName}'"); _didFullyEvaluateDisplayName = didFullyEvaluate; return didFullyEvaluate; } private Dictionary EvaluateStepInputs(IStepHost stepHost) { DictionaryContextData expressionValues = ExecutionContext.GetExpressionValues(stepHost); var templateEvaluator = ExecutionContext.ToPipelineTemplateEvaluator(); var inputs = templateEvaluator.EvaluateStepInputs(Action.Inputs, expressionValues, ExecutionContext.ExpressionFunctions); return inputs; } 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 try { if (tokenToParse.CheckHasRequiredContext(contextData, context.ExpressionFunctions)) { var templateEvaluator = context.ToPipelineTemplateEvaluator(); displayName = templateEvaluator.EvaluateStepDisplayName(tokenToParse, contextData, context.ExpressionFunctions); didFullyEvaluate = true; } } 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}"; } } }