From 22d1938ac420a4cb9e3255e47a91c2e43c38db29 Mon Sep 17 00:00:00 2001 From: John Hernley Date: Wed, 3 May 2023 16:04:21 -0400 Subject: [PATCH] Resolve Actions Directly From Launch for Run Service Jobs (#2529) Co-authored-by: Tingluo Huang --- src/Runner.Common/Constants.cs | 1 + src/Runner.Common/LaunchServer.cs | 42 ++++++++ src/Runner.Worker/ActionManager.cs | 11 ++- src/Runner.Worker/ExecutionContext.cs | 3 + src/Runner.Worker/JobRunner.cs | 12 +++ src/Sdk/Sdk.csproj | 2 +- src/Sdk/WebApi/WebApi/LaunchContracts.cs | 70 +++++++++++++ src/Sdk/WebApi/WebApi/LaunchHttpClient.cs | 115 ++++++++++++++++++++++ src/Test/L0/Worker/ActionManagerL0.cs | 21 ++++ 9 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/Runner.Common/LaunchServer.cs create mode 100644 src/Sdk/WebApi/WebApi/LaunchContracts.cs create mode 100644 src/Sdk/WebApi/WebApi/LaunchHttpClient.cs diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index ceddd42116f..d14e3e88d05 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -261,6 +261,7 @@ public static class System public static readonly string AccessToken = "system.accessToken"; public static readonly string Culture = "system.culture"; public static readonly string PhaseDisplayName = "system.phaseDisplayName"; + public static readonly string JobRequestType = "system.jobRequestType"; public static readonly string OrchestrationId = "system.orchestrationId"; } } diff --git a/src/Runner.Common/LaunchServer.cs b/src/Runner.Common/LaunchServer.cs new file mode 100644 index 00000000000..338e5b88488 --- /dev/null +++ b/src/Runner.Common/LaunchServer.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.WebApi; +using GitHub.Services.Launch.Client; +using GitHub.Services.WebApi; + +namespace GitHub.Runner.Common +{ + [ServiceLocator(Default = typeof(LaunchServer))] + public interface ILaunchServer : IRunnerService + { + void InitializeLaunchClient(Uri uri, string token); + + Task ResolveActionsDownloadInfoAsync(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, CancellationToken cancellationToken); + } + + public sealed class LaunchServer : RunnerService, ILaunchServer + { + private LaunchHttpClient _launchClient; + + public void InitializeLaunchClient(Uri uri, string token) + { + var httpMessageHandler = HostContext.CreateHttpClientHandler(); + this._launchClient = new LaunchHttpClient(uri, httpMessageHandler, token, disposeHandler: true); + } + + public Task ResolveActionsDownloadInfoAsync(Guid planId, Guid jobId, ActionReferenceList actionReferenceList, + CancellationToken cancellationToken) + { + if (_launchClient != null) + { + return _launchClient.GetResolveActionsDownloadInfoAsync(planId, jobId, actionReferenceList, + cancellationToken: cancellationToken); + } + + throw new InvalidOperationException("Launch client is not initialized."); + } + } +} diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 49e14e68ae3..ad39efb917e 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using GitHub.DistributedTask.ObjectTemplating.Tokens; using GitHub.Runner.Common; +using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; using GitHub.Runner.Worker.Container; using GitHub.Services.Common; @@ -648,13 +649,21 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, } // Resolve download info + var launchServer = HostContext.GetService(); var jobServer = HostContext.GetService(); var actionDownloadInfos = default(WebApi.ActionDownloadInfoCollection); for (var attempt = 1; attempt <= 3; attempt++) { try { - 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); + 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. diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 660883d73e0..a5df0379f7c 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -757,6 +757,9 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation // File table Global.FileTable = new List(message.FileTable ?? new string[0]); + // What type of job request is running (i.e. Run Service vs. pipelines) + Global.Variables.Set(Constants.Variables.System.JobRequestType, message.MessageType); + // Expression values if (message.ContextData?.Count > 0) { diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index f2edb873d8b..e2803dc1c9c 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -58,6 +58,18 @@ public async Task RunAsync(AgentJobRequestMessage message, Cancellat await runServer.ConnectAsync(systemConnection.Url, jobServerCredential); server = runServer; + message.Variables.TryGetValue("system.github.launch_endpoint", out VariableValue launchEndpointVariable); + var launchReceiverEndpoint = launchEndpointVariable?.Value; + + if (systemConnection?.Authorization != null && + systemConnection.Authorization.Parameters.TryGetValue("AccessToken", out var accessToken) && + !string.IsNullOrEmpty(accessToken) && + !string.IsNullOrEmpty(launchReceiverEndpoint)) + { + Trace.Info("Initializing launch client"); + var launchServer = HostContext.GetService(); + launchServer.InitializeLaunchClient(new Uri(launchReceiverEndpoint), accessToken); + } _jobServerQueue = HostContext.GetService(); _jobServerQueue.Start(message, resultServiceOnly: true); } diff --git a/src/Sdk/Sdk.csproj b/src/Sdk/Sdk.csproj index 2bb85af37f7..9e73caa8b9c 100644 --- a/src/Sdk/Sdk.csproj +++ b/src/Sdk/Sdk.csproj @@ -8,7 +8,7 @@ NU1701;NU1603 $(Version) TRACE - 7.3 + 8.0 true diff --git a/src/Sdk/WebApi/WebApi/LaunchContracts.cs b/src/Sdk/WebApi/WebApi/LaunchContracts.cs new file mode 100644 index 00000000000..0815a116bf3 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/LaunchContracts.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace GitHub.Services.Launch.Contracts +{ + [DataContract] + public class ActionReferenceRequest + { + [DataMember(EmitDefaultValue = false, Name = "action")] + public string Action { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "version")] + public string Version { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "path")] + public string Path { get; set; } + } + + [DataContract] + public class ActionReferenceRequestList + { + [DataMember(EmitDefaultValue = false, Name = "actions")] + public IList Actions { get; set; } + } + + [DataContract] + public class ActionDownloadInfoResponse + { + [DataMember(EmitDefaultValue = false, Name = "authentication")] + public ActionDownloadAuthenticationResponse Authentication { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "name")] + public string Name { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "resolved_name")] + public string ResolvedName { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "resolved_sha")] + public string ResolvedSha { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "tar_url")] + public string TarUrl { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "version")] + public string Version { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "zip_url")] + public string ZipUrl { get; set; } + } + + [DataContract] + public class ActionDownloadAuthenticationResponse + { + [DataMember(EmitDefaultValue = false, Name = "expires_at")] + public DateTime ExpiresAt { get; set; } + + [DataMember(EmitDefaultValue = false, Name = "token")] + public string Token { get; set; } + } + + [DataContract] + public class ActionDownloadInfoResponseCollection + { + /// A mapping of action specifications to their download information. + /// The key is the full name of the action plus version, e.g. "actions/checkout@v2". + [DataMember(EmitDefaultValue = false, Name = "actions")] + public IDictionary Actions { get; set; } + } +} diff --git a/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs b/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs new file mode 100644 index 00000000000..54a718b3938 --- /dev/null +++ b/src/Sdk/WebApi/WebApi/LaunchHttpClient.cs @@ -0,0 +1,115 @@ +#nullable enable + +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Formatting; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +using GitHub.DistributedTask.WebApi; +using GitHub.Services.Launch.Contracts; + +using Sdk.WebApi.WebApi; + +namespace GitHub.Services.Launch.Client +{ + public class LaunchHttpClient : RawHttpClientBase + { + public LaunchHttpClient( + Uri baseUrl, + HttpMessageHandler pipeline, + string token, + bool disposeHandler) + : base(baseUrl, pipeline, disposeHandler) + { + m_token = token; + m_launchServiceUrl = baseUrl; + m_formatter = new JsonMediaTypeFormatter(); + } + + 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)); + } + + // Resolve Actions + private async Task GetLaunchSignedURLResponse(Uri uri, R request, CancellationToken cancellationToken) + { + using (HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, uri)) + { + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", m_token); + requestMessage.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + + 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); + } + } + } + } + + private static ActionReferenceRequestList ToGitHubData(ActionReferenceList actionReferenceList) + { + return new ActionReferenceRequestList + { + Actions = actionReferenceList.Actions?.Select(ToGitHubData).ToList() + }; + } + + private static ActionReferenceRequest ToGitHubData(ActionReference actionReference) + { + return new ActionReferenceRequest + { + Action = actionReference.NameWithOwner, + Version = actionReference.Ref, + Path = actionReference.Path + }; + } + + private static ActionDownloadInfoCollection ToServerData(ActionDownloadInfoResponseCollection actionDownloadInfoResponseCollection) + { + return new ActionDownloadInfoCollection + { + Actions = actionDownloadInfoResponseCollection.Actions?.ToDictionary(kvp => kvp.Key, kvp => ToServerData(kvp.Value)) + }; + } + + private static ActionDownloadInfo ToServerData(ActionDownloadInfoResponse actionDownloadInfoResponse) + { + return new ActionDownloadInfo + { + Authentication = ToServerData(actionDownloadInfoResponse.Authentication), + NameWithOwner = actionDownloadInfoResponse.Name, + ResolvedNameWithOwner = actionDownloadInfoResponse.ResolvedName, + ResolvedSha = actionDownloadInfoResponse.ResolvedSha, + TarballUrl = actionDownloadInfoResponse.TarUrl, + Ref = actionDownloadInfoResponse.Version, + ZipballUrl = actionDownloadInfoResponse.ZipUrl, + }; + } + + private static ActionDownloadAuthentication? ToServerData(ActionDownloadAuthenticationResponse? actionDownloadAuthenticationResponse) + { + if (actionDownloadAuthenticationResponse == null) + { + return null; + } + + return new ActionDownloadAuthentication + { + ExpiresAt = actionDownloadAuthenticationResponse.ExpiresAt, + Token = actionDownloadAuthenticationResponse.Token + }; + } + + private MediaTypeFormatter m_formatter; + private Uri m_launchServiceUrl; + private string m_token; + } +} diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 07cf9b48700..7a1a3d7a30d 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -29,6 +29,7 @@ public sealed class ActionManagerL0 private Mock _dockerManager; private Mock _ec; private Mock _jobServer; + private Mock _launchServer; private Mock _pluginManager; private TestHostContext _hc; private ActionManager _actionManager; @@ -2175,6 +2176,25 @@ private void Setup([CallerMemberName] string name = "", bool enableComposite = t return Task.FromResult(result); }); + _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) => + { + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + _pluginManager = new Mock(); _pluginManager.Setup(x => x.GetPluginAction(It.IsAny())).Returns(new RunnerPluginActionInfo() { PluginTypeName = "plugin.class, plugin", PostPluginTypeName = "plugin.cleanup, plugin" }); @@ -2183,6 +2203,7 @@ private void Setup([CallerMemberName] string name = "", bool enableComposite = t _hc.SetSingleton(_dockerManager.Object); _hc.SetSingleton(_jobServer.Object); + _hc.SetSingleton(_launchServer.Object); _hc.SetSingleton(_pluginManager.Object); _hc.SetSingleton(actionManifest); _hc.SetSingleton(new HttpClientHandlerFactory());