using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using GitHub.Runner.Common; using GitHub.Runner.Sdk; using GitHub.Services.Common; namespace GitHub.Runner.Listener { [ServiceLocator(Default = typeof(RunnerConfigUpdater))] public interface IRunnerConfigUpdater : IRunnerService { Task UpdateRunnerConfigAsync(string runnerQualifiedId, string configType, string serviceType, string configRefreshUrl); } public sealed class RunnerConfigUpdater : RunnerService, IRunnerConfigUpdater { private RunnerSettings _settings; private CredentialData _credData; private IRunnerServer _runnerServer; private IConfigurationStore _store; public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); _store = hostContext.GetService(); _settings = _store.GetSettings(); _credData = _store.GetCredentials(); _runnerServer = HostContext.GetService(); } public async Task UpdateRunnerConfigAsync(string runnerQualifiedId, string configType, string serviceType, string configRefreshUrl) { Trace.Entering(); try { ArgUtil.NotNullOrEmpty(runnerQualifiedId, nameof(runnerQualifiedId)); ArgUtil.NotNullOrEmpty(configType, nameof(configType)); ArgUtil.NotNullOrEmpty(serviceType, nameof(serviceType)); ArgUtil.NotNullOrEmpty(configRefreshUrl, nameof(configRefreshUrl)); // make sure the runner qualified id matches the current runner if (!await VerifyRunnerQualifiedId(runnerQualifiedId)) { return; } // keep the timeout short to avoid blocking the main thread using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30))) { switch (configType.ToLowerInvariant()) { case "runner": await UpdateRunnerSettingsAsync(serviceType, configRefreshUrl, tokenSource.Token); break; case "credentials": await UpdateRunnerCredentialsAsync(serviceType, configRefreshUrl, tokenSource.Token); break; default: Trace.Error($"Invalid config type '{configType}'."); await ReportTelemetryAsync($"Invalid config type '{configType}'."); return; } } } catch (Exception ex) { Trace.Error($"Failed to update runner '{configType}' config."); Trace.Error(ex); await ReportTelemetryAsync($"Failed to update runner '{configType}' config: {ex}"); } } private async Task UpdateRunnerSettingsAsync(string serviceType, string configRefreshUrl, CancellationToken token) { Trace.Entering(); // read the current runner settings and encode with base64 var runnerConfig = HostContext.GetConfigFile(WellKnownConfigFile.Runner); string runnerConfigContent = File.ReadAllText(runnerConfig, Encoding.UTF8); var encodedConfig = Convert.ToBase64String(Encoding.UTF8.GetBytes(runnerConfigContent)); if (string.IsNullOrEmpty(encodedConfig)) { await ReportTelemetryAsync("Failed to get encoded runner settings."); return; } // exchange the encoded runner settings with the service string refreshedEncodedConfig = await RefreshRunnerConfigAsync(encodedConfig, serviceType, "runner", configRefreshUrl, token); if (string.IsNullOrEmpty(refreshedEncodedConfig)) { // service will return empty string if there is no change in the config return; } var decodedConfig = Encoding.UTF8.GetString(Convert.FromBase64String(refreshedEncodedConfig)); RunnerSettings refreshedRunnerConfig; try { refreshedRunnerConfig = StringUtil.ConvertFromJson(decodedConfig); } catch (Exception ex) { Trace.Error($"Failed to convert runner config from json '{decodedConfig}'."); Trace.Error(ex); await ReportTelemetryAsync($"Failed to convert runner config '{decodedConfig}' from json: {ex}"); return; } // make sure the runner id and name in the refreshed config match the current runner if (refreshedRunnerConfig?.AgentId != _settings.AgentId) { Trace.Error($"Runner id in refreshed config '{refreshedRunnerConfig?.AgentId.ToString() ?? "Empty"}' does not match the current runner '{_settings.AgentId}'."); await ReportTelemetryAsync($"Runner id in refreshed config '{refreshedRunnerConfig?.AgentId.ToString() ?? "Empty"}' does not match the current runner '{_settings.AgentId}'."); return; } if (refreshedRunnerConfig?.AgentName != _settings.AgentName) { Trace.Error($"Runner name in refreshed config '{refreshedRunnerConfig?.AgentName ?? "Empty"}' does not match the current runner '{_settings.AgentName}'."); await ReportTelemetryAsync($"Runner name in refreshed config '{refreshedRunnerConfig?.AgentName ?? "Empty"}' does not match the current runner '{_settings.AgentName}'."); return; } // save the refreshed runner settings as a separate file _store.SaveMigratedSettings(refreshedRunnerConfig); await ReportTelemetryAsync("Runner settings updated successfully."); } private async Task UpdateRunnerCredentialsAsync(string serviceType, string configRefreshUrl, CancellationToken token) { Trace.Entering(); // read the current runner credentials and encode with base64 var credConfig = HostContext.GetConfigFile(WellKnownConfigFile.Credentials); string credConfigContent = File.ReadAllText(credConfig, Encoding.UTF8); var encodedConfig = Convert.ToBase64String(Encoding.UTF8.GetBytes(credConfigContent)); if (string.IsNullOrEmpty(encodedConfig)) { await ReportTelemetryAsync("Failed to get encoded credentials."); return; } CredentialData currentCred = _store.GetCredentials(); if (currentCred == null) { await ReportTelemetryAsync("Failed to get current credentials."); return; } // we only support refreshing OAuth credentials which is used by self-hosted runners. if (currentCred.Scheme != Constants.Configuration.OAuth) { await ReportTelemetryAsync($"Not supported credential scheme '{currentCred.Scheme}'."); return; } // exchange the encoded runner credentials with the service string refreshedEncodedConfig = await RefreshRunnerConfigAsync(encodedConfig, serviceType, "credentials", configRefreshUrl, token); if (string.IsNullOrEmpty(refreshedEncodedConfig)) { // service will return empty string if there is no change in the config return; } var decodedConfig = Encoding.UTF8.GetString(Convert.FromBase64String(refreshedEncodedConfig)); CredentialData refreshedCredConfig; try { refreshedCredConfig = StringUtil.ConvertFromJson(decodedConfig); } catch (Exception ex) { Trace.Error($"Failed to convert credentials config from json '{decodedConfig}'."); Trace.Error(ex); await ReportTelemetryAsync($"Failed to convert credentials config '{decodedConfig}' from json: {ex}"); return; } // make sure the credential scheme in the refreshed config match the current credential scheme if (refreshedCredConfig?.Scheme != _credData.Scheme) { Trace.Error($"Credential scheme in refreshed config '{refreshedCredConfig?.Scheme ?? "Empty"}' does not match the current credential scheme '{_credData.Scheme}'."); await ReportTelemetryAsync($"Credential scheme in refreshed config '{refreshedCredConfig?.Scheme ?? "Empty"}' does not match the current credential scheme '{_credData.Scheme}'."); return; } if (_credData.Scheme == Constants.Configuration.OAuth) { // make sure the credential clientId in the refreshed config match the current credential clientId for OAuth auth scheme var clientId = _credData.Data.GetValueOrDefault("clientId", null); var refreshedClientId = refreshedCredConfig.Data.GetValueOrDefault("clientId", null); if (clientId != refreshedClientId) { Trace.Error($"Credential clientId in refreshed config '{refreshedClientId ?? "Empty"}' does not match the current credential clientId '{clientId}'."); await ReportTelemetryAsync($"Credential clientId in refreshed config '{refreshedClientId ?? "Empty"}' does not match the current credential clientId '{clientId}'."); return; } // make sure the credential authorizationUrl in the refreshed config match the current credential authorizationUrl for OAuth auth scheme var authorizationUrl = _credData.Data.GetValueOrDefault("authorizationUrl", null); var refreshedAuthorizationUrl = refreshedCredConfig.Data.GetValueOrDefault("authorizationUrl", null); if (authorizationUrl != refreshedAuthorizationUrl) { Trace.Error($"Credential authorizationUrl in refreshed config '{refreshedAuthorizationUrl ?? "Empty"}' does not match the current credential authorizationUrl '{authorizationUrl}'."); await ReportTelemetryAsync($"Credential authorizationUrl in refreshed config '{refreshedAuthorizationUrl ?? "Empty"}' does not match the current credential authorizationUrl '{authorizationUrl}'."); return; } } // save the refreshed runner credentials as a separate file _store.SaveMigratedCredential(refreshedCredConfig); if (refreshedCredConfig.Data.ContainsKey("authorizationUrlV2")) { HostContext.EnableAuthMigration("Credential file updated"); await ReportTelemetryAsync("Runner credentials updated successfully. Auth migration is enabled."); } else { HostContext.DeferAuthMigration(TimeSpan.FromDays(365), "Credential file does not contain authorizationUrlV2"); await ReportTelemetryAsync("Runner credentials updated successfully. Auth migration is disabled."); } } private async Task VerifyRunnerQualifiedId(string runnerQualifiedId) { Trace.Entering(); Trace.Info($"Verifying runner qualified id: {runnerQualifiedId}"); var idParts = runnerQualifiedId.Split("/", StringSplitOptions.RemoveEmptyEntries); if (idParts.Length != 4 || idParts[3] != _settings.AgentId.ToString()) { Trace.Error($"Runner qualified id '{runnerQualifiedId}' does not match the current runner '{_settings.AgentId}'."); await ReportTelemetryAsync($"Runner qualified id '{runnerQualifiedId}' does not match the current runner '{_settings.AgentId}'."); return false; } return true; } private async Task RefreshRunnerConfigAsync(string encodedConfig, string serviceType, string configType, string configRefreshUrl, CancellationToken token) { string refreshedEncodedConfig; switch (serviceType.ToLowerInvariant()) { case "pipelines": try { refreshedEncodedConfig = await _runnerServer.RefreshRunnerConfigAsync((int)_settings.AgentId, configType, encodedConfig, token); } catch (Exception ex) { Trace.Error($"Failed to refresh runner {configType} config with service."); Trace.Error(ex); await ReportTelemetryAsync($"Failed to refresh {configType} config: {ex}"); return null; } break; case "runner-admin": throw new NotSupportedException("Runner admin service is not supported."); default: Trace.Error($"Invalid service type '{serviceType}'."); await ReportTelemetryAsync($"Invalid service type '{serviceType}'."); return null; } return refreshedEncodedConfig; } private async Task ReportTelemetryAsync(string telemetry) { Trace.Entering(); try { using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30))) { await _runnerServer.UpdateAgentUpdateStateAsync(_settings.PoolId, _settings.AgentId, "RefreshConfig", telemetry, tokenSource.Token); } } catch (Exception ex) { Trace.Error("Failed to report telemetry."); Trace.Error(ex); } } } }