diff --git a/src/Runner.Common/ConfigurationStore.cs b/src/Runner.Common/ConfigurationStore.cs index 7daa4d66fdd..49c4229daf4 100644 --- a/src/Runner.Common/ConfigurationStore.cs +++ b/src/Runner.Common/ConfigurationStore.cs @@ -50,6 +50,9 @@ public sealed class RunnerSettings [DataMember(EmitDefaultValue = false)] public string MonitorSocketAddress { get; set; } + [DataMember(EmitDefaultValue = false)] + public bool UseV2Flow { get; set; } + [IgnoreDataMember] public bool IsHostedServer { diff --git a/src/Runner.Common/RunnerDotcomServer.cs b/src/Runner.Common/RunnerDotcomServer.cs new file mode 100644 index 00000000000..510877e2e17 --- /dev/null +++ b/src/Runner.Common/RunnerDotcomServer.cs @@ -0,0 +1,237 @@ +using GitHub.DistributedTask.WebApi; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Services.WebApi; +using GitHub.Services.Common; +using GitHub.Runner.Sdk; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Linq; + +namespace GitHub.Runner.Common +{ + [ServiceLocator(Default = typeof(RunnerDotcomServer))] + public interface IRunnerDotcomServer : IRunnerService + { + Task> GetRunnersAsync(int runnerGroupId, string githubUrl, string githubToken, string agentName); + + Task AddRunnerAsync(int runnerGroupId, TaskAgent agent, string githubUrl, string githubToken); + Task> GetRunnerGroupsAsync(string githubUrl, string githubToken); + + string GetGitHubRequestId(HttpResponseHeaders headers); + } + + public enum RequestType + { + Get, + Post, + Patch, + Delete + } + + public class RunnerDotcomServer : RunnerService, IRunnerDotcomServer + { + private ITerminal _term; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _term = hostContext.GetService(); + } + + + public async Task> GetRunnersAsync(int runnerGroupId, string githubUrl, string githubToken, string agentName = null) + { + var githubApiUrl = ""; + var gitHubUrlBuilder = new UriBuilder(githubUrl); + var path = gitHubUrlBuilder.Path.Split('/', '\\', StringSplitOptions.RemoveEmptyEntries); + if (path.Length == 1) + { + // org runner + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/orgs/{path[0]}/actions/runner-groups/{runnerGroupId}/runners"; + } + else + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://{gitHubUrlBuilder.Host}/api/v3/orgs/{path[0]}/actions/runner-groups/{runnerGroupId}/runners"; + } + } + else if (path.Length == 2) + { + // repo or enterprise runner. + if (!string.Equals(path[0], "enterprises", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/{path[0]}/{path[1]}/actions/runner-groups/{runnerGroupId}/runners"; + } + else + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://{gitHubUrlBuilder.Host}/api/v3/{path[0]}/{path[1]}/actions/runner-groups/{runnerGroupId}/runners"; + } + } + else + { + throw new ArgumentException($"'{githubUrl}' should point to an org or enterprise."); + } + + var runnersList = await RetryRequest(githubApiUrl, githubToken, RequestType.Get, 3, "Failed to get agents pools"); + var agents = runnersList.ToTaskAgents(); + + if (string.IsNullOrEmpty(agentName)) + { + return agents; + } + + return agents.Where(x => string.Equals(x.Name, agentName, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + public async Task> GetRunnerGroupsAsync(string githubUrl, string githubToken) + { + var githubApiUrl = ""; + var gitHubUrlBuilder = new UriBuilder(githubUrl); + var path = gitHubUrlBuilder.Path.Split('/', '\\', StringSplitOptions.RemoveEmptyEntries); + if (path.Length == 1) + { + // org runner + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/orgs/{path[0]}/actions/runner-groups"; + } + else + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://{gitHubUrlBuilder.Host}/api/v3/orgs/{path[0]}/actions/runner-groups"; + } + } + else if (path.Length == 2) + { + // repo or enterprise runner. + if (!string.Equals(path[0], "enterprises", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/{path[0]}/{path[1]}/actions/runner-groups"; + } + else + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://{gitHubUrlBuilder.Host}/api/v3/{path[0]}/{path[1]}/actions/runner-groups"; + } + } + else + { + throw new ArgumentException($"'{githubUrl}' should point to an org or enterprise."); + } + + var agentPools = await RetryRequest(githubApiUrl, githubToken, RequestType.Get, 3, "Failed to get agents pools"); + + return agentPools?.ToAgentPoolList(); + } + + public async Task AddRunnerAsync(int runnerGroupId, TaskAgent agent, string githubUrl, string githubToken) + { + var gitHubUrlBuilder = new UriBuilder(githubUrl); + var path = gitHubUrlBuilder.Path.Split('/', '\\', StringSplitOptions.RemoveEmptyEntries); + string githubApiUrl; + if (UrlUtil.IsHostedServer(gitHubUrlBuilder)) + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://api.{gitHubUrlBuilder.Host}/actions/runners/register"; + } + else + { + githubApiUrl = $"{gitHubUrlBuilder.Scheme}://{gitHubUrlBuilder.Host}/api/v3/actions/runners/register"; + } + + var bodyObject = new Dictionary() + { + {"url", githubUrl}, + {"group_id", runnerGroupId}, + {"name", agent.Name}, + {"version", agent.Version}, + {"updates_disabled", agent.DisableUpdate}, + {"ephemeral", agent.Ephemeral}, + {"labels", agent.Labels}, + }; + + var body = new StringContent(StringUtil.ConvertToJson(bodyObject), null, "application/json"); + var responseAgent = await RetryRequest(githubApiUrl, githubToken, RequestType.Post, 3, "Failed to add agent", body); + agent.Id = responseAgent.Id; + return agent; + } + + private async Task RetryRequest(string githubApiUrl, string githubToken, RequestType requestType, int maxRetryAttemptsCount = 5, string errorMessage = null, StringContent body = null) + { + int retry = 0; + while (true) + { + retry++; + using (var httpClientHandler = HostContext.CreateHttpClientHandler()) + using (var httpClient = new HttpClient(httpClientHandler)) + { + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("RemoteAuth", githubToken); + httpClient.DefaultRequestHeaders.UserAgent.AddRange(HostContext.UserAgents); + + var responseStatus = System.Net.HttpStatusCode.OK; + try + { + HttpResponseMessage response = null; + if (requestType == RequestType.Get) + { + response = await httpClient.GetAsync(githubApiUrl); + } + else + { + response = await httpClient.PostAsync(githubApiUrl, body); + } + + if (response != null) + { + responseStatus = response.StatusCode; + var githubRequestId = GetGitHubRequestId(response.Headers); + + if (response.IsSuccessStatusCode) + { + Trace.Info($"Http response code: {response.StatusCode} from '{requestType.ToString()} {githubApiUrl}' ({githubRequestId})"); + var jsonResponse = await response.Content.ReadAsStringAsync(); + return StringUtil.ConvertFromJson(jsonResponse); + } + else + { + _term.WriteError($"Http response code: {response.StatusCode} from '{requestType.ToString()} {githubApiUrl}' (Request Id: {githubRequestId})"); + var errorResponse = await response.Content.ReadAsStringAsync(); + _term.WriteError(errorResponse); + response.EnsureSuccessStatusCode(); + } + } + + } + catch (Exception ex) when (retry < maxRetryAttemptsCount && responseStatus != System.Net.HttpStatusCode.NotFound) + { + Trace.Error($"{errorMessage} -- Atempt: {retry}"); + Trace.Error(ex); + } + } + var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5)); + Trace.Info($"Retrying in {backOff.Seconds} seconds"); + await Task.Delay(backOff); + } + } + + public string GetGitHubRequestId(HttpResponseHeaders headers) + { + if (headers.TryGetValues("x-github-request-id", out var headerValues)) + { + return headerValues.FirstOrDefault(); + } + return string.Empty; + } + } +} diff --git a/src/Runner.Listener/Configuration/ConfigurationManager.cs b/src/Runner.Listener/Configuration/ConfigurationManager.cs index 392eb0e3c3b..492bea04213 100644 --- a/src/Runner.Listener/Configuration/ConfigurationManager.cs +++ b/src/Runner.Listener/Configuration/ConfigurationManager.cs @@ -31,12 +31,14 @@ public sealed class ConfigurationManager : RunnerService, IConfigurationManager { private IConfigurationStore _store; private IRunnerServer _runnerServer; + private IRunnerDotcomServer _dotcomServer; private ITerminal _term; public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); _runnerServer = HostContext.GetService(); + _dotcomServer = HostContext.GetService(); Trace.Verbose("Creating _store"); _store = hostContext.GetService(); Trace.Verbose("store created"); @@ -113,6 +115,7 @@ public async Task ConfigureAsync(CommandSettings command) ICredentialProvider credProvider = null; VssCredentials creds = null; _term.WriteSection("Authentication"); + string registerToken = string.Empty; while (true) { // When testing against a dev deployment of Actions Service, set this environment variable @@ -130,9 +133,11 @@ public async Task ConfigureAsync(CommandSettings command) else { runnerSettings.GitHubUrl = inputUrl; - var registerToken = await GetRunnerTokenAsync(command, inputUrl, "registration"); + registerToken = await GetRunnerTokenAsync(command, inputUrl, "registration"); GitHubAuthResult authResult = await GetTenantCredential(inputUrl, registerToken, Constants.RunnerEvent.Register); runnerSettings.ServerUrl = authResult.TenantUrl; + runnerSettings.UseV2Flow = authResult.UseV2Flow; + _term.WriteLine($"Using V2 flow: {runnerSettings.UseV2Flow}"); creds = authResult.ToVssCredentials(); Trace.Info("cred retrieved via GitHub auth"); } @@ -186,9 +191,17 @@ public async Task ConfigureAsync(CommandSettings command) // If we have more than one runner group available, allow the user to specify which one to be added into string poolName = null; TaskAgentPool agentPool = null; - List agentPools = await _runnerServer.GetAgentPoolsAsync(); - TaskAgentPool defaultPool = agentPools?.Where(x => x.IsInternal).FirstOrDefault(); + List agentPools; + if (runnerSettings.UseV2Flow) + { + agentPools = await _dotcomServer.GetRunnerGroupsAsync(runnerSettings.GitHubUrl, registerToken); + } + else + { + agentPools = await _runnerServer.GetAgentPoolsAsync(); + } + TaskAgentPool defaultPool = agentPools?.Where(x => x.IsInternal).FirstOrDefault(); if (agentPools?.Where(x => !x.IsHosted).Count() > 0) { poolName = command.GetRunnerGroupName(defaultPool?.Name); @@ -226,8 +239,16 @@ public async Task ConfigureAsync(CommandSettings command) var userLabels = command.GetLabels(); _term.WriteLine(); + List agents; + if (runnerSettings.UseV2Flow) + { + agents = await _dotcomServer.GetRunnersAsync(runnerSettings.PoolId, runnerSettings.GitHubUrl, registerToken, runnerSettings.AgentName); + } + else + { + agents = await _runnerServer.GetAgentsAsync(runnerSettings.PoolId, runnerSettings.AgentName); + } - var agents = await _runnerServer.GetAgentsAsync(runnerSettings.PoolId, runnerSettings.AgentName); Trace.Verbose("Returns {0} agents", agents.Count); agent = agents.FirstOrDefault(); if (agent != null) @@ -274,7 +295,15 @@ public async Task ConfigureAsync(CommandSettings command) try { - agent = await _runnerServer.AddAgentAsync(runnerSettings.PoolId, agent); + if (runnerSettings.UseV2Flow) + { + agent = await _dotcomServer.AddRunnerAsync(runnerSettings.PoolId, agent, runnerSettings.GitHubUrl, registerToken); + } + else + { + agent = await _runnerServer.AddAgentAsync(runnerSettings.PoolId, agent); + } + if (command.DisableUpdate && command.DisableUpdate != agent.DisableUpdate) { @@ -652,7 +681,7 @@ private async Task GetJITRunnerTokenAsync(string gith { var response = await httpClient.PostAsync(githubApiUrl, new StringContent(string.Empty)); responseStatus = response.StatusCode; - var githubRequestId = GetGitHubRequestId(response.Headers); + var githubRequestId = _dotcomServer.GetGitHubRequestId(response.Headers); if (response.IsSuccessStatusCode) { @@ -715,7 +744,7 @@ private async Task GetTenantCredential(string githubUrl, strin { var response = await httpClient.PostAsync(githubApiUrl, new StringContent(StringUtil.ConvertToJson(bodyObject), null, "application/json")); responseStatus = response.StatusCode; - var githubRequestId = GetGitHubRequestId(response.Headers); + var githubRequestId = _dotcomServer.GetGitHubRequestId(response.Headers); if (response.IsSuccessStatusCode) { @@ -744,14 +773,5 @@ private async Task GetTenantCredential(string githubUrl, strin } return null; } - - private string GetGitHubRequestId(HttpResponseHeaders headers) - { - if (headers.TryGetValues("x-github-request-id", out var headerValues)) - { - return headerValues.FirstOrDefault(); - } - return string.Empty; - } } } diff --git a/src/Runner.Listener/Configuration/CredentialManager.cs b/src/Runner.Listener/Configuration/CredentialManager.cs index 327961794e5..80cff904cf5 100644 --- a/src/Runner.Listener/Configuration/CredentialManager.cs +++ b/src/Runner.Listener/Configuration/CredentialManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.Serialization; using GitHub.Runner.Common; @@ -20,8 +20,8 @@ public class CredentialManager : RunnerService, ICredentialManager { public static readonly Dictionary CredentialTypes = new(StringComparer.OrdinalIgnoreCase) { - { Constants.Configuration.OAuth, typeof(OAuthCredential)}, - { Constants.Configuration.OAuthAccessToken, typeof(OAuthAccessTokenCredential)}, + { Constants.Configuration.OAuth, typeof(OAuthCredential) }, + { Constants.Configuration.OAuthAccessToken, typeof(OAuthAccessTokenCredential) }, }; public ICredentialProvider GetCredentialProvider(string credType) @@ -93,6 +93,9 @@ public sealed class GitHubAuthResult [DataMember(Name = "token")] public string Token { get; set; } + [DataMember(Name = "use_v2_flow")] + public bool UseV2Flow { get; set; } + public VssCredentials ToVssCredentials() { ArgUtil.NotNullOrEmpty(TokenSchema, nameof(TokenSchema)); diff --git a/src/Sdk/DTWebApi/WebApi/ListRunnersResponse.cs b/src/Sdk/DTWebApi/WebApi/ListRunnersResponse.cs new file mode 100644 index 00000000000..292b8d66801 --- /dev/null +++ b/src/Sdk/DTWebApi/WebApi/ListRunnersResponse.cs @@ -0,0 +1,50 @@ +using GitHub.Services.WebApi; +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using System.Linq; + +namespace GitHub.DistributedTask.WebApi +{ + + public class ListRunnersResponse + { + public ListRunnersResponse() + { + } + + public ListRunnersResponse(ListRunnersResponse responseToBeCloned) + { + this.TotalCount = responseToBeCloned.TotalCount; + this.Runners = responseToBeCloned.Runners; + } + + [JsonProperty("total_count")] + public int TotalCount + { + get; + set; + } + + [JsonProperty("runners")] + public List Runners + { + get; + set; + } + + public ListRunnersResponse Clone() + { + return new ListRunnersResponse(this); + } + + public List ToTaskAgents() + { + List taskAgents = new List(); + + return Runners.Select(runner => new TaskAgent() { Name = runner.Name }).ToList(); + } + } + +} diff --git a/src/Sdk/DTWebApi/WebApi/Runner.cs b/src/Sdk/DTWebApi/WebApi/Runner.cs new file mode 100644 index 00000000000..2103bf3a210 --- /dev/null +++ b/src/Sdk/DTWebApi/WebApi/Runner.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace GitHub.DistributedTask.WebApi +{ + public class Runner + { + /// + /// Name of the agent + /// + [JsonProperty("name")] + public string Name + { + get; + internal set; + } + + } +} diff --git a/src/Sdk/DTWebApi/WebApi/RunnerGroup.cs b/src/Sdk/DTWebApi/WebApi/RunnerGroup.cs new file mode 100644 index 00000000000..e1171bb663b --- /dev/null +++ b/src/Sdk/DTWebApi/WebApi/RunnerGroup.cs @@ -0,0 +1,98 @@ +using GitHub.Services.WebApi; +using System; +using System.Runtime.Serialization; +using System.ComponentModel; +using System.Collections.Generic; +using Newtonsoft.Json; +using System.Linq; + +namespace GitHub.DistributedTask.WebApi +{ + /// + /// An organization-level grouping of runners. + /// + [DataContract] + public class RunnerGroup + { + internal RunnerGroup() + { + } + + public RunnerGroup(String name) + { + this.Name = name; + } + + private RunnerGroup(RunnerGroup poolToBeCloned) + { + this.Id = poolToBeCloned.Id; + this.IsHosted = poolToBeCloned.IsHosted; + this.Name = poolToBeCloned.Name; + this.IsDefault = poolToBeCloned.IsDefault; + } + + [DataMember(EmitDefaultValue = false)] + [JsonProperty("id")] + public Int32 Id + { + get; + set; + } + + [DataMember(EmitDefaultValue = false)] + [JsonProperty("name")] + public String Name + { + get; + set; + } + + /// + /// Gets or sets a value indicating whether or not this pool is internal and can't be modified by users + /// + [DataMember] + [JsonProperty("default")] + public bool IsDefault + { + get; + set; + } + + /// + /// Gets or sets a value indicating whether or not this pool is managed by the service. + /// + [DataMember] + [JsonProperty("is_hosted")] + public Boolean IsHosted + { + get; + set; + } + } + + public class RunnerGroupList + { + public RunnerGroupList() + { + this.RunnerGroups = new List(); + } + + public List ToAgentPoolList() + { + var agentPools = this.RunnerGroups.Select(x => new TaskAgentPool(x.Name) + { + Id = x.Id, + IsHosted = x.IsHosted, + IsInternal = x.IsDefault + }).ToList(); + + return agentPools; + } + + [JsonProperty("runner_groups")] + public List RunnerGroups { get; set; } + + [JsonProperty("total_count")] + public int Count { get; set; } + } +} diff --git a/src/Test/L0/Listener/Configuration/ConfigurationManagerL0.cs b/src/Test/L0/Listener/Configuration/ConfigurationManagerL0.cs index ca6e90b6bf7..19bf0250c79 100644 --- a/src/Test/L0/Listener/Configuration/ConfigurationManagerL0.cs +++ b/src/Test/L0/Listener/Configuration/ConfigurationManagerL0.cs @@ -19,6 +19,7 @@ namespace GitHub.Runner.Common.Tests.Listener.Configuration public class ConfigurationManagerL0 { private Mock _runnerServer; + private Mock _dotcomServer; private Mock _locationServer; private Mock _credMgr; private Mock _promptManager; @@ -55,6 +56,7 @@ public ConfigurationManagerL0() _store = new Mock(); _extnMgr = new Mock(); _rsaKeyManager = new Mock(); + _dotcomServer = new Mock(); #if OS_WINDOWS _serviceControlManager = new Mock(); @@ -106,6 +108,10 @@ public ConfigurationManagerL0() _runnerServer.Setup(x => x.AddAgentAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult(expectedAgent)); _runnerServer.Setup(x => x.ReplaceAgentAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult(expectedAgent)); + _dotcomServer.Setup(x => x.GetRunnersAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(expectedAgents)); + _dotcomServer.Setup(x => x.GetRunnerGroupsAsync(It.IsAny(), It.IsAny())).Returns(Task.FromResult(expectedPools)); + _dotcomServer.Setup(x => x.AddRunnerAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(expectedAgent)); + rsa = new RSACryptoServiceProvider(2048); _rsaKeyManager.Setup(x => x.CreateKey()).Returns(rsa); @@ -119,6 +125,7 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " tc.SetSingleton(_store.Object); tc.SetSingleton(_extnMgr.Object); tc.SetSingleton(_runnerServer.Object); + tc.SetSingleton(_dotcomServer.Object); tc.SetSingleton(_locationServer.Object); #if OS_WINDOWS