mirror of
https://github.com/actions/runner.git
synced 2025-12-10 20:36:49 +00:00
288 lines
14 KiB
C#
288 lines
14 KiB
C#
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<IConfigurationStore>();
|
|
_settings = _store.GetSettings();
|
|
_credData = _store.GetCredentials();
|
|
_runnerServer = HostContext.GetService<IRunnerServer>();
|
|
}
|
|
|
|
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<RunnerSettings>(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<CredentialData>(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<bool> 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<string> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|