using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; 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 Pipelines = GitHub.DistributedTask.Pipelines; using PipelineTemplateConstants = GitHub.DistributedTask.Pipelines.ObjectTemplating.PipelineTemplateConstants; using WebApi = GitHub.DistributedTask.WebApi; namespace GitHub.Runner.Worker { public class PrepareResult { public PrepareResult(List containerSetupSteps, Dictionary preStepTracker) { this.ContainerSetupSteps = containerSetupSteps; this.PreStepTracker = preStepTracker; } public List ContainerSetupSteps { get; set; } public Dictionary PreStepTracker { get; set; } } [ServiceLocator(Default = typeof(ActionManager))] public interface IActionManager : IRunnerService { Dictionary CachedActionContainers { get; } Dictionary> CachedEmbeddedPreSteps { get; } Dictionary> CachedEmbeddedStepIds { get; } Dictionary> CachedEmbeddedPostSteps { get; } Task PrepareActionsAsync(IExecutionContext executionContext, IEnumerable steps, Guid rootStepId = default(Guid)); 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 _cachedActionContainers = new(); public Dictionary CachedActionContainers => _cachedActionContainers; private readonly Dictionary> _cachedEmbeddedPreSteps = new(); public Dictionary> CachedEmbeddedPreSteps => _cachedEmbeddedPreSteps; private readonly Dictionary> _cachedEmbeddedStepIds = new(); public Dictionary> CachedEmbeddedStepIds => _cachedEmbeddedStepIds; private readonly Dictionary> _cachedEmbeddedPostSteps = new(); public Dictionary> CachedEmbeddedPostSteps => _cachedEmbeddedPostSteps; public async Task PrepareActionsAsync(IExecutionContext executionContext, IEnumerable steps, Guid rootStepId = default(Guid)) { // Assert inputs ArgUtil.NotNull(executionContext, nameof(executionContext)); ArgUtil.NotNull(steps, nameof(steps)); var state = new PrepareActionsState { ImagesToBuild = new Dictionary>(StringComparer.OrdinalIgnoreCase), ImagesToPull = new Dictionary>(StringComparer.OrdinalIgnoreCase), ImagesToBuildInfo = new Dictionary(StringComparer.OrdinalIgnoreCase), PreStepTracker = new Dictionary() }; var containerSetupSteps = new List(); var depth = 0; // We are running at the start of a job if (rootStepId == default(Guid)) { IOUtil.DeleteDirectory(HostContext.GetDirectory(WellKnownDirectory.Actions), executionContext.CancellationToken); } // We are running mid job due to a local composite action else { if (!_cachedEmbeddedStepIds.ContainsKey(rootStepId)) { _cachedEmbeddedStepIds[rootStepId] = new List(); foreach (var compositeStep in steps) { var guid = Guid.NewGuid(); compositeStep.Id = guid; _cachedEmbeddedStepIds[rootStepId].Add(guid); } } depth = 1; } IEnumerable actions = steps.OfType(); executionContext.Output("Prepare all required actions"); PrepareActionsState result = new PrepareActionsState(); try { result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId); } catch (FailedToResolveActionDownloadInfoException ex) { // Log the error and fail the PrepareActionsAsync Initialization. Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); executionContext.InfrastructureError(ex.Message); executionContext.Result = TaskResult.Failed; throw; } catch (InvalidActionArchiveException ex) { // Log the error and fail the PrepareActionsAsync Initialization. Trace.Error($"Caught exception from PrepareActionsAsync Initialization: {ex}"); executionContext.InfrastructureError(ex.Message); executionContext.Result = TaskResult.Failed; throw; } if (!FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) { if (state.ImagesToPull.Count > 0) { foreach (var imageToPull in result.ImagesToPull) { Trace.Info($"{imageToPull.Value.Count} steps need to pull image '{imageToPull.Key}'"); containerSetupSteps.Add(new JobExtensionRunner(runAsync: this.PullActionContainerAsync, condition: $"{PipelineTemplateConstants.Success}()", displayName: $"Pull {imageToPull.Key}", data: new ContainerSetupInfo(imageToPull.Value, imageToPull.Key))); } } if (result.ImagesToBuild.Count > 0) { foreach (var imageToBuild in result.ImagesToBuild) { var setupInfo = result.ImagesToBuildInfo[imageToBuild.Key]; Trace.Info($"{imageToBuild.Value.Count} steps need to build image from '{setupInfo.Dockerfile}'"); containerSetupSteps.Add(new JobExtensionRunner(runAsync: this.BuildActionContainerAsync, condition: $"{PipelineTemplateConstants.Success}()", displayName: $"Build {setupInfo.ActionRepository}", data: new ContainerSetupInfo(imageToBuild.Value, setupInfo.Dockerfile, setupInfo.WorkingDirectory))); } } #if !OS_LINUX if (containerSetupSteps.Count > 0) { executionContext.Output("Container action is only supported on Linux, skip pull and build docker images."); containerSetupSteps.Clear(); } #endif } return new PrepareResult(containerSetupSteps, result.PreStepTracker); } private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Int32 depth = 0, Guid parentStepId = default(Guid)) { ArgUtil.NotNull(executionContext, nameof(executionContext)); if (depth > Constants.CompositeActionsMaxDepth) { throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}"); } var repositoryActions = new List(); 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 (!state.ImagesToPull.ContainsKey(containerReference.Image)) { state.ImagesToPull[containerReference.Image] = new List(); } Trace.Info($"Action {action.Name} ({action.Id}) needs to pull image '{containerReference.Image}'"); state.ImagesToPull[containerReference.Image].Add(action.Id); } else if (action.Reference.Type == Pipelines.ActionSourceType.Repository) { repositoryActions.Add(action); } } if (repositoryActions.Count > 0) { // Get the download info var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions); // Download each action foreach (var action in repositoryActions) { var lookupKey = GetDownloadInfoLookupKey(action); if (string.IsNullOrEmpty(lookupKey)) { continue; } if (!downloadInfos.TryGetValue(lookupKey, out var downloadInfo)) { throw new Exception($"Missing download info for {lookupKey}"); } await DownloadRepositoryActionAsync(executionContext, downloadInfo); } // More preparation based on content in the repository (action.yml) foreach (var action in repositoryActions) { var setupInfo = PrepareRepositoryActionAsync(executionContext, action); if (setupInfo != null && setupInfo.Container != null) { if (!string.IsNullOrEmpty(setupInfo.Container.Image)) { if (!state.ImagesToPull.ContainsKey(setupInfo.Container.Image)) { state.ImagesToPull[setupInfo.Container.Image] = new List(); } Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to pull image '{setupInfo.Container.Image}'"); state.ImagesToPull[setupInfo.Container.Image].Add(action.Id); } else { ArgUtil.NotNullOrEmpty(setupInfo.Container.ActionRepository, nameof(setupInfo.Container.ActionRepository)); if (!state.ImagesToBuild.ContainsKey(setupInfo.Container.ActionRepository)) { state.ImagesToBuild[setupInfo.Container.ActionRepository] = new List(); } Trace.Info($"Action {action.Name} ({action.Id}) from repository '{setupInfo.Container.ActionRepository}' needs to build image '{setupInfo.Container.Dockerfile}'"); state.ImagesToBuild[setupInfo.Container.ActionRepository].Add(action.Id); state.ImagesToBuildInfo[setupInfo.Container.ActionRepository] = setupInfo.Container; } } else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0) { state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id); } var repoAction = action.Reference as Pipelines.RepositoryPathReference; if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias) { var definition = LoadAction(executionContext, action); if (definition.Data.Execution.HasPre) { Trace.Info($"Add 'pre' execution for {action.Id}"); // Root Step if (depth < 1) { var actionRunner = HostContext.CreateService(); actionRunner.Action = action; actionRunner.Stage = ActionRunStage.Pre; actionRunner.Condition = definition.Data.Execution.InitCondition; state.PreStepTracker[action.Id] = actionRunner; } // Embedded Step else { if (!_cachedEmbeddedPreSteps.ContainsKey(parentStepId)) { _cachedEmbeddedPreSteps[parentStepId] = new List(); } // Clone action so we can modify the condition without affecting the original var clonedAction = action.Clone() as Pipelines.ActionStep; clonedAction.Condition = definition.Data.Execution.InitCondition; _cachedEmbeddedPreSteps[parentStepId].Add(clonedAction); } } if (definition.Data.Execution.HasPost && depth > 0) { if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId)) { // If we haven't done so already, add the parent to the post steps _cachedEmbeddedPostSteps[parentStepId] = new Stack(); } // Clone action so we can modify the condition without affecting the original var clonedAction = action.Clone() as Pipelines.ActionStep; clonedAction.Condition = definition.Data.Execution.CleanupCondition; _cachedEmbeddedPostSteps[parentStepId].Push(clonedAction); } } else if (depth > 0) { // if we're in a composite action and haven't loaded the local action yet // we assume it has a post step if (!_cachedEmbeddedPostSteps.ContainsKey(parentStepId)) { // If we haven't done so already, add the parent to the post steps _cachedEmbeddedPostSteps[parentStepId] = new Stack(); } // Clone action so we can modify the condition without affecting the original var clonedAction = action.Clone() as Pipelines.ActionStep; _cachedEmbeddedPostSteps[parentStepId].Push(clonedAction); } } } return state; } 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) { if (FeatureManager.IsContainerHooksEnabled(executionContext.Global.Variables)) { Trace.Info("Load action that will run container through container hooks."); var containerAction = action.Reference as Pipelines.ContainerRegistryReference; definition.Data.Execution = new ContainerActionExecutionData() { Image = containerAction.Image, }; Trace.Info($"Using action container image: {containerAction.Image}."); } else { 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, Constants.Path.ActionManifestYmlFile); string manifestFileYaml = Path.Combine(actionDirectory, Constants.Path.ActionManifestYamlFile); string dockerFile = Path.Combine(actionDirectory, "Dockerfile"); string dockerFileLowerCase = Path.Combine(actionDirectory, "dockerfile"); if (File.Exists(manifestFile) || File.Exists(manifestFileYaml)) { var manifestManager = HostContext.GetService(); if (File.Exists(manifestFile)) { definition.Data = manifestManager.Load(executionContext, manifestFile); } else { definition.Data = manifestManager.Load(executionContext, manifestFileYaml); } 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.Pre)) { Trace.Info($"Action container pre entrypoint: {containerAction.Pre}."); } if (!string.IsNullOrEmpty(containerAction.EntryPoint)) { Trace.Info($"Action container entrypoint: {containerAction.EntryPoint}."); } if (!string.IsNullOrEmpty(containerAction.Post)) { Trace.Info($"Action container post entrypoint: {containerAction.Post}."); } 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 pre node.js file: {nodeAction.Pre ?? "N/A"}."); Trace.Info($"Action node.js file: {nodeAction.Script}."); Trace.Info($"Action post node.js file: {nodeAction.Post ?? "N/A"}."); } else if (definition.Data.Execution.ExecutionType == ActionExecutionType.Plugin) { var pluginAction = definition.Data.Execution as PluginActionExecutionData; var pluginManager = HostContext.GetService(); 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.Post = plugin.PostPluginTypeName; Trace.Info($"Action cleanup plugin: {plugin.PluginTypeName}."); } } else if (definition.Data.Execution.ExecutionType == ActionExecutionType.Composite) { var compositeAction = definition.Data.Execution as CompositeActionExecutionData; Trace.Info($"Load {compositeAction.Steps?.Count ?? 0} action steps."); Trace.Verbose($"Details: {StringUtil.ConvertToJson(compositeAction?.Steps)}"); Trace.Info($"Load: {compositeAction.Outputs?.Count ?? 0} number of outputs"); Trace.Info($"Details: {StringUtil.ConvertToJson(compositeAction?.Outputs)}"); if (CachedEmbeddedPreSteps.TryGetValue(action.Id, out var preSteps)) { compositeAction.PreSteps = preSteps; } if (CachedEmbeddedPostSteps.TryGetValue(action.Id, out var postSteps)) { compositeAction.PostSteps = postSteps; } if (_cachedEmbeddedStepIds.ContainsKey(action.Id)) { for (var i = 0; i < compositeAction.Steps.Count; i++) { // Load stored Ids for later load actions compositeAction.Steps[i].Id = _cachedEmbeddedStepIds[action.Id][i]; } } else { _cachedEmbeddedStepIds[action.Id] = new List(); foreach (var compositeStep in compositeAction.Steps) { var guid = Guid.NewGuid(); compositeStep.Id = guid; _cachedEmbeddedStepIds[action.Id].Add(guid); } } } 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', 'action.yaml' 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($"##[group]Pull down action image '{setupInfo.Container.Image}'"); // Pull down docker image with retry up to 3 times var dockerManager = HostContext.GetService(); int retryCount = 0; int pullExitCode = 0; while (retryCount < 3) { pullExitCode = await dockerManager.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); } } } executionContext.Output("##[endgroup]"); 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($"##[group]Build container for action use: '{setupInfo.Container.Dockerfile}'."); // Build docker image with retry up to 3 times var dockerManager = HostContext.GetService(); int retryCount = 0; int buildExitCode = 0; var imageName = $"{dockerManager.DockerInstanceLabel}:{Guid.NewGuid().ToString("N")}"; while (retryCount < 3) { buildExitCode = await dockerManager.DockerBuild( executionContext, setupInfo.Container.WorkingDirectory, setupInfo.Container.Dockerfile, 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); } } } executionContext.Output("##[endgroup]"); 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})"); } } // This implementation is temporary and will be replaced with a REST API call to the service to resolve private async Task> GetDownloadInfoAsync(IExecutionContext executionContext, List actions) { executionContext.Output("Getting action download info"); // Convert to action reference var actionReferences = actions .GroupBy(x => GetDownloadInfoLookupKey(x)) .Where(x => !string.IsNullOrEmpty(x.Key)) .Select(x => { var action = x.First(); var repositoryReference = action.Reference as Pipelines.RepositoryPathReference; ArgUtil.NotNull(repositoryReference, nameof(repositoryReference)); return new WebApi.ActionReference { NameWithOwner = repositoryReference.Name, Ref = repositoryReference.Ref, Path = repositoryReference.Path, }; }) .ToList(); // Nothing to resolve? if (actionReferences.Count == 0) { return new Dictionary(); } // Resolve download info var launchServer = HostContext.GetService(); var jobServer = HostContext.GetService(); var actionDownloadInfos = default(WebApi.ActionDownloadInfoCollection); for (var attempt = 1; attempt <= 3; attempt++) { try { if (MessageUtil.IsRunServiceJob(executionContext.Global.Variables.Get(Constants.Variables.System.JobRequestType))) { actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken); } else { actionDownloadInfos = await jobServer.ResolveActionDownloadInfoAsync(executionContext.Global.Plan.ScopeIdentifier, executionContext.Global.Plan.PlanType, executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken); } break; } catch (Exception ex) when (!executionContext.CancellationToken.IsCancellationRequested) // Do not retry if the run is cancelled. { // UnresolvableActionDownloadInfoException is a 422 client error, don't retry // NonRetryableActionDownloadInfoException is an non-retryable exception from Actions // Some possible cases are: // * Repo is rate limited // * Repo or tag doesn't exist, or isn't public // * Policy validation failed if (attempt < 3 && !(ex is WebApi.UnresolvableActionDownloadInfoException) && !(ex is WebApi.NonRetryableActionDownloadInfoException)) { executionContext.Output($"Failed to resolve action download info. Error: {ex.Message}"); executionContext.Debug(ex.ToString()); if (String.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_DOWNLOAD_NO_BACKOFF"))) { var backoff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); executionContext.Output($"Retrying in {backoff.TotalSeconds} seconds"); await Task.Delay(backoff); } } else { // Some possible cases are: // * Repo is rate limited // * Repo or tag doesn't exist, or isn't public // * Policy validation failed if (ex is WebApi.UnresolvableActionDownloadInfoException) { throw; } else { // This exception will be traced as an infrastructure failure throw new WebApi.FailedToResolveActionDownloadInfoException("Failed to resolve action download info.", ex); } } } } ArgUtil.NotNull(actionDownloadInfos, nameof(actionDownloadInfos)); ArgUtil.NotNull(actionDownloadInfos.Actions, nameof(actionDownloadInfos.Actions)); var defaultAccessToken = executionContext.GetGitHubContext("token"); foreach (var actionDownloadInfo in actionDownloadInfos.Actions.Values) { // Add secret HostContext.SecretMasker.AddValue(actionDownloadInfo.Authentication?.Token); // Default auth token if (string.IsNullOrEmpty(actionDownloadInfo.Authentication?.Token)) { actionDownloadInfo.Authentication = new WebApi.ActionDownloadAuthentication { Token = defaultAccessToken }; } } return actionDownloadInfos.Actions; } private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo) { Trace.Entering(); ArgUtil.NotNull(executionContext, nameof(executionContext)); ArgUtil.NotNull(downloadInfo, nameof(downloadInfo)); ArgUtil.NotNullOrEmpty(downloadInfo.NameWithOwner, nameof(downloadInfo.NameWithOwner)); ArgUtil.NotNullOrEmpty(downloadInfo.Ref, nameof(downloadInfo.Ref)); ArgUtil.NotNullOrEmpty(downloadInfo.Ref, nameof(downloadInfo.ResolvedNameWithOwner)); ArgUtil.NotNullOrEmpty(downloadInfo.Ref, nameof(downloadInfo.ResolvedSha)); string destDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Actions), downloadInfo.NameWithOwner.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar), downloadInfo.Ref); string watermarkFile = GetWatermarkFilePath(destDirectory); if (File.Exists(watermarkFile)) { executionContext.Debug($"Action '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}' already downloaded at '{destDirectory}'."); return; } else { // make sure we get a clean folder ready to use. IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken); Directory.CreateDirectory(destDirectory); executionContext.Output($"Download action repository '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}' (SHA:{downloadInfo.ResolvedSha})"); } //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"); string link = downloadInfo?.ZipballUrl; #else string archiveFile = Path.Combine(tempDirectory, $"{Guid.NewGuid()}.tar.gz"); string link = downloadInfo?.TarballUrl; #endif try { var useActionArchiveCache = false; var hasActionArchiveCache = false; var actionArchiveCacheDir = Environment.GetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory); if (!string.IsNullOrEmpty(actionArchiveCacheDir) && Directory.Exists(actionArchiveCacheDir)) { hasActionArchiveCache = true; Trace.Info($"Check if action archive '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}' already exists in cache directory '{actionArchiveCacheDir}'"); #if OS_WINDOWS var cacheArchiveFile = Path.Combine(actionArchiveCacheDir, downloadInfo.ResolvedNameWithOwner.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_'), $"{downloadInfo.ResolvedSha}.zip"); #else var cacheArchiveFile = Path.Combine(actionArchiveCacheDir, downloadInfo.ResolvedNameWithOwner.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_'), $"{downloadInfo.ResolvedSha}.tar.gz"); #endif if (File.Exists(cacheArchiveFile)) { try { Trace.Info($"Found action archive '{cacheArchiveFile}' in cache directory '{actionArchiveCacheDir}'"); File.Copy(cacheArchiveFile, archiveFile); useActionArchiveCache = true; executionContext.Debug($"Copied action archive '{cacheArchiveFile}' to '{archiveFile}'"); } catch (Exception ex) { Trace.Error($"Failed to copy action archive '{cacheArchiveFile}' to '{archiveFile}'. Error: {ex}"); } } } executionContext.Global.JobTelemetry.Add(new JobTelemetry() { Type = JobTelemetryType.General, Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache}" }); if (!useActionArchiveCache) { await DownloadRepositoryArchive(executionContext, link, downloadInfo.Authentication?.Token, archiveFile); } var stagingDirectory = Path.Combine(tempDirectory, "_staging"); Directory.CreateDirectory(stagingDirectory); #if OS_WINDOWS try { ZipFile.ExtractToDirectory(archiveFile, stagingDirectory); } catch (InvalidDataException e) { throw new InvalidActionArchiveException($"Can't un-zip archive file: {archiveFile}. action being checked out: {downloadInfo.NameWithOwner}@{downloadInfo.Ref}. error: {e}."); } #else string tar = WhichUtil.Which("tar", require: true, trace: Trace); // tar -xzf using (var processInvoker = HostContext.CreateService()) { var tarOutputs = new List(); processInvoker.OutputDataReceived += new EventHandler((sender, args) => { if (!string.IsNullOrEmpty(args.Data)) { Trace.Info(args.Data); tarOutputs.Add($"STDOUT: {args.Data}"); } }); processInvoker.ErrorDataReceived += new EventHandler((sender, args) => { if (!string.IsNullOrEmpty(args.Data)) { Trace.Error(args.Data); tarOutputs.Add($"STDERR: {args.Data}"); } }); int exitCode = await processInvoker.ExecuteAsync(stagingDirectory, tar, $"-xzf \"{archiveFile}\"", null, executionContext.CancellationToken); if (exitCode != 0) { var fileInfo = new FileInfo(archiveFile); var sha256hash = await IOUtil.GetFileContentSha256HashAsync(archiveFile); throw new InvalidActionArchiveException($"Can't use 'tar -xzf' extract archive file: {archiveFile} (SHA256 '{sha256hash}', size '{fileInfo.Length}' bytes, tar outputs '{string.Join(' ', tarOutputs)}'). Action being checked out: {downloadInfo.NameWithOwner}@{downloadInfo.Ref}. 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(watermarkFile, 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 string GetWatermarkFilePath(string directory) => directory + ".completed"; private ActionSetupInfo 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 ActionSetupInfo(); var actionContainer = 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}"; actionContainer.ActionRepository = $"{repositoryReference.Name}/{repositoryReference.Path}@{repositoryReference.Ref}"; } else { actionContainer.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, Constants.Path.ActionManifestYmlFile); var actionManifestYaml = Path.Combine(actionEntryDirectory, Constants.Path.ActionManifestYamlFile); if (File.Exists(actionManifest) || File.Exists(actionManifestYaml)) { executionContext.Debug($"action.yml for action: '{actionManifest}'."); var manifestManager = HostContext.GetService(); ActionDefinitionData actionDefinitionData = null; if (File.Exists(actionManifest)) { actionDefinitionData = manifestManager.Load(executionContext, actionManifest); } else { actionDefinitionData = manifestManager.Load(executionContext, actionManifestYaml); } if (actionDefinitionData.Execution.ExecutionType == ActionExecutionType.Container) { var containerAction = actionDefinitionData.Execution as ContainerActionExecutionData; if (DockerUtil.IsDockerfile(containerAction.Image)) { var dockerFileFullPath = Path.Combine(actionEntryDirectory, containerAction.Image); executionContext.Debug($"Dockerfile for action: '{dockerFileFullPath}'."); actionContainer.Dockerfile = dockerFileFullPath; actionContainer.WorkingDirectory = destDirectory; setupInfo.Container = actionContainer; return setupInfo; } else if (containerAction.Image.StartsWith("docker://", StringComparison.OrdinalIgnoreCase)) { var actionImage = containerAction.Image.Substring("docker://".Length); executionContext.Debug($"Container image for action: '{actionImage}'."); actionContainer.Image = actionImage; setupInfo.Container = actionContainer; 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 if (actionDefinitionData.Execution.ExecutionType == ActionExecutionType.Composite) { Trace.Info($"Loading Composite steps"); var compositeAction = actionDefinitionData.Execution as CompositeActionExecutionData; setupInfo.Steps = compositeAction.Steps; // cache steps ids if not done so already if (!_cachedEmbeddedStepIds.ContainsKey(repositoryAction.Id)) { _cachedEmbeddedStepIds[repositoryAction.Id] = new List(); foreach (var compositeStep in compositeAction.Steps) { var guid = Guid.NewGuid(); compositeStep.Id = guid; _cachedEmbeddedStepIds[repositoryAction.Id].Add(guid); } } return setupInfo; } else { throw new NotSupportedException(actionDefinitionData.Execution.ExecutionType.ToString()); } } else if (File.Exists(dockerFile)) { executionContext.Debug($"Dockerfile for action: '{dockerFile}'."); actionContainer.Dockerfile = dockerFile; actionContainer.WorkingDirectory = destDirectory; setupInfo.Container = actionContainer; return setupInfo; } else if (File.Exists(dockerFileLowerCase)) { executionContext.Debug($"Dockerfile for action: '{dockerFileLowerCase}'."); actionContainer.Dockerfile = dockerFileLowerCase; actionContainer.WorkingDirectory = destDirectory; setupInfo.Container = actionContainer; return setupInfo; } else { var reference = repositoryReference.Name; if (!string.IsNullOrEmpty(repositoryReference.Path)) { reference = $"{reference}/{repositoryReference.Path}"; } if (!string.IsNullOrEmpty(repositoryReference.Ref)) { reference = $"{reference}@{repositoryReference.Ref}"; } throw new InvalidOperationException($"Can't find 'action.yml', 'action.yaml' or 'Dockerfile' for action '{reference}'."); } } private static string GetDownloadInfoLookupKey(Pipelines.ActionStep action) { if (action.Reference.Type != Pipelines.ActionSourceType.Repository) { return null; } var repositoryReference = action.Reference as Pipelines.RepositoryPathReference; ArgUtil.NotNull(repositoryReference, nameof(repositoryReference)); if (string.Equals(repositoryReference.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase)) { return null; } 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)); return $"{repositoryReference.Name}@{repositoryReference.Ref}"; } private AuthenticationHeaderValue CreateAuthHeader(string token) { if (string.IsNullOrEmpty(token)) { return null; } var base64EncodingToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{token}")); HostContext.SecretMasker.AddValue(base64EncodingToken); return new AuthenticationHeaderValue("Basic", base64EncodingToken); } private async Task DownloadRepositoryArchive(IExecutionContext executionContext, string downloadUrl, string downloadAuthToken, string archiveFile) { Trace.Info($"Save archive '{downloadUrl}' into {archiveFile}."); int retryCount = 0; // Allow up to 20 * 60s for any action to be downloaded from github graph. int timeoutSeconds = 20 * 60; while (retryCount < 3) { string requestId = string.Empty; 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(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true)) using (var httpClientHandler = HostContext.CreateHttpClientHandler()) using (var httpClient = new HttpClient(httpClientHandler)) { httpClient.DefaultRequestHeaders.Authorization = CreateAuthHeader(downloadAuthToken); httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents); using (var response = await httpClient.GetAsync(downloadUrl)) { requestId = UrlUtil.GetGitHubRequestId(response.Headers); if (!string.IsNullOrEmpty(requestId)) { Trace.Info($"Request URL: {downloadUrl} X-GitHub-Request-Id: {requestId} Http Status: {response.StatusCode}"); } if (response.IsSuccessStatusCode) { using (var result = await response.Content.ReadAsStreamAsync()) { await result.CopyToAsync(fs, _defaultCopyBufferSize, actionDownloadCancellation.Token); await fs.FlushAsync(actionDownloadCancellation.Token); // download succeed, break out the retry loop. break; } } else if (response.StatusCode == HttpStatusCode.NotFound) { // It doesn't make sense to retry in this case, so just stop throw new ActionNotFoundException(new Uri(downloadUrl), requestId); } else { // Something else bad happened, let's go to our retry logic response.EnsureSuccessStatusCode(); } } } } catch (OperationCanceledException) when (executionContext.CancellationToken.IsCancellationRequested) { Trace.Info("Action download has been cancelled."); throw; } catch (OperationCanceledException ex) when (!executionContext.CancellationToken.IsCancellationRequested && retryCount >= 2) { Trace.Info($"Action download final retry timeout after {timeoutSeconds} seconds."); throw new TimeoutException($"Action '{downloadUrl}' download has timed out. Error: {ex.Message} {requestId}"); } catch (ActionNotFoundException) { Trace.Info($"The action at '{downloadUrl}' does not exist"); throw; } catch (Exception ex) when (retryCount < 2) { retryCount++; Trace.Error($"Fail to download archive '{downloadUrl}' -- Attempt: {retryCount}"); Trace.Error(ex); if (actionDownloadTimeout.Token.IsCancellationRequested) { // action download didn't finish within timeout executionContext.Warning($"Action '{downloadUrl}' didn't finish download within {timeoutSeconds} seconds. {requestId}"); } else { executionContext.Warning($"Failed to download action '{downloadUrl}'. Error: {ex.Message} {requestId}"); } } } 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 '{downloadUrl}' to '{archiveFile}'"); } } 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 Deprecated { get; set; } } public enum ActionExecutionType { Container, NodeJS, Plugin, Script, Composite, } public sealed class ContainerActionExecutionData : ActionExecutionData { public override ActionExecutionType ExecutionType => ActionExecutionType.Container; public override bool HasPre => !string.IsNullOrEmpty(Pre); public override bool HasPost => !string.IsNullOrEmpty(Post); public string Image { get; set; } public string EntryPoint { get; set; } public SequenceToken Arguments { get; set; } public MappingToken Environment { get; set; } public string Pre { get; set; } public string Post { get; set; } } public sealed class NodeJSActionExecutionData : ActionExecutionData { public override ActionExecutionType ExecutionType => ActionExecutionType.NodeJS; public override bool HasPre => !string.IsNullOrEmpty(Pre); public override bool HasPost => !string.IsNullOrEmpty(Post); public string Script { get; set; } public string Pre { get; set; } public string Post { get; set; } public string NodeVersion { get; set; } } public sealed class PluginActionExecutionData : ActionExecutionData { public override ActionExecutionType ExecutionType => ActionExecutionType.Plugin; public override bool HasPre => false; public override bool HasPost => !string.IsNullOrEmpty(Post); public string Plugin { get; set; } public string Post { get; set; } } public sealed class ScriptActionExecutionData : ActionExecutionData { public override ActionExecutionType ExecutionType => ActionExecutionType.Script; public override bool HasPre => false; public override bool HasPost => false; } public sealed class CompositeActionExecutionData : ActionExecutionData { public override ActionExecutionType ExecutionType => ActionExecutionType.Composite; public override bool HasPre => PreSteps.Count > 0; public override bool HasPost => PostSteps.Count > 0; public List PreSteps { get; set; } public List Steps { get; set; } public Stack PostSteps { get; set; } public MappingToken Outputs { get; set; } } public abstract class ActionExecutionData { private string _initCondition = $"{Constants.Expressions.Always}()"; private string _cleanupCondition = $"{Constants.Expressions.Always}()"; public abstract ActionExecutionType ExecutionType { get; } public abstract bool HasPre { get; } public abstract bool HasPost { get; } public string CleanupCondition { get { return _cleanupCondition; } set { _cleanupCondition = value; } } public string InitCondition { get { return _initCondition; } set { _initCondition = value; } } } public class ContainerSetupInfo { public ContainerSetupInfo(List ids, string image) { StepIds = ids; Container = new ActionContainer() { Image = image }; } public ContainerSetupInfo(List ids, string dockerfile, string workingDirectory) { StepIds = ids; Container = new ActionContainer() { Dockerfile = dockerfile, WorkingDirectory = workingDirectory }; } public List 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; } } public class ActionSetupInfo { public ActionContainer Container { get; set; } public List Steps { get; set; } } public class PrepareActionsState { public Dictionary> ImagesToPull; public Dictionary> ImagesToBuild; public Dictionary ImagesToBuildInfo; public Dictionary PreStepTracker; } }