diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 515031424..03d01b628 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -168,6 +168,7 @@ namespace GitHub.Runner.Common public static readonly string UseContainerPathForTemplate = "DistributedTask.UseContainerPathForTemplate"; public static readonly string AllowRunnerContainerHooks = "DistributedTask.AllowRunnerContainerHooks"; public static readonly string AddCheckRunIdToJobContext = "actions_add_check_run_id_to_job_context"; + public static readonly string DisplayHelpfulActionsDownloadErrors = "actions_display_helpful_actions_download_errors"; } public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry"; diff --git a/src/Runner.Common/LaunchServer.cs b/src/Runner.Common/LaunchServer.cs index f8584ac53..6fb69833e 100644 --- a/src/Runner.Common/LaunchServer.cs +++ b/src/Runner.Common/LaunchServer.cs @@ -15,7 +15,7 @@ namespace GitHub.Runner.Common { void InitializeLaunchClient(Uri uri, string token); - Task ResolveActionsDownloadInfoAsync(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, CancellationToken cancellationToken); + Task ResolveActionsDownloadInfoAsync(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, CancellationToken cancellationToken, bool displayHelpfulActionsDownloadErrors); } public sealed class LaunchServer : RunnerService, ILaunchServer @@ -42,12 +42,16 @@ namespace GitHub.Runner.Common } public Task ResolveActionsDownloadInfoAsync(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, - CancellationToken cancellationToken) + CancellationToken cancellationToken, bool displayHelpfulActionsDownloadErrors) { if (_launchClient != null) { - return _launchClient.GetResolveActionsDownloadInfoAsync(planId, jobId, actionReferenceList, - cancellationToken: cancellationToken); + if (!displayHelpfulActionsDownloadErrors) + { + return _launchClient.GetResolveActionsDownloadInfoAsync(planId, jobId, actionReferenceList, + cancellationToken: cancellationToken); + } + return _launchClient.GetResolveActionsDownloadInfoAsyncV2(planId, jobId, actionReferenceList, cancellationToken); } throw new InvalidOperationException("Launch client is not initialized."); diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 47a66dd12..9a21aeb4c 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -688,7 +688,8 @@ namespace GitHub.Runner.Worker { 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); + var displayHelpfulActionsDownloadErrors = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.DisplayHelpfulActionsDownloadErrors) ?? false; + actionDownloadInfos = await launchServer.ResolveActionsDownloadInfoAsync(executionContext.Global.Plan.PlanId, executionContext.Root.Id, new WebApi.ActionReferenceList { Actions = actionReferences }, executionContext.CancellationToken, displayHelpfulActionsDownloadErrors); } else { diff --git a/src/Sdk/WebApi/WebApi/LaunchContracts.cs b/src/Sdk/WebApi/WebApi/LaunchContracts.cs index 7b896fd75..28b6ce3cf 100644 --- a/src/Sdk/WebApi/WebApi/LaunchContracts.cs +++ b/src/Sdk/WebApi/WebApi/LaunchContracts.cs @@ -29,7 +29,7 @@ namespace GitHub.Services.Launch.Contracts { [DataMember(EmitDefaultValue = false, Name = "authentication")] public ActionDownloadAuthenticationResponse Authentication { get; set; } - + [DataMember(EmitDefaultValue = false, Name = "package_details")] public ActionDownloadPackageDetailsResponse PackageDetails { get; set; } @@ -64,7 +64,7 @@ namespace GitHub.Services.Launch.Contracts [DataContract] - public class ActionDownloadPackageDetailsResponse + public class ActionDownloadPackageDetailsResponse { [DataMember(EmitDefaultValue = false, Name = "version")] public string Version { get; set; } @@ -81,4 +81,25 @@ namespace GitHub.Services.Launch.Contracts [DataMember(EmitDefaultValue = false, Name = "actions")] public IDictionary Actions { get; set; } } + + [DataContract] + public class ActionDownloadResolutionError + { + /// + /// The error message associated with the action download error. + /// + [DataMember(EmitDefaultValue = false, Name = "message")] + public string Message { get; set; } + } + + [DataContract] + public class ActionDownloadResolutionErrorCollection + { + /// + /// A mapping of action specifications to their download errors. + /// The key is the full name of the action plus version, e.g. "actions/checkout@v2". + /// + [DataMember(EmitDefaultValue = false, Name = "errors")] + public IDictionary Errors { get; set; } + } } diff --git a/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs b/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs index 6ba06a6a0..24e398636 100644 --- a/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs @@ -2,6 +2,7 @@ using System; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Formatting; using System.Net.Http.Headers; @@ -32,11 +33,52 @@ namespace GitHub.Services.Launch.Client public async Task GetResolveActionsDownloadInfoAsync(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, CancellationToken cancellationToken) { var GetResolveActionsDownloadInfoURLEndpoint = new Uri(m_launchServiceUrl, $"/actions/build/{planId.ToString()}/jobs/{jobId.ToString()}/runnerresolve/actions"); - return ToServerData(await GetLaunchSignedURLResponse(GetResolveActionsDownloadInfoURLEndpoint, ToGitHubData(actionReferenceList), cancellationToken)); + var response = await GetLaunchSignedURLResponse(GetResolveActionsDownloadInfoURLEndpoint, ToGitHubData(actionReferenceList), cancellationToken); + return ToServerData(await ReadJsonContentAsync(response, cancellationToken)); } - // Resolve Actions - private async Task GetLaunchSignedURLResponse(Uri uri, R request, CancellationToken cancellationToken) + public async Task GetResolveActionsDownloadInfoAsyncV2(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, CancellationToken cancellationToken) + { + var GetResolveActionsDownloadInfoURLEndpoint = new Uri(m_launchServiceUrl, $"/actions/build/{planId.ToString()}/jobs/{jobId.ToString()}/runnerresolve/actions"); + var response = await GetLaunchSignedURLResponse(GetResolveActionsDownloadInfoURLEndpoint, ToGitHubData(actionReferenceList), cancellationToken); + + if (response.IsSuccessStatusCode) + { + // Success response - deserialize the action download info + return ToServerData(await ReadJsonContentAsync(response, cancellationToken)); + } + + var responseError = response.ReasonPhrase ?? ""; + if (response.StatusCode == HttpStatusCode.UnprocessableEntity) + { + // 422 response - unresolvable actions, error details are in the body + var errors = await ReadJsonContentAsync(response, cancellationToken); + string combinedErrorMessage; + if (errors?.Errors != null && errors.Errors.Any()) + { + combinedErrorMessage = String.Join(". ", errors.Errors.Select(kvp => kvp.Value.Message)); + } + else + { + combinedErrorMessage = responseError; + } + + throw new UnresolvableActionDownloadInfoException(combinedErrorMessage); + } + else if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + // Here we want to add a message so customers don't think it's a rate limit scoped to them + // Ideally this would be 500 but the runner retries 500s, which we don't want to do when we're being rate limited + // See: https://github.com/github/ecosystem-api/issues/4084 + throw new NonRetryableActionDownloadInfoException(responseError + " (GitHub has reached an internal rate limit, please try again later)"); + } + else + { + throw new Exception(responseError); + } + } + + private async Task GetLaunchSignedURLResponse(Uri uri, R request, CancellationToken cancellationToken) { using (HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, uri)) { @@ -46,10 +88,7 @@ namespace GitHub.Services.Launch.Client using (HttpContent content = new ObjectContent(request, m_formatter)) { requestMessage.Content = content; - using (var response = await SendAsync(requestMessage, HttpCompletionOption.ResponseContentRead, cancellationToken: cancellationToken)) - { - return await ReadJsonContentAsync(response, cancellationToken); - } + return await SendAsync(requestMessage, HttpCompletionOption.ResponseContentRead, cancellationToken: cancellationToken); } } } diff --git a/src/Test/L0/Sdk/LaunchWebApi/LaunchHttpClientL0.cs b/src/Test/L0/Sdk/LaunchWebApi/LaunchHttpClientL0.cs new file mode 100644 index 000000000..bda56141c --- /dev/null +++ b/src/Test/L0/Sdk/LaunchWebApi/LaunchHttpClientL0.cs @@ -0,0 +1,126 @@ +using GitHub.Actions.RunService.WebApi; +using GitHub.DistributedTask.WebApi; +using GitHub.Services.Launch.Client; +using GitHub.Services.Launch.Contracts; +using Moq; +using Moq.Protected; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace GitHub.Actions.RunService.WebApi.Tests +{ + public sealed class LaunchHttpClientL0 + { + [Fact] + public async Task GetResolveActionsDownloadInfoAsync_SuccessResponse() + { + var baseUrl = new Uri("https://api.github.com/"); + var planId = Guid.NewGuid(); + var jobId = Guid.NewGuid(); + var token = "fake-token"; + + var actionReferenceList = new ActionReferenceList + { + Actions = new List + { + new ActionReference + { + NameWithOwner = "owner1/action1", + Ref = "0123456789" + } + } + }; + + var responseContent = @"{ + ""actions"": { + ""owner1/action1@0123456789"": { + ""name"": ""owner1/action1"", + ""resolved_name"": ""owner1/action1"", + ""resolved_sha"": ""0123456789"", + ""version"": ""0123456789"", + ""zip_url"": ""https://github.com/owner1/action1/zip"", + ""tar_url"": ""https://github.com/owner1/action1/tar"" + } + } + }"; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json"), + RequestMessage = new HttpRequestMessage() + { + RequestUri = new Uri($"{baseUrl}actions/build/{planId}/jobs/{jobId}/runnerresolve/actions") + } + }; + + var mockHandler = new Mock(); + mockHandler.Protected().Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + var client = new LaunchHttpClient(baseUrl, mockHandler.Object, token, false); + var result = await client.GetResolveActionsDownloadInfoAsyncV2(planId, jobId, actionReferenceList, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result.Actions); + Assert.Equal(actionReferenceList.Actions.Count, result.Actions.Count); + Assert.True(result.Actions.ContainsKey("owner1/action1@0123456789")); + } + + [Fact] + public async Task GetResolveActionsDownloadInfoAsync_UnprocessableEntityResponse() + { + var baseUrl = new Uri("https://api.github.com/"); + var planId = Guid.NewGuid(); + var jobId = Guid.NewGuid(); + var token = "fake-token"; + + var actionReferenceList = new ActionReferenceList + { + Actions = new List + { + new ActionReference + { + NameWithOwner = "owner1/action1", + Ref = "0123456789" + } + } + }; + + var responseContent = @"{ + ""errors"": { + ""owner1/invalid-action@0123456789"": { + ""message"": ""Unable to resolve action 'owner1/invalid-action@0123456789', repository not found"" + } + } + }"; + + var httpResponse = new HttpResponseMessage(HttpStatusCode.UnprocessableEntity) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json"), + RequestMessage = new HttpRequestMessage() + { + RequestUri = new Uri($"{baseUrl}actions/build/{planId}/jobs/{jobId}/runnerresolve/actions") + } + }; + + var mockHandler = new Mock(); + mockHandler.Protected().Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + var client = new LaunchHttpClient(baseUrl, mockHandler.Object, token, false); + + var exception = await Assert.ThrowsAsync( + () => client.GetResolveActionsDownloadInfoAsyncV2(planId, jobId, actionReferenceList, CancellationToken.None)); + + Assert.Contains("repository not found", exception.Message); + } + } +} \ No newline at end of file diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 91f183ae2..50d5b99d1 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -2411,8 +2411,8 @@ runs: }); _launchServer = new Mock(); - _launchServer.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + _launchServer.Setup(x => x.ResolveActionsDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken, bool displayHelpfulActionsDownloadErrors) => { var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; foreach (var action in actions.Actions)