GitHub Actions Runner

This commit is contained in:
Tingluo Huang
2019-10-10 00:52:42 -04:00
commit c8afc84840
1255 changed files with 198670 additions and 0 deletions

View File

@@ -0,0 +1,493 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Listener.Configuration;
using GitHub.Runner.Common.Util;
using System;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Services.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener
{
[ServiceLocator(Default = typeof(Runner))]
public interface IRunner : IRunnerService
{
Task<int> ExecuteCommand(CommandSettings command);
}
public sealed class Runner : RunnerService, IRunner
{
private IMessageListener _listener;
private ITerminal _term;
private bool _inConfigStage;
private ManualResetEvent _completedCommand = new ManualResetEvent(false);
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_term = HostContext.GetService<ITerminal>();
}
public async Task<int> ExecuteCommand(CommandSettings command)
{
try
{
var runnerWebProxy = HostContext.GetService<IRunnerWebProxy>();
var runnerCertManager = HostContext.GetService<IRunnerCertificateManager>();
VssUtil.InitializeVssClientSettings(HostContext.UserAgent, runnerWebProxy.WebProxy, runnerCertManager.VssClientCertificateManager);
_inConfigStage = true;
_completedCommand.Reset();
_term.CancelKeyPress += CtrlCHandler;
//register a SIGTERM handler
HostContext.Unloading += Runner_Unloading;
// TODO Unit test to cover this logic
Trace.Info(nameof(ExecuteCommand));
var configManager = HostContext.GetService<IConfigurationManager>();
// command is not required, if no command it just starts if configured
// TODO: Invalid config prints usage
if (command.Help)
{
PrintUsage(command);
return Constants.Runner.ReturnCode.Success;
}
if (command.Version)
{
_term.WriteLine(BuildConstants.RunnerPackage.Version);
return Constants.Runner.ReturnCode.Success;
}
if (command.Commit)
{
_term.WriteLine(BuildConstants.Source.CommitHash);
return Constants.Runner.ReturnCode.Success;
}
// Configure runner prompt for args if not supplied
// Unattended configure mode will not prompt for args if not supplied and error on any missing or invalid value.
if (command.Configure)
{
try
{
await configManager.ConfigureAsync(command);
return Constants.Runner.ReturnCode.Success;
}
catch (Exception ex)
{
Trace.Error(ex);
_term.WriteError(ex.Message);
return Constants.Runner.ReturnCode.TerminatedError;
}
}
// remove config files, remove service, and exit
if (command.Remove)
{
try
{
await configManager.UnconfigureAsync(command);
return Constants.Runner.ReturnCode.Success;
}
catch (Exception ex)
{
Trace.Error(ex);
_term.WriteError(ex.Message);
return Constants.Runner.ReturnCode.TerminatedError;
}
}
_inConfigStage = false;
// warmup runner process (JIT/CLR)
// In scenarios where the runner is single use (used and then thrown away), the system provisioning the runner can call `Runner.Listener --warmup` before the machine is made available to the pool for use.
// this will optimizes the runner process startup time.
if (command.Warmup)
{
var binDir = HostContext.GetDirectory(WellKnownDirectory.Bin);
foreach (var assemblyFile in Directory.EnumerateFiles(binDir, "*.dll"))
{
try
{
Trace.Info($"Load assembly: {assemblyFile}.");
var assembly = Assembly.LoadFrom(assemblyFile);
var types = assembly.GetTypes();
foreach (Type loadedType in types)
{
try
{
Trace.Info($"Load methods: {loadedType.FullName}.");
var methods = loadedType.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static);
foreach (var method in methods)
{
if (!method.IsAbstract && !method.ContainsGenericParameters)
{
Trace.Verbose($"Prepare method: {method.Name}.");
RuntimeHelpers.PrepareMethod(method.MethodHandle);
}
}
}
catch (Exception ex)
{
Trace.Error(ex);
}
}
}
catch (Exception ex)
{
Trace.Error(ex);
}
}
return Constants.Runner.ReturnCode.Success;
}
RunnerSettings settings = configManager.LoadSettings();
var store = HostContext.GetService<IConfigurationStore>();
bool configuredAsService = store.IsServiceConfigured();
// Run runner
if (command.Run) // this line is current break machine provisioner.
{
// Error if runner not configured.
if (!configManager.IsConfigured())
{
_term.WriteError("Runner is not configured.");
PrintUsage(command);
return Constants.Runner.ReturnCode.TerminatedError;
}
Trace.Verbose($"Configured as service: '{configuredAsService}'");
//Get the startup type of the runner i.e., autostartup, service, manual
StartupType startType;
var startupTypeAsString = command.GetStartupType();
if (string.IsNullOrEmpty(startupTypeAsString) && configuredAsService)
{
// We need try our best to make the startup type accurate
// The problem is coming from runner autoupgrade, which result an old version service host binary but a newer version runner binary
// At that time the servicehost won't pass --startuptype to Runner.Listener while the runner is actually running as service.
// We will guess the startup type only when the runner is configured as service and the guess will based on whether STDOUT/STDERR/STDIN been redirect or not
Trace.Info($"Try determine runner startup type base on console redirects.");
startType = (Console.IsErrorRedirected && Console.IsInputRedirected && Console.IsOutputRedirected) ? StartupType.Service : StartupType.Manual;
}
else
{
if (!Enum.TryParse(startupTypeAsString, true, out startType))
{
Trace.Info($"Could not parse the argument value '{startupTypeAsString}' for StartupType. Defaulting to {StartupType.Manual}");
startType = StartupType.Manual;
}
}
#if !OS_WINDOWS
// Fix the work folder setting on Linux
if (settings.WorkFolder.Contains("vsts", StringComparison.OrdinalIgnoreCase))
{
var workFolder = "/runner/work";
var unix = HostContext.GetService<IUnixUtil>();
// create new work folder /runner/work
await unix.ExecAsync(HostContext.GetDirectory(WellKnownDirectory.Root), "sh", $"-c \"sudo mkdir -p {workFolder}\"");
// fix permission
await unix.ExecAsync(HostContext.GetDirectory(WellKnownDirectory.Root), "sh", $"-c \"sudo chown -R $USER {workFolder}\"");
// update settings
settings.WorkFolder = workFolder;
store.SaveSettings(settings);
}
#endif
Trace.Info($"Set runner startup type - {startType}");
HostContext.StartupType = startType;
// Run the runner interactively or as service
return await RunAsync(settings, command.RunOnce);
}
else
{
PrintUsage(command);
return Constants.Runner.ReturnCode.Success;
}
}
finally
{
_term.CancelKeyPress -= CtrlCHandler;
HostContext.Unloading -= Runner_Unloading;
_completedCommand.Set();
}
}
private void Runner_Unloading(object sender, EventArgs e)
{
if ((!_inConfigStage) && (!HostContext.RunnerShutdownToken.IsCancellationRequested))
{
HostContext.ShutdownRunner(ShutdownReason.UserCancelled);
_completedCommand.WaitOne(Constants.Runner.ExitOnUnloadTimeout);
}
}
private void CtrlCHandler(object sender, EventArgs e)
{
_term.WriteLine("Exiting...");
if (_inConfigStage)
{
HostContext.Dispose();
Environment.Exit(Constants.Runner.ReturnCode.TerminatedError);
}
else
{
ConsoleCancelEventArgs cancelEvent = e as ConsoleCancelEventArgs;
if (cancelEvent != null && HostContext.GetService<IConfigurationStore>().IsServiceConfigured())
{
ShutdownReason reason;
if (cancelEvent.SpecialKey == ConsoleSpecialKey.ControlBreak)
{
Trace.Info("Received Ctrl-Break signal from runner service host, this indicate the operating system is shutting down.");
reason = ShutdownReason.OperatingSystemShutdown;
}
else
{
Trace.Info("Received Ctrl-C signal, stop Runner.Listener and Runner.Worker.");
reason = ShutdownReason.UserCancelled;
}
HostContext.ShutdownRunner(reason);
}
else
{
HostContext.ShutdownRunner(ShutdownReason.UserCancelled);
}
}
}
//create worker manager, create message listener and start listening to the queue
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false)
{
try
{
Trace.Info(nameof(RunAsync));
_listener = HostContext.GetService<IMessageListener>();
if (!await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken))
{
return Constants.Runner.ReturnCode.TerminatedError;
}
HostContext.WritePerfCounter("SessionCreated");
_term.WriteLine($"{DateTime.UtcNow:u}: Listening for Jobs");
IJobDispatcher jobDispatcher = null;
CancellationTokenSource messageQueueLoopTokenSource = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken);
try
{
var notification = HostContext.GetService<IJobNotification>();
if (!String.IsNullOrEmpty(settings.NotificationSocketAddress))
{
notification.StartClient(settings.NotificationSocketAddress, settings.MonitorSocketAddress);
}
else
{
notification.StartClient(settings.NotificationPipeName, settings.MonitorSocketAddress, HostContext.RunnerShutdownToken);
}
bool autoUpdateInProgress = false;
Task<bool> selfUpdateTask = null;
bool runOnceJobReceived = false;
jobDispatcher = HostContext.CreateService<IJobDispatcher>();
while (!HostContext.RunnerShutdownToken.IsCancellationRequested)
{
TaskAgentMessage message = null;
bool skipMessageDeletion = false;
try
{
Task<TaskAgentMessage> getNextMessage = _listener.GetNextMessageAsync(messageQueueLoopTokenSource.Token);
if (autoUpdateInProgress)
{
Trace.Verbose("Auto update task running at backend, waiting for getNextMessage or selfUpdateTask to finish.");
Task completeTask = await Task.WhenAny(getNextMessage, selfUpdateTask);
if (completeTask == selfUpdateTask)
{
autoUpdateInProgress = false;
if (await selfUpdateTask)
{
Trace.Info("Auto update task finished at backend, an runner update is ready to apply exit the current runner instance.");
Trace.Info("Stop message queue looping.");
messageQueueLoopTokenSource.Cancel();
try
{
await getNextMessage;
}
catch (Exception ex)
{
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
}
if (runOnce)
{
return Constants.Runner.ReturnCode.RunOnceRunnerUpdating;
}
else
{
return Constants.Runner.ReturnCode.RunnerUpdating;
}
}
else
{
Trace.Info("Auto update task finished at backend, there is no available runner update needs to apply, continue message queue looping.");
}
}
}
if (runOnceJobReceived)
{
Trace.Verbose("One time used runner has start running its job, waiting for getNextMessage or the job to finish.");
Task completeTask = await Task.WhenAny(getNextMessage, jobDispatcher.RunOnceJobCompleted.Task);
if (completeTask == jobDispatcher.RunOnceJobCompleted.Task)
{
Trace.Info("Job has finished at backend, the runner will exit since it is running under onetime use mode.");
Trace.Info("Stop message queue looping.");
messageQueueLoopTokenSource.Cancel();
try
{
await getNextMessage;
}
catch (Exception ex)
{
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
}
return Constants.Runner.ReturnCode.Success;
}
}
message = await getNextMessage; //get next message
HostContext.WritePerfCounter($"MessageReceived_{message.MessageType}");
if (string.Equals(message.MessageType, AgentRefreshMessage.MessageType, StringComparison.OrdinalIgnoreCase))
{
if (autoUpdateInProgress == false)
{
autoUpdateInProgress = true;
var runnerUpdateMessage = JsonUtility.FromString<AgentRefreshMessage>(message.Body);
var selfUpdater = HostContext.GetService<ISelfUpdater>();
selfUpdateTask = selfUpdater.SelfUpdate(runnerUpdateMessage, jobDispatcher, !runOnce && HostContext.StartupType != StartupType.Service, HostContext.RunnerShutdownToken);
Trace.Info("Refresh message received, kick-off selfupdate background process.");
}
else
{
Trace.Info("Refresh message received, skip autoupdate since a previous autoupdate is already running.");
}
}
else if (string.Equals(message.MessageType, JobRequestMessageTypes.PipelineAgentJobRequest, StringComparison.OrdinalIgnoreCase))
{
if (autoUpdateInProgress || runOnceJobReceived)
{
skipMessageDeletion = true;
Trace.Info($"Skip message deletion for job request message '{message.MessageId}'.");
}
else
{
var jobMessage = StringUtil.ConvertFromJson<Pipelines.AgentJobRequestMessage>(message.Body);
jobDispatcher.Run(jobMessage, runOnce);
if (runOnce)
{
Trace.Info("One time used runner received job message.");
runOnceJobReceived = true;
}
}
}
else if (string.Equals(message.MessageType, JobCancelMessage.MessageType, StringComparison.OrdinalIgnoreCase))
{
var cancelJobMessage = JsonUtility.FromString<JobCancelMessage>(message.Body);
bool jobCancelled = jobDispatcher.Cancel(cancelJobMessage);
skipMessageDeletion = (autoUpdateInProgress || runOnceJobReceived) && !jobCancelled;
if (skipMessageDeletion)
{
Trace.Info($"Skip message deletion for cancellation message '{message.MessageId}'.");
}
}
else
{
Trace.Error($"Received message {message.MessageId} with unsupported message type {message.MessageType}.");
}
}
finally
{
if (!skipMessageDeletion && message != null)
{
try
{
await _listener.DeleteMessageAsync(message);
}
catch (Exception ex)
{
Trace.Error($"Catch exception during delete message from message queue. message id: {message.MessageId}");
Trace.Error(ex);
}
finally
{
message = null;
}
}
}
}
}
finally
{
if (jobDispatcher != null)
{
await jobDispatcher.ShutdownAsync();
}
//TODO: make sure we don't mask more important exception
await _listener.DeleteSessionAsync();
messageQueueLoopTokenSource.Dispose();
}
}
catch (TaskAgentAccessTokenExpiredException)
{
Trace.Info("Agent OAuth token has been revoked. Shutting down.");
}
return Constants.Runner.ReturnCode.Success;
}
private void PrintUsage(CommandSettings command)
{
string separator;
string ext;
#if OS_WINDOWS
separator = "\\";
ext = "cmd";
#else
separator = "/";
ext = "sh";
#endif
_term.WriteLine($@"
Commands:,
.{separator}config.{ext} Configures the runner
.{separator}config.{ext} remove Unconfigures the runner
.{separator}run.{ext} Runs the runner interactively. Does not require any options.
Options:
--version Prints the runner version
--commit Prints the runner commit
--help Prints the help for each command
");
}
}
}

View File

@@ -0,0 +1,467 @@
using GitHub.Runner.Listener.Configuration;
using GitHub.Runner.Common.Util;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using GitHub.DistributedTask.Logging;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener
{
public sealed class CommandSettings
{
private readonly Dictionary<string, string> _envArgs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private readonly CommandLineParser _parser;
private readonly IPromptManager _promptManager;
private readonly Tracing _trace;
private readonly string[] validCommands =
{
Constants.Runner.CommandLine.Commands.Configure,
Constants.Runner.CommandLine.Commands.Remove,
Constants.Runner.CommandLine.Commands.Run,
Constants.Runner.CommandLine.Commands.Warmup,
};
private readonly string[] validFlags =
{
Constants.Runner.CommandLine.Flags.Commit,
#if OS_WINDOWS
Constants.Runner.CommandLine.Flags.GitUseSChannel,
#endif
Constants.Runner.CommandLine.Flags.Help,
Constants.Runner.CommandLine.Flags.Replace,
Constants.Runner.CommandLine.Flags.RunAsService,
Constants.Runner.CommandLine.Flags.Once,
Constants.Runner.CommandLine.Flags.SslSkipCertValidation,
Constants.Runner.CommandLine.Flags.Unattended,
Constants.Runner.CommandLine.Flags.Version
};
private readonly string[] validArgs =
{
Constants.Runner.CommandLine.Args.Agent,
Constants.Runner.CommandLine.Args.Auth,
Constants.Runner.CommandLine.Args.MonitorSocketAddress,
Constants.Runner.CommandLine.Args.NotificationPipeName,
Constants.Runner.CommandLine.Args.Password,
Constants.Runner.CommandLine.Args.Pool,
Constants.Runner.CommandLine.Args.ProxyPassword,
Constants.Runner.CommandLine.Args.ProxyUrl,
Constants.Runner.CommandLine.Args.ProxyUserName,
Constants.Runner.CommandLine.Args.SslCACert,
Constants.Runner.CommandLine.Args.SslClientCert,
Constants.Runner.CommandLine.Args.SslClientCertKey,
Constants.Runner.CommandLine.Args.SslClientCertArchive,
Constants.Runner.CommandLine.Args.SslClientCertPassword,
Constants.Runner.CommandLine.Args.StartupType,
Constants.Runner.CommandLine.Args.Token,
Constants.Runner.CommandLine.Args.Url,
Constants.Runner.CommandLine.Args.UserName,
Constants.Runner.CommandLine.Args.WindowsLogonAccount,
Constants.Runner.CommandLine.Args.WindowsLogonPassword,
Constants.Runner.CommandLine.Args.Work
};
// Commands.
public bool Configure => TestCommand(Constants.Runner.CommandLine.Commands.Configure);
public bool Remove => TestCommand(Constants.Runner.CommandLine.Commands.Remove);
public bool Run => TestCommand(Constants.Runner.CommandLine.Commands.Run);
public bool Warmup => TestCommand(Constants.Runner.CommandLine.Commands.Warmup);
// Flags.
public bool Commit => TestFlag(Constants.Runner.CommandLine.Flags.Commit);
public bool Help => TestFlag(Constants.Runner.CommandLine.Flags.Help);
public bool Unattended => TestFlag(Constants.Runner.CommandLine.Flags.Unattended);
public bool Version => TestFlag(Constants.Runner.CommandLine.Flags.Version);
#if OS_WINDOWS
public bool GitUseSChannel => TestFlag(Constants.Runner.CommandLine.Flags.GitUseSChannel);
#endif
public bool RunOnce => TestFlag(Constants.Runner.CommandLine.Flags.Once);
// Constructor.
public CommandSettings(IHostContext context, string[] args)
{
ArgUtil.NotNull(context, nameof(context));
_promptManager = context.GetService<IPromptManager>();
_trace = context.GetTrace(nameof(CommandSettings));
// Parse the command line args.
_parser = new CommandLineParser(
hostContext: context,
secretArgNames: Constants.Runner.CommandLine.Args.Secrets);
_parser.Parse(args);
// Store and remove any args passed via environment variables.
IDictionary environment = Environment.GetEnvironmentVariables();
string envPrefix = "ACTIONS_RUNNER_INPUT_";
foreach (DictionaryEntry entry in environment)
{
// Test if starts with ACTIONS_RUNNER_INPUT_.
string fullKey = entry.Key as string ?? string.Empty;
if (fullKey.StartsWith(envPrefix, StringComparison.OrdinalIgnoreCase))
{
string val = (entry.Value as string ?? string.Empty).Trim();
if (!string.IsNullOrEmpty(val))
{
// Extract the name.
string name = fullKey.Substring(envPrefix.Length);
// Mask secrets.
bool secret = Constants.Runner.CommandLine.Args.Secrets.Any(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
if (secret)
{
context.SecretMasker.AddValue(val);
}
// Store the value.
_envArgs[name] = val;
}
// Remove from the environment block.
_trace.Info($"Removing env var: '{fullKey}'");
Environment.SetEnvironmentVariable(fullKey, null);
}
}
}
// Validate commandline parser result
public List<string> Validate()
{
List<string> unknowns = new List<string>();
// detect unknown commands
unknowns.AddRange(_parser.Commands.Where(x => !validCommands.Contains(x, StringComparer.OrdinalIgnoreCase)));
// detect unknown flags
unknowns.AddRange(_parser.Flags.Where(x => !validFlags.Contains(x, StringComparer.OrdinalIgnoreCase)));
// detect unknown args
unknowns.AddRange(_parser.Args.Keys.Where(x => !validArgs.Contains(x, StringComparer.OrdinalIgnoreCase)));
return unknowns;
}
//
// Interactive flags.
//
public bool GetReplace()
{
return TestFlagOrPrompt(
name: Constants.Runner.CommandLine.Flags.Replace,
description: "Would you like to replace the existing runner? (Y/N)",
defaultValue: false);
}
public bool GetRunAsService()
{
return TestFlagOrPrompt(
name: Constants.Runner.CommandLine.Flags.RunAsService,
description: "Would you like to run the runner as service? (Y/N)",
defaultValue: false);
}
public bool GetAutoLaunchBrowser()
{
return TestFlagOrPrompt(
name: Constants.Runner.CommandLine.Flags.LaunchBrowser,
description: "Would you like to launch your browser for AAD Device Code Flow? (Y/N)",
defaultValue: true);
}
//
// Args.
//
public string GetAgentName()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Agent,
description: "Enter the name of runner:",
defaultValue: Environment.MachineName ?? "myagent",
validator: Validators.NonEmptyValidator);
}
public string GetAuth(string defaultValue)
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Auth,
description: "How would you like to authenticate?",
defaultValue: defaultValue,
validator: Validators.AuthSchemeValidator);
}
public string GetPassword()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Password,
description: "What is your GitHub password?",
defaultValue: string.Empty,
validator: Validators.NonEmptyValidator);
}
public string GetPool()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Pool,
description: "Enter the name of your runner pool:",
defaultValue: "default",
validator: Validators.NonEmptyValidator);
}
public string GetToken()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Token,
description: "Enter your personal access token:",
defaultValue: string.Empty,
validator: Validators.NonEmptyValidator);
}
public string GetRunnerRegisterToken()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Token,
description: "Enter runner register token:",
defaultValue: string.Empty,
validator: Validators.NonEmptyValidator);
}
public string GetUrl(bool suppressPromptIfEmpty = false)
{
// Note, GetArg does not consume the arg (like GetArgOrPrompt does).
if (suppressPromptIfEmpty &&
string.IsNullOrEmpty(GetArg(Constants.Runner.CommandLine.Args.Url)))
{
return string.Empty;
}
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Url,
description: "What is the URL of your repository?",
defaultValue: string.Empty,
validator: Validators.ServerUrlValidator);
}
public string GetUserName()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.UserName,
description: "What is your GitHub username?",
defaultValue: string.Empty,
validator: Validators.NonEmptyValidator);
}
public string GetWindowsLogonAccount(string defaultValue, string descriptionMsg)
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.WindowsLogonAccount,
description: descriptionMsg,
defaultValue: defaultValue,
validator: Validators.NTAccountValidator);
}
public string GetWindowsLogonPassword(string accountName)
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.WindowsLogonPassword,
description: $"Password for the account {accountName}",
defaultValue: string.Empty,
validator: Validators.NonEmptyValidator);
}
public string GetWork()
{
return GetArgOrPrompt(
name: Constants.Runner.CommandLine.Args.Work,
description: "Enter name of work folder:",
defaultValue: Constants.Path.WorkDirectory,
validator: Validators.NonEmptyValidator);
}
public string GetMonitorSocketAddress()
{
return GetArg(Constants.Runner.CommandLine.Args.MonitorSocketAddress);
}
public string GetNotificationPipeName()
{
return GetArg(Constants.Runner.CommandLine.Args.NotificationPipeName);
}
public string GetNotificationSocketAddress()
{
return GetArg(Constants.Runner.CommandLine.Args.NotificationSocketAddress);
}
// This is used to find out the source from where the Runner.Listener.exe was launched at the time of run
public string GetStartupType()
{
return GetArg(Constants.Runner.CommandLine.Args.StartupType);
}
public string GetProxyUrl()
{
return GetArg(Constants.Runner.CommandLine.Args.ProxyUrl);
}
public string GetProxyUserName()
{
return GetArg(Constants.Runner.CommandLine.Args.ProxyUserName);
}
public string GetProxyPassword()
{
return GetArg(Constants.Runner.CommandLine.Args.ProxyPassword);
}
public bool GetSkipCertificateValidation()
{
return TestFlag(Constants.Runner.CommandLine.Flags.SslSkipCertValidation);
}
public string GetCACertificate()
{
return GetArg(Constants.Runner.CommandLine.Args.SslCACert);
}
public string GetClientCertificate()
{
return GetArg(Constants.Runner.CommandLine.Args.SslClientCert);
}
public string GetClientCertificatePrivateKey()
{
return GetArg(Constants.Runner.CommandLine.Args.SslClientCertKey);
}
public string GetClientCertificateArchrive()
{
return GetArg(Constants.Runner.CommandLine.Args.SslClientCertArchive);
}
public string GetClientCertificatePassword()
{
return GetArg(Constants.Runner.CommandLine.Args.SslClientCertPassword);
}
//
// Private helpers.
//
private string GetArg(string name)
{
string result;
if (!_parser.Args.TryGetValue(name, out result))
{
result = GetEnvArg(name);
}
return result;
}
private void RemoveArg(string name)
{
if (_parser.Args.ContainsKey(name))
{
_parser.Args.Remove(name);
}
if (_envArgs.ContainsKey(name))
{
_envArgs.Remove(name);
}
}
private string GetArgOrPrompt(
string name,
string description,
string defaultValue,
Func<string, bool> validator)
{
// Check for the arg in the command line parser.
ArgUtil.NotNull(validator, nameof(validator));
string result = GetArg(name);
// Return the arg if it is not empty and is valid.
_trace.Info($"Arg '{name}': '{result}'");
if (!string.IsNullOrEmpty(result))
{
// After read the arg from input commandline args, remove it from Arg dictionary,
// This will help if bad arg value passed through CommandLine arg, when ConfigurationManager ask CommandSetting the second time,
// It will prompt for input instead of continue use the bad input.
_trace.Info($"Remove {name} from Arg dictionary.");
RemoveArg(name);
if (validator(result))
{
return result;
}
_trace.Info("Arg is invalid.");
}
// Otherwise prompt for the arg.
return _promptManager.ReadValue(
argName: name,
description: description,
secret: Constants.Runner.CommandLine.Args.Secrets.Any(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase)),
defaultValue: defaultValue,
validator: validator,
unattended: Unattended);
}
private string GetEnvArg(string name)
{
string val;
if (_envArgs.TryGetValue(name, out val) && !string.IsNullOrEmpty(val))
{
_trace.Info($"Env arg '{name}': '{val}'");
return val;
}
return null;
}
private bool TestCommand(string name)
{
bool result = _parser.IsCommand(name);
_trace.Info($"Command '{name}': '{result}'");
return result;
}
private bool TestFlag(string name)
{
bool result = _parser.Flags.Contains(name);
if (!result)
{
string envStr = GetEnvArg(name);
if (!bool.TryParse(envStr, out result))
{
result = false;
}
}
_trace.Info($"Flag '{name}': '{result}'");
return result;
}
private bool TestFlagOrPrompt(
string name,
string description,
bool defaultValue)
{
bool result = TestFlag(name);
if (!result)
{
result = _promptManager.ReadBool(
argName: name,
description: description,
defaultValue: defaultValue,
unattended: Unattended);
}
return result;
}
}
}

View File

@@ -0,0 +1,667 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Capabilities;
using GitHub.Runner.Common.Util;
using GitHub.Services.Common;
using GitHub.Services.OAuth;
using GitHub.Services.WebApi;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
namespace GitHub.Runner.Listener.Configuration
{
[ServiceLocator(Default = typeof(ConfigurationManager))]
public interface IConfigurationManager : IRunnerService
{
bool IsConfigured();
Task ConfigureAsync(CommandSettings command);
Task UnconfigureAsync(CommandSettings command);
RunnerSettings LoadSettings();
}
public sealed class ConfigurationManager : RunnerService, IConfigurationManager
{
private IConfigurationStore _store;
private IRunnerServer _runnerServer;
private ITerminal _term;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_runnerServer = HostContext.GetService<IRunnerServer>();
Trace.Verbose("Creating _store");
_store = hostContext.GetService<IConfigurationStore>();
Trace.Verbose("store created");
_term = hostContext.GetService<ITerminal>();
}
public bool IsConfigured()
{
bool result = _store.IsConfigured();
Trace.Info($"Is configured: {result}");
return result;
}
public RunnerSettings LoadSettings()
{
Trace.Info(nameof(LoadSettings));
if (!IsConfigured())
{
throw new InvalidOperationException("Not configured");
}
RunnerSettings settings = _store.GetSettings();
Trace.Info("Settings Loaded");
return settings;
}
public async Task ConfigureAsync(CommandSettings command)
{
_term.WriteLine();
_term.WriteLine("--------------------------------------------------------------------------------", ConsoleColor.White);
_term.WriteLine("| ____ _ _ _ _ _ _ _ _ |", ConsoleColor.White);
_term.WriteLine("| / ___(_) |_| | | |_ _| |__ / \\ ___| |_(_) ___ _ __ ___ |", ConsoleColor.White);
_term.WriteLine("| | | _| | __| |_| | | | | '_ \\ / _ \\ / __| __| |/ _ \\| '_ \\/ __| |", ConsoleColor.White);
_term.WriteLine("| | |_| | | |_| _ | |_| | |_) | / ___ \\ (__| |_| | (_) | | | \\__ \\ |", ConsoleColor.White);
_term.WriteLine("| \\____|_|\\__|_| |_|\\__,_|_.__/ /_/ \\_\\___|\\__|_|\\___/|_| |_|___/ |", ConsoleColor.White);
_term.WriteLine("| |", ConsoleColor.White);
_term.Write("| ", ConsoleColor.White);
_term.Write("Self-hosted runner registration", ConsoleColor.Cyan);
_term.WriteLine(" |", ConsoleColor.White);
_term.WriteLine("| |", ConsoleColor.White);
_term.WriteLine("--------------------------------------------------------------------------------", ConsoleColor.White);
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
Trace.Info(nameof(ConfigureAsync));
if (IsConfigured())
{
throw new InvalidOperationException("Cannot configure the runner because it is already configured. To reconfigure the runner, run 'config.cmd remove' or './config.sh remove' first.");
}
// Populate proxy setting from commandline args
var runnerProxy = HostContext.GetService<IRunnerWebProxy>();
bool saveProxySetting = false;
string proxyUrl = command.GetProxyUrl();
if (!string.IsNullOrEmpty(proxyUrl))
{
if (!Uri.IsWellFormedUriString(proxyUrl, UriKind.Absolute))
{
throw new ArgumentOutOfRangeException(nameof(proxyUrl));
}
Trace.Info("Reset proxy base on commandline args.");
string proxyUserName = command.GetProxyUserName();
string proxyPassword = command.GetProxyPassword();
(runnerProxy as RunnerWebProxy).SetupProxy(proxyUrl, proxyUserName, proxyPassword);
saveProxySetting = true;
}
// Populate cert setting from commandline args
var runnerCertManager = HostContext.GetService<IRunnerCertificateManager>();
bool saveCertSetting = false;
bool skipCertValidation = command.GetSkipCertificateValidation();
string caCert = command.GetCACertificate();
string clientCert = command.GetClientCertificate();
string clientCertKey = command.GetClientCertificatePrivateKey();
string clientCertArchive = command.GetClientCertificateArchrive();
string clientCertPassword = command.GetClientCertificatePassword();
// We require all Certificate files are under agent root.
// So we can set ACL correctly when configure as service
if (!string.IsNullOrEmpty(caCert))
{
caCert = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), caCert);
ArgUtil.File(caCert, nameof(caCert));
}
if (!string.IsNullOrEmpty(clientCert) &&
!string.IsNullOrEmpty(clientCertKey) &&
!string.IsNullOrEmpty(clientCertArchive))
{
// Ensure all client cert pieces are there.
clientCert = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), clientCert);
clientCertKey = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), clientCertKey);
clientCertArchive = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), clientCertArchive);
ArgUtil.File(clientCert, nameof(clientCert));
ArgUtil.File(clientCertKey, nameof(clientCertKey));
ArgUtil.File(clientCertArchive, nameof(clientCertArchive));
}
else if (!string.IsNullOrEmpty(clientCert) ||
!string.IsNullOrEmpty(clientCertKey) ||
!string.IsNullOrEmpty(clientCertArchive))
{
// Print out which args are missing.
ArgUtil.NotNullOrEmpty(Constants.Runner.CommandLine.Args.SslClientCert, Constants.Runner.CommandLine.Args.SslClientCert);
ArgUtil.NotNullOrEmpty(Constants.Runner.CommandLine.Args.SslClientCertKey, Constants.Runner.CommandLine.Args.SslClientCertKey);
ArgUtil.NotNullOrEmpty(Constants.Runner.CommandLine.Args.SslClientCertArchive, Constants.Runner.CommandLine.Args.SslClientCertArchive);
}
if (skipCertValidation || !string.IsNullOrEmpty(caCert) || !string.IsNullOrEmpty(clientCert))
{
Trace.Info("Reset runner cert setting base on commandline args.");
(runnerCertManager as RunnerCertificateManager).SetupCertificate(skipCertValidation, caCert, clientCert, clientCertKey, clientCertArchive, clientCertPassword);
saveCertSetting = true;
}
RunnerSettings runnerSettings = new RunnerSettings();
bool isHostedServer = false;
// Loop getting url and creds until you can connect
ICredentialProvider credProvider = null;
VssCredentials creds = null;
_term.WriteSection("Authentication");
while (true)
{
// Get the URL
var inputUrl = command.GetUrl();
if (!inputUrl.Contains("github.com", StringComparison.OrdinalIgnoreCase))
{
runnerSettings.ServerUrl = inputUrl;
// Get the credentials
credProvider = GetCredentialProvider(command, runnerSettings.ServerUrl);
creds = credProvider.GetVssCredentials(HostContext);
Trace.Info("legacy vss cred retrieved");
}
else
{
runnerSettings.GitHubUrl = inputUrl;
var githubToken = command.GetRunnerRegisterToken();
GitHubAuthResult authResult = await GetTenantCredential(inputUrl, githubToken);
runnerSettings.ServerUrl = authResult.TenantUrl;
creds = authResult.ToVssCredentials();
Trace.Info("cred retrieved via GitHub auth");
}
try
{
// Determine the service deployment type based on connection data. (Hosted/OnPremises)
isHostedServer = await IsHostedServer(runnerSettings.ServerUrl, creds);
// Validate can connect.
await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), creds);
_term.WriteLine();
_term.WriteSuccessMessage("Connected to GitHub");
Trace.Info("Test Connection complete.");
break;
}
catch (Exception e) when (!command.Unattended)
{
_term.WriteError(e);
_term.WriteError("Failed to connect. Try again or ctrl-c to quit");
_term.WriteLine();
}
}
// We want to use the native CSP of the platform for storage, so we use the RSACSP directly
RSAParameters publicKey;
var keyManager = HostContext.GetService<IRSAKeyManager>();
using (var rsa = keyManager.CreateKey())
{
publicKey = rsa.ExportParameters(false);
}
_term.WriteSection("Runner Registration");
//Get all the agent pools, and select the first private pool
List<TaskAgentPool> agentPools = await _runnerServer.GetAgentPoolsAsync();
TaskAgentPool agentPool = agentPools?.Where(x => x.IsHosted == false).FirstOrDefault();
if (agentPool == null)
{
throw new TaskAgentPoolNotFoundException($"Could not find any private pool. Contact support.");
}
else
{
Trace.Info("Found a private pool with id {1} and name {2}", agentPool.Id, agentPool.Name);
runnerSettings.PoolId = agentPool.Id;
runnerSettings.PoolName = agentPool.Name;
}
TaskAgent agent;
while (true)
{
runnerSettings.AgentName = command.GetAgentName();
// Get the system capabilities.
Dictionary<string, string> systemCapabilities = await HostContext.GetService<ICapabilitiesManager>().GetCapabilitiesAsync(runnerSettings, CancellationToken.None);
_term.WriteLine();
var agents = await _runnerServer.GetAgentsAsync(runnerSettings.PoolId, runnerSettings.AgentName);
Trace.Verbose("Returns {0} agents", agents.Count);
agent = agents.FirstOrDefault();
if (agent != null)
{
_term.WriteLine("A runner exists with the same name", ConsoleColor.Yellow);
if (command.GetReplace())
{
// Update existing agent with new PublicKey, agent version and SystemCapabilities.
agent = UpdateExistingAgent(agent, publicKey, systemCapabilities);
try
{
agent = await _runnerServer.UpdateAgentAsync(runnerSettings.PoolId, agent);
_term.WriteSuccessMessage("Successfully replaced the runner");
break;
}
catch (Exception e) when (!command.Unattended)
{
_term.WriteError(e);
_term.WriteError("Failed to replace the runner. Try again or ctrl-c to quit");
}
}
else if (command.Unattended)
{
// if not replace and it is unattended config.
throw new TaskAgentExistsException($"Pool {runnerSettings.PoolId} already contains a runner with name {runnerSettings.AgentName}.");
}
}
else
{
// Create a new agent.
agent = CreateNewAgent(runnerSettings.AgentName, publicKey, systemCapabilities);
try
{
agent = await _runnerServer.AddAgentAsync(runnerSettings.PoolId, agent);
_term.WriteSuccessMessage("Runner successfully added");
break;
}
catch (Exception e) when (!command.Unattended)
{
_term.WriteError(e);
_term.WriteError("Failed to add the runner. Try again or ctrl-c to quit");
}
}
}
// Add Agent Id to settings
runnerSettings.AgentId = agent.Id;
// respect the serverUrl resolve by server.
// in case of agent configured using collection url instead of account url.
string agentServerUrl;
if (agent.Properties.TryGetValidatedValue<string>("ServerUrl", out agentServerUrl) &&
!string.IsNullOrEmpty(agentServerUrl))
{
Trace.Info($"Agent server url resolve by server: '{agentServerUrl}'.");
// we need make sure the Schema/Host/Port component of the url remain the same.
UriBuilder inputServerUrl = new UriBuilder(runnerSettings.ServerUrl);
UriBuilder serverReturnedServerUrl = new UriBuilder(agentServerUrl);
if (Uri.Compare(inputServerUrl.Uri, serverReturnedServerUrl.Uri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) != 0)
{
inputServerUrl.Path = serverReturnedServerUrl.Path;
Trace.Info($"Replace server returned url's scheme://host:port component with user input server url's scheme://host:port: '{inputServerUrl.Uri.AbsoluteUri}'.");
runnerSettings.ServerUrl = inputServerUrl.Uri.AbsoluteUri;
}
else
{
runnerSettings.ServerUrl = agentServerUrl;
}
}
// See if the server supports our OAuth key exchange for credentials
if (agent.Authorization != null &&
agent.Authorization.ClientId != Guid.Empty &&
agent.Authorization.AuthorizationUrl != null)
{
UriBuilder configServerUrl = new UriBuilder(runnerSettings.ServerUrl);
UriBuilder oauthEndpointUrlBuilder = new UriBuilder(agent.Authorization.AuthorizationUrl);
if (!isHostedServer && Uri.Compare(configServerUrl.Uri, oauthEndpointUrlBuilder.Uri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) != 0)
{
oauthEndpointUrlBuilder.Scheme = configServerUrl.Scheme;
oauthEndpointUrlBuilder.Host = configServerUrl.Host;
oauthEndpointUrlBuilder.Port = configServerUrl.Port;
Trace.Info($"Set oauth endpoint url's scheme://host:port component to match runner configure url's scheme://host:port: '{oauthEndpointUrlBuilder.Uri.AbsoluteUri}'.");
}
var credentialData = new CredentialData
{
Scheme = Constants.Configuration.OAuth,
Data =
{
{ "clientId", agent.Authorization.ClientId.ToString("D") },
{ "authorizationUrl", agent.Authorization.AuthorizationUrl.AbsoluteUri },
{ "oauthEndpointUrl", oauthEndpointUrlBuilder.Uri.AbsoluteUri },
},
};
// Save the negotiated OAuth credential data
_store.SaveCredential(credentialData);
}
else
{
throw new NotSupportedException("Message queue listen OAuth token.");
}
// Testing agent connection, detect any protential connection issue, like local clock skew that cause OAuth token expired.
var credMgr = HostContext.GetService<ICredentialManager>();
VssCredentials credential = credMgr.LoadCredentials();
try
{
await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), credential);
_term.WriteSuccessMessage("Runner connection is good");
}
catch (VssOAuthTokenRequestException ex) when (ex.Message.Contains("Current server time is"))
{
// there are two exception messages server send that indicate clock skew.
// 1. The bearer token expired on {jwt.ValidTo}. Current server time is {DateTime.UtcNow}.
// 2. The bearer token is not valid until {jwt.ValidFrom}. Current server time is {DateTime.UtcNow}.
Trace.Error("Catch exception during test agent connection.");
Trace.Error(ex);
throw new Exception("The local machine's clock may be out of sync with the server time by more than five minutes. Please sync your clock with your domain or internet time and try again.");
}
_term.WriteSection("Runner settings");
// We will Combine() what's stored with root. Defaults to string a relative path
runnerSettings.WorkFolder = command.GetWork();
// notificationPipeName for Hosted agent provisioner.
runnerSettings.NotificationPipeName = command.GetNotificationPipeName();
runnerSettings.MonitorSocketAddress = command.GetMonitorSocketAddress();
runnerSettings.NotificationSocketAddress = command.GetNotificationSocketAddress();
_store.SaveSettings(runnerSettings);
if (saveProxySetting)
{
Trace.Info("Save proxy setting to disk.");
(runnerProxy as RunnerWebProxy).SaveProxySetting();
}
if (saveCertSetting)
{
Trace.Info("Save agent cert setting to disk.");
(runnerCertManager as RunnerCertificateManager).SaveCertificateSetting();
}
_term.WriteLine();
_term.WriteSuccessMessage("Settings Saved.");
_term.WriteLine();
bool saveRuntimeOptions = false;
var runtimeOptions = new RunnerRuntimeOptions();
#if OS_WINDOWS
if (command.GitUseSChannel)
{
saveRuntimeOptions = true;
runtimeOptions.GitUseSecureChannel = true;
}
#endif
if (saveRuntimeOptions)
{
Trace.Info("Save agent runtime options to disk.");
_store.SaveRunnerRuntimeOptions(runtimeOptions);
}
#if OS_WINDOWS
// config windows service
bool runAsService = command.GetRunAsService();
if (runAsService)
{
Trace.Info("Configuring to run the agent as service");
var serviceControlManager = HostContext.GetService<IWindowsServiceControlManager>();
serviceControlManager.ConfigureService(runnerSettings, command);
}
#elif OS_LINUX || OS_OSX
// generate service config script for OSX and Linux, GenerateScripts() will no-opt on windows.
var serviceControlManager = HostContext.GetService<ILinuxServiceControlManager>();
serviceControlManager.GenerateScripts(runnerSettings);
#endif
}
public async Task UnconfigureAsync(CommandSettings command)
{
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
string currentAction = string.Empty;
_term.WriteSection("Runner removal");
try
{
//stop, uninstall service and remove service config file
if (_store.IsServiceConfigured())
{
currentAction = "Removing service";
_term.WriteLine(currentAction);
#if OS_WINDOWS
var serviceControlManager = HostContext.GetService<IWindowsServiceControlManager>();
serviceControlManager.UnconfigureService();
_term.WriteLine();
_term.WriteSuccessMessage("Runner service removed");
#elif OS_LINUX
// unconfig system D service first
throw new Exception("Unconfigure service first");
#elif OS_OSX
// unconfig osx service first
throw new Exception("Unconfigure service first");
#endif
}
//delete agent from the server
currentAction = "Removing runner from the server";
bool isConfigured = _store.IsConfigured();
bool hasCredentials = _store.HasCredentials();
if (isConfigured && hasCredentials)
{
RunnerSettings settings = _store.GetSettings();
var credentialManager = HostContext.GetService<ICredentialManager>();
// Get the credentials
VssCredentials creds = null;
if (string.IsNullOrEmpty(settings.GitHubUrl))
{
var credProvider = GetCredentialProvider(command, settings.ServerUrl);
creds = credProvider.GetVssCredentials(HostContext);
Trace.Info("legacy vss cred retrieved");
}
else
{
var githubToken = command.GetToken();
GitHubAuthResult authResult = await GetTenantCredential(settings.GitHubUrl, githubToken);
creds = authResult.ToVssCredentials();
Trace.Info("cred retrieved via GitHub auth");
}
// Determine the service deployment type based on connection data. (Hosted/OnPremises)
bool isHostedServer = await IsHostedServer(settings.ServerUrl, creds);
await _runnerServer.ConnectAsync(new Uri(settings.ServerUrl), creds);
var agents = await _runnerServer.GetAgentsAsync(settings.PoolId, settings.AgentName);
Trace.Verbose("Returns {0} agents", agents.Count);
TaskAgent agent = agents.FirstOrDefault();
if (agent == null)
{
_term.WriteLine("Does not exist. Skipping " + currentAction);
}
else
{
await _runnerServer.DeleteAgentAsync(settings.PoolId, settings.AgentId);
_term.WriteLine();
_term.WriteSuccessMessage("Runner removed successfully");
}
}
else
{
_term.WriteLine("Cannot connect to server, because config files are missing. Skipping removing runner from the server.");
}
//delete credential config files
currentAction = "Removing .credentials";
if (hasCredentials)
{
_store.DeleteCredential();
var keyManager = HostContext.GetService<IRSAKeyManager>();
keyManager.DeleteKey();
_term.WriteSuccessMessage("Removed .credentials");
}
else
{
_term.WriteLine("Does not exist. Skipping " + currentAction);
}
//delete settings config file
currentAction = "Removing .runner";
if (isConfigured)
{
// delete proxy setting
(HostContext.GetService<IRunnerWebProxy>() as RunnerWebProxy).DeleteProxySetting();
// delete agent cert setting
(HostContext.GetService<IRunnerCertificateManager>() as RunnerCertificateManager).DeleteCertificateSetting();
// delete agent runtime option
_store.DeleteRunnerRuntimeOptions();
_store.DeleteSettings();
_term.WriteSuccessMessage("Removed .runner");
}
else
{
_term.WriteLine("Does not exist. Skipping " + currentAction);
}
}
catch (Exception)
{
_term.WriteError("Failed: " + currentAction);
throw;
}
_term.WriteLine();
}
private ICredentialProvider GetCredentialProvider(CommandSettings command, string serverUrl)
{
Trace.Info(nameof(GetCredentialProvider));
var credentialManager = HostContext.GetService<ICredentialManager>();
string authType = command.GetAuth(defaultValue: Constants.Configuration.AAD);
// Create the credential.
Trace.Info("Creating credential for auth: {0}", authType);
var provider = credentialManager.GetCredentialProvider(authType);
if (provider.RequireInteractive && command.Unattended)
{
throw new NotSupportedException($"Authentication type '{authType}' is not supported for unattended configuration.");
}
provider.EnsureCredential(HostContext, command, serverUrl);
return provider;
}
private TaskAgent UpdateExistingAgent(TaskAgent agent, RSAParameters publicKey, Dictionary<string, string> systemCapabilities)
{
ArgUtil.NotNull(agent, nameof(agent));
agent.Authorization = new TaskAgentAuthorization
{
PublicKey = new TaskAgentPublicKey(publicKey.Exponent, publicKey.Modulus),
};
// update - update instead of delete so we don't lose user capabilities etc...
agent.Version = BuildConstants.RunnerPackage.Version;
agent.OSDescription = RuntimeInformation.OSDescription;
foreach (KeyValuePair<string, string> capability in systemCapabilities)
{
agent.SystemCapabilities[capability.Key] = capability.Value ?? string.Empty;
}
return agent;
}
private TaskAgent CreateNewAgent(string agentName, RSAParameters publicKey, Dictionary<string, string> systemCapabilities)
{
TaskAgent agent = new TaskAgent(agentName)
{
Authorization = new TaskAgentAuthorization
{
PublicKey = new TaskAgentPublicKey(publicKey.Exponent, publicKey.Modulus),
},
MaxParallelism = 1,
Version = BuildConstants.RunnerPackage.Version,
OSDescription = RuntimeInformation.OSDescription,
};
foreach (KeyValuePair<string, string> capability in systemCapabilities)
{
agent.SystemCapabilities[capability.Key] = capability.Value ?? string.Empty;
}
return agent;
}
private async Task<bool> IsHostedServer(string serverUrl, VssCredentials credentials)
{
// Determine the service deployment type based on connection data. (Hosted/OnPremises)
var locationServer = HostContext.GetService<ILocationServer>();
VssConnection connection = VssUtil.CreateConnection(new Uri(serverUrl), credentials);
await locationServer.ConnectAsync(connection);
try
{
var connectionData = await locationServer.GetConnectionDataAsync();
Trace.Info($"Server deployment type: {connectionData.DeploymentType}");
return connectionData.DeploymentType.HasFlag(DeploymentFlags.Hosted);
}
catch (Exception ex)
{
// Since the DeploymentType is Enum, deserialization exception means there is a new Enum member been added.
// It's more likely to be Hosted since OnPremises is always behind and customer can update their agent if are on-prem
Trace.Error(ex);
return true;
}
}
private async Task<GitHubAuthResult> GetTenantCredential(string githubUrl, string githubToken)
{
var gitHubUrl = new UriBuilder(githubUrl);
var githubApiUrl = $"https://api.github.com/repos/{gitHubUrl.Path.Trim('/')}/actions-runners/registration";
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
using (var httpClient = new HttpClient(httpClientHandler))
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("RemoteAuth", githubToken);
httpClient.DefaultRequestHeaders.UserAgent.Add(HostContext.UserAgent);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.shuri-preview+json"));
var response = await httpClient.PostAsync(githubApiUrl, new StringContent("", null, "application/json"));
if (response.IsSuccessStatusCode)
{
Trace.Info($"Http response code: {response.StatusCode} from 'POST {githubApiUrl}'");
var jsonResponse = await response.Content.ReadAsStringAsync();
return StringUtil.ConvertFromJson<GitHubAuthResult>(jsonResponse);
}
else
{
_term.WriteError($"Http response code: {response.StatusCode} from 'POST {githubApiUrl}'");
var errorResponse = await response.Content.ReadAsStringAsync();
_term.WriteError(errorResponse);
response.EnsureSuccessStatusCode();
return null;
}
}
}
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Services.Common;
using GitHub.Services.OAuth;
namespace GitHub.Runner.Listener.Configuration
{
// TODO: Refactor extension manager to enable using it from the agent process.
[ServiceLocator(Default = typeof(CredentialManager))]
public interface ICredentialManager : IRunnerService
{
ICredentialProvider GetCredentialProvider(string credType);
VssCredentials LoadCredentials();
}
public class CredentialManager : RunnerService, ICredentialManager
{
public static readonly Dictionary<string, Type> CredentialTypes = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
{ Constants.Configuration.AAD, typeof(AadDeviceCodeAccessToken)},
{ Constants.Configuration.PAT, typeof(PersonalAccessToken)},
{ Constants.Configuration.OAuth, typeof(OAuthCredential)},
{ Constants.Configuration.OAuthAccessToken, typeof(OAuthAccessTokenCredential)},
};
public ICredentialProvider GetCredentialProvider(string credType)
{
Trace.Info(nameof(GetCredentialProvider));
Trace.Info("Creating type {0}", credType);
if (!CredentialTypes.ContainsKey(credType))
{
throw new ArgumentException("Invalid Credential Type");
}
Trace.Info("Creating credential type: {0}", credType);
var creds = Activator.CreateInstance(CredentialTypes[credType]) as ICredentialProvider;
Trace.Verbose("Created credential type");
return creds;
}
public VssCredentials LoadCredentials()
{
IConfigurationStore store = HostContext.GetService<IConfigurationStore>();
if (!store.HasCredentials())
{
throw new InvalidOperationException("Credentials not stored. Must reconfigure.");
}
CredentialData credData = store.GetCredentials();
ICredentialProvider credProv = GetCredentialProvider(credData.Scheme);
credProv.CredentialData = credData;
VssCredentials creds = credProv.GetVssCredentials(HostContext);
return creds;
}
}
[DataContract]
public sealed class GitHubAuthResult
{
[DataMember(Name = "url")]
public string TenantUrl { get; set; }
[DataMember(Name = "token_schema")]
public string TokenSchema { get; set; }
[DataMember(Name = "token")]
public string Token { get; set; }
public VssCredentials ToVssCredentials()
{
ArgUtil.NotNullOrEmpty(TokenSchema, nameof(TokenSchema));
ArgUtil.NotNullOrEmpty(Token, nameof(Token));
if (string.Equals(TokenSchema, "OAuthAccessToken", StringComparison.OrdinalIgnoreCase))
{
return new VssCredentials(null, new VssOAuthAccessTokenCredential(Token), CredentialPromptType.DoNotPrompt);
}
else
{
throw new NotSupportedException($"Not supported token schema: {TokenSchema}");
}
}
}
}

View File

@@ -0,0 +1,231 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using GitHub.Runner.Common.Util;
using GitHub.Services.Client;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Services.OAuth;
namespace GitHub.Runner.Listener.Configuration
{
public interface ICredentialProvider
{
Boolean RequireInteractive { get; }
CredentialData CredentialData { get; set; }
VssCredentials GetVssCredentials(IHostContext context);
void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl);
}
public abstract class CredentialProvider : ICredentialProvider
{
public CredentialProvider(string scheme)
{
CredentialData = new CredentialData();
CredentialData.Scheme = scheme;
}
public virtual Boolean RequireInteractive => false;
public CredentialData CredentialData { get; set; }
public abstract VssCredentials GetVssCredentials(IHostContext context);
public abstract void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl);
}
public sealed class AadDeviceCodeAccessToken : CredentialProvider
{
private string _azureDevOpsClientId = "97877f11-0fc6-4aee-b1ff-febb0519dd00";
public override Boolean RequireInteractive => true;
public AadDeviceCodeAccessToken() : base(Constants.Configuration.AAD) { }
public override VssCredentials GetVssCredentials(IHostContext context)
{
ArgUtil.NotNull(context, nameof(context));
Tracing trace = context.GetTrace(nameof(AadDeviceCodeAccessToken));
trace.Info(nameof(GetVssCredentials));
ArgUtil.NotNull(CredentialData, nameof(CredentialData));
CredentialData.Data.TryGetValue(Constants.Runner.CommandLine.Args.Url, out string serverUrl);
ArgUtil.NotNullOrEmpty(serverUrl, nameof(serverUrl));
var tenantAuthorityUrl = GetTenantAuthorityUrl(context, serverUrl);
if (tenantAuthorityUrl == null)
{
throw new NotSupportedException($"'{serverUrl}' is not backed by Azure Active Directory.");
}
LoggerCallbackHandler.LogCallback = ((LogLevel level, string message, bool containsPii) =>
{
switch (level)
{
case LogLevel.Information:
trace.Info(message);
break;
case LogLevel.Error:
trace.Error(message);
break;
case LogLevel.Warning:
trace.Warning(message);
break;
default:
trace.Verbose(message);
break;
}
});
LoggerCallbackHandler.UseDefaultLogging = false;
AuthenticationContext ctx = new AuthenticationContext(tenantAuthorityUrl.AbsoluteUri);
var queryParameters = $"redirect_uri={Uri.EscapeDataString(new Uri(serverUrl).GetLeftPart(UriPartial.Authority))}";
DeviceCodeResult codeResult = ctx.AcquireDeviceCodeAsync("https://management.core.windows.net/", _azureDevOpsClientId, queryParameters).GetAwaiter().GetResult();
var term = context.GetService<ITerminal>();
term.WriteLine($"Please finish AAD device code flow in browser ({codeResult.VerificationUrl}), user code: {codeResult.UserCode}");
if (string.Equals(CredentialData.Data[Constants.Runner.CommandLine.Flags.LaunchBrowser], bool.TrueString, StringComparison.OrdinalIgnoreCase))
{
try
{
#if OS_WINDOWS
Process.Start(new ProcessStartInfo() { FileName = codeResult.VerificationUrl, UseShellExecute = true });
#elif OS_LINUX
Process.Start(new ProcessStartInfo() { FileName = "xdg-open", Arguments = codeResult.VerificationUrl });
#else
Process.Start(new ProcessStartInfo() { FileName = "open", Arguments = codeResult.VerificationUrl });
#endif
}
catch (Exception ex)
{
// not able to open browser, ex: xdg-open/open is not installed.
trace.Error(ex);
term.WriteLine($"Fail to open browser. {codeResult.Message}");
}
}
AuthenticationResult authResult = ctx.AcquireTokenByDeviceCodeAsync(codeResult).GetAwaiter().GetResult();
ArgUtil.NotNull(authResult, nameof(authResult));
trace.Info($"receive AAD auth result with {authResult.AccessTokenType} token");
var aadCred = new VssAadCredential(new VssAadToken(authResult));
VssCredentials creds = new VssCredentials(null, aadCred, CredentialPromptType.DoNotPrompt);
trace.Info("cred created");
return creds;
}
public override void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl)
{
ArgUtil.NotNull(context, nameof(context));
Tracing trace = context.GetTrace(nameof(AadDeviceCodeAccessToken));
trace.Info(nameof(EnsureCredential));
ArgUtil.NotNull(command, nameof(command));
CredentialData.Data[Constants.Runner.CommandLine.Args.Url] = serverUrl;
CredentialData.Data[Constants.Runner.CommandLine.Flags.LaunchBrowser] = command.GetAutoLaunchBrowser().ToString();
}
private Uri GetTenantAuthorityUrl(IHostContext context, string serverUrl)
{
using (var client = new HttpClient(context.CreateHttpClientHandler()))
{
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.DefaultRequestHeaders.Add("X-TFS-FedAuthRedirect", "Suppress");
client.DefaultRequestHeaders.UserAgent.Clear();
client.DefaultRequestHeaders.UserAgent.AddRange(VssClientHttpRequestSettings.Default.UserAgent);
var requestMessage = new HttpRequestMessage(HttpMethod.Head, $"{serverUrl.Trim('/')}/_apis/connectiondata");
var response = client.SendAsync(requestMessage).GetAwaiter().GetResult();
// Get the tenant from the Login URL, MSA backed accounts will not return `Bearer` www-authenticate header.
var bearerResult = response.Headers.WwwAuthenticate.Where(p => p.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
if (bearerResult != null && bearerResult.Parameter.StartsWith("authorization_uri=", StringComparison.OrdinalIgnoreCase))
{
var authorizationUri = bearerResult.Parameter.Substring("authorization_uri=".Length);
if (Uri.TryCreate(authorizationUri, UriKind.Absolute, out Uri aadTenantUrl))
{
return aadTenantUrl;
}
}
return null;
}
}
}
public sealed class OAuthAccessTokenCredential : CredentialProvider
{
public OAuthAccessTokenCredential() : base(Constants.Configuration.OAuthAccessToken) { }
public override VssCredentials GetVssCredentials(IHostContext context)
{
ArgUtil.NotNull(context, nameof(context));
Tracing trace = context.GetTrace(nameof(OAuthAccessTokenCredential));
trace.Info(nameof(GetVssCredentials));
ArgUtil.NotNull(CredentialData, nameof(CredentialData));
string token;
if (!CredentialData.Data.TryGetValue(Constants.Runner.CommandLine.Args.Token, out token))
{
token = null;
}
ArgUtil.NotNullOrEmpty(token, nameof(token));
trace.Info("token retrieved: {0} chars", token.Length);
VssCredentials creds = new VssCredentials(null, new VssOAuthAccessTokenCredential(token), CredentialPromptType.DoNotPrompt);
trace.Info("cred created");
return creds;
}
public override void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl)
{
ArgUtil.NotNull(context, nameof(context));
Tracing trace = context.GetTrace(nameof(OAuthAccessTokenCredential));
trace.Info(nameof(EnsureCredential));
ArgUtil.NotNull(command, nameof(command));
CredentialData.Data[Constants.Runner.CommandLine.Args.Token] = command.GetToken();
}
}
public sealed class PersonalAccessToken : CredentialProvider
{
public PersonalAccessToken() : base(Constants.Configuration.PAT) { }
public override VssCredentials GetVssCredentials(IHostContext context)
{
ArgUtil.NotNull(context, nameof(context));
Tracing trace = context.GetTrace(nameof(PersonalAccessToken));
trace.Info(nameof(GetVssCredentials));
ArgUtil.NotNull(CredentialData, nameof(CredentialData));
string token;
if (!CredentialData.Data.TryGetValue(Constants.Runner.CommandLine.Args.Token, out token))
{
token = null;
}
ArgUtil.NotNullOrEmpty(token, nameof(token));
trace.Info("token retrieved: {0} chars", token.Length);
// PAT uses a basic credential
VssBasicCredential basicCred = new VssBasicCredential("ActionsRunner", token);
VssCredentials creds = new VssCredentials(null, basicCred, CredentialPromptType.DoNotPrompt);
trace.Info("cred created");
return creds;
}
public override void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl)
{
ArgUtil.NotNull(context, nameof(context));
Tracing trace = context.GetTrace(nameof(PersonalAccessToken));
trace.Info(nameof(EnsureCredential));
ArgUtil.NotNull(command, nameof(command));
CredentialData.Data[Constants.Runner.CommandLine.Args.Token] = command.GetToken();
}
}
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Runtime.Serialization;
using System.Security.Cryptography;
using GitHub.Runner.Common;
namespace GitHub.Runner.Listener.Configuration
{
/// <summary>
/// Manages an RSA key for the agent using the most appropriate store for the target platform.
/// </summary>
#if OS_WINDOWS
[ServiceLocator(Default = typeof(RSAEncryptedFileKeyManager))]
#else
[ServiceLocator(Default = typeof(RSAFileKeyManager))]
#endif
public interface IRSAKeyManager : IRunnerService
{
/// <summary>
/// Creates a new <c>RSACryptoServiceProvider</c> instance for the current agent. If a key file is found then the current
/// key is returned to the caller.
/// </summary>
/// <returns>An <c>RSACryptoServiceProvider</c> instance representing the key for the agent</returns>
RSACryptoServiceProvider CreateKey();
/// <summary>
/// Deletes the RSA key managed by the key manager.
/// </summary>
void DeleteKey();
/// <summary>
/// Gets the <c>RSACryptoServiceProvider</c> instance currently stored by the key manager.
/// </summary>
/// <returns>An <c>RSACryptoServiceProvider</c> instance representing the key for the agent</returns>
/// <exception cref="CryptographicException">No key exists in the store</exception>
RSACryptoServiceProvider GetKey();
}
// Newtonsoft 10 is not working properly with dotnet RSAParameters class
// RSAParameters has fields marked as [NonSerialized] which cause we loss those fields after serialize to JSON
// https://github.com/JamesNK/Newtonsoft.Json/issues/1517
// https://github.com/dotnet/corefx/issues/23847
// As workaround, we create our own RSAParameters class without any [NonSerialized] attributes.
[Serializable]
internal class RSAParametersSerializable : ISerializable
{
private RSAParameters _rsaParameters;
public RSAParameters RSAParameters
{
get
{
return _rsaParameters;
}
}
public RSAParametersSerializable(RSAParameters rsaParameters)
{
_rsaParameters = rsaParameters;
}
private RSAParametersSerializable()
{
}
public byte[] D { get { return _rsaParameters.D; } set { _rsaParameters.D = value; } }
public byte[] DP { get { return _rsaParameters.DP; } set { _rsaParameters.DP = value; } }
public byte[] DQ { get { return _rsaParameters.DQ; } set { _rsaParameters.DQ = value; } }
public byte[] Exponent { get { return _rsaParameters.Exponent; } set { _rsaParameters.Exponent = value; } }
public byte[] InverseQ { get { return _rsaParameters.InverseQ; } set { _rsaParameters.InverseQ = value; } }
public byte[] Modulus { get { return _rsaParameters.Modulus; } set { _rsaParameters.Modulus = value; } }
public byte[] P { get { return _rsaParameters.P; } set { _rsaParameters.P = value; } }
public byte[] Q { get { return _rsaParameters.Q; } set { _rsaParameters.Q = value; } }
public RSAParametersSerializable(SerializationInfo information, StreamingContext context)
{
_rsaParameters = new RSAParameters()
{
D = (byte[])information.GetValue("d", typeof(byte[])),
DP = (byte[])information.GetValue("dp", typeof(byte[])),
DQ = (byte[])information.GetValue("dq", typeof(byte[])),
Exponent = (byte[])information.GetValue("exponent", typeof(byte[])),
InverseQ = (byte[])information.GetValue("inverseQ", typeof(byte[])),
Modulus = (byte[])information.GetValue("modulus", typeof(byte[])),
P = (byte[])information.GetValue("p", typeof(byte[])),
Q = (byte[])information.GetValue("q", typeof(byte[]))
};
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("d", _rsaParameters.D);
info.AddValue("dp", _rsaParameters.DP);
info.AddValue("dq", _rsaParameters.DQ);
info.AddValue("exponent", _rsaParameters.Exponent);
info.AddValue("inverseQ", _rsaParameters.InverseQ);
info.AddValue("modulus", _rsaParameters.Modulus);
info.AddValue("p", _rsaParameters.P);
info.AddValue("q", _rsaParameters.Q);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Services.Common;
using GitHub.Services.OAuth;
using GitHub.Services.WebApi;
namespace GitHub.Runner.Listener.Configuration
{
public class OAuthCredential : CredentialProvider
{
public OAuthCredential()
: base(Constants.Configuration.OAuth)
{
}
public override void EnsureCredential(
IHostContext context,
CommandSettings command,
String serverUrl)
{
// Nothing to verify here
}
public override VssCredentials GetVssCredentials(IHostContext context)
{
var clientId = this.CredentialData.Data.GetValueOrDefault("clientId", null);
var authorizationUrl = this.CredentialData.Data.GetValueOrDefault("authorizationUrl", null);
// For back compat with .credential file that doesn't has 'oauthEndpointUrl' section
var oathEndpointUrl = this.CredentialData.Data.GetValueOrDefault("oauthEndpointUrl", authorizationUrl);
ArgUtil.NotNullOrEmpty(clientId, nameof(clientId));
ArgUtil.NotNullOrEmpty(authorizationUrl, nameof(authorizationUrl));
// We expect the key to be in the machine store at this point. Configuration should have set all of
// this up correctly so we can use the key to generate access tokens.
var keyManager = context.GetService<IRSAKeyManager>();
var signingCredentials = VssSigningCredentials.Create(() => keyManager.GetKey());
var clientCredential = new VssOAuthJwtBearerClientCredential(clientId, authorizationUrl, signingCredentials);
var agentCredential = new VssOAuthCredential(new Uri(oathEndpointUrl, UriKind.Absolute), VssOAuthGrant.ClientCredentials, clientCredential);
// Construct a credentials cache with a single OAuth credential for communication. The windows credential
// is explicitly set to null to ensure we never do that negotiation.
return new VssCredentials(null, agentCredential, CredentialPromptType.DoNotPrompt);
}
}
}

View File

@@ -0,0 +1,59 @@
#if OS_OSX
using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener.Configuration
{
public class OsxServiceControlManager : ServiceControlManager, ILinuxServiceControlManager
{
// This is the name you would see when you do `systemctl list-units | grep runner`
private const string _svcNamePattern = "actions.runner.{0}.{1}.{2}";
private const string _svcDisplayPattern = "GitHub Actions Runner ({0}.{1}.{2})";
private const string _shTemplate = "darwin.svc.sh.template";
private const string _svcShName = "svc.sh";
public void GenerateScripts(RunnerSettings settings)
{
Trace.Entering();
string serviceName;
string serviceDisplayName;
CalculateServiceName(settings, _svcNamePattern, _svcDisplayPattern, out serviceName, out serviceDisplayName);
try
{
string svcShPath = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), _svcShName);
// TODO: encoding?
// TODO: Loc strings formatted into MSG_xxx vars in shellscript
string svcShContent = File.ReadAllText(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), _shTemplate));
var tokensToReplace = new Dictionary<string, string>
{
{ "{{SvcDescription}}", serviceDisplayName },
{ "{{SvcNameVar}}", serviceName }
};
svcShContent = tokensToReplace.Aggregate(
svcShContent,
(current, item) => current.Replace(item.Key, item.Value));
//TODO: encoding?
File.WriteAllText(svcShPath, svcShContent);
var unixUtil = HostContext.CreateService<IUnixUtil>();
unixUtil.ChmodAsync("755", svcShPath).GetAwaiter().GetResult();
}
catch (Exception e)
{
Trace.Error(e);
throw;
}
}
}
}
#endif

View File

@@ -0,0 +1,117 @@
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using System;
namespace GitHub.Runner.Listener.Configuration
{
[ServiceLocator(Default = typeof(PromptManager))]
public interface IPromptManager : IRunnerService
{
bool ReadBool(
string argName,
string description,
bool defaultValue,
bool unattended);
string ReadValue(
string argName,
string description,
bool secret,
string defaultValue,
Func<String, bool> validator,
bool unattended);
}
public sealed class PromptManager : RunnerService, IPromptManager
{
private ITerminal _terminal;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_terminal = HostContext.GetService<ITerminal>();
}
public bool ReadBool(
string argName,
string description,
bool defaultValue,
bool unattended)
{
string answer = ReadValue(
argName: argName,
description: description,
secret: false,
defaultValue: defaultValue ? "Y" : "N",
validator: Validators.BoolValidator,
unattended: unattended);
return String.Equals(answer, "true", StringComparison.OrdinalIgnoreCase) ||
String.Equals(answer, "Y", StringComparison.CurrentCultureIgnoreCase);
}
public string ReadValue(
string argName,
string description,
bool secret,
string defaultValue,
Func<string, bool> validator,
bool unattended)
{
Trace.Info(nameof(ReadValue));
ArgUtil.NotNull(validator, nameof(validator));
string value = string.Empty;
// Check if unattended.
if (unattended)
{
// Return the default value if specified.
if (!string.IsNullOrEmpty(defaultValue))
{
return defaultValue;
}
// Otherwise throw.
throw new Exception($"Invalid configuration provided for {argName}. Terminating unattended configuration.");
}
// Prompt until a valid value is read.
while (true)
{
// Write the message prompt.
_terminal.Write($"{description} ", ConsoleColor.White);
if(!string.IsNullOrEmpty(defaultValue))
{
_terminal.Write($"[press Enter for {defaultValue}] ");
}
// Read and trim the value.
value = secret ? _terminal.ReadSecret() : _terminal.ReadLine();
value = value?.Trim() ?? string.Empty;
// Return the default if not specified.
if (string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(defaultValue))
{
Trace.Info($"Falling back to the default: '{defaultValue}'");
return defaultValue;
}
// Return the value if it is not empty and it is valid.
// Otherwise try the loop again.
if (!string.IsNullOrEmpty(value))
{
if (validator(value))
{
return value;
}
else
{
Trace.Info("Invalid value.");
_terminal.WriteLine("Entered value is invalid", ConsoleColor.Yellow);
}
}
}
}
}
}

View File

@@ -0,0 +1,87 @@
#if OS_WINDOWS
using System.IO;
using System.Security.Cryptography;
using System.Text;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener.Configuration
{
public class RSAEncryptedFileKeyManager : RunnerService, IRSAKeyManager
{
private string _keyFile;
private IHostContext _context;
public RSACryptoServiceProvider CreateKey()
{
RSACryptoServiceProvider rsa = null;
if (!File.Exists(_keyFile))
{
Trace.Info("Creating new RSA key using 2048-bit key length");
rsa = new RSACryptoServiceProvider(2048);
// Now write the parameters to disk
SaveParameters(rsa.ExportParameters(true));
Trace.Info("Successfully saved RSA key parameters to file {0}", _keyFile);
}
else
{
Trace.Info("Found existing RSA key parameters file {0}", _keyFile);
rsa = new RSACryptoServiceProvider();
rsa.ImportParameters(LoadParameters());
}
return rsa;
}
public void DeleteKey()
{
if (File.Exists(_keyFile))
{
Trace.Info("Deleting RSA key parameters file {0}", _keyFile);
File.Delete(_keyFile);
}
}
public RSACryptoServiceProvider GetKey()
{
if (!File.Exists(_keyFile))
{
throw new CryptographicException($"RSA key file {_keyFile} was not found");
}
Trace.Info("Loading RSA key parameters from file {0}", _keyFile);
var rsa = new RSACryptoServiceProvider();
rsa.ImportParameters(LoadParameters());
return rsa;
}
private RSAParameters LoadParameters()
{
var encryptedBytes = File.ReadAllBytes(_keyFile);
var parametersString = Encoding.UTF8.GetString(ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine));
return StringUtil.ConvertFromJson<RSAParametersSerializable>(parametersString).RSAParameters;
}
private void SaveParameters(RSAParameters parameters)
{
var parametersString = StringUtil.ConvertToJson(new RSAParametersSerializable(parameters));
var encryptedBytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(parametersString), null, DataProtectionScope.LocalMachine);
File.WriteAllBytes(_keyFile, encryptedBytes);
File.SetAttributes(_keyFile, File.GetAttributes(_keyFile) | FileAttributes.Hidden);
}
void IRunnerService.Initialize(IHostContext context)
{
base.Initialize(context);
_context = context;
_keyFile = context.GetConfigFile(WellKnownConfigFile.RSACredentials);
}
}
}
#endif

View File

@@ -0,0 +1,97 @@
#if OS_LINUX || OS_OSX
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener.Configuration
{
public class RSAFileKeyManager : RunnerService, IRSAKeyManager
{
private string _keyFile;
private IHostContext _context;
public RSACryptoServiceProvider CreateKey()
{
RSACryptoServiceProvider rsa = null;
if (!File.Exists(_keyFile))
{
Trace.Info("Creating new RSA key using 2048-bit key length");
rsa = new RSACryptoServiceProvider(2048);
// Now write the parameters to disk
IOUtil.SaveObject(new RSAParametersSerializable(rsa.ExportParameters(true)), _keyFile);
Trace.Info("Successfully saved RSA key parameters to file {0}", _keyFile);
// Try to lock down the credentials_key file to the owner/group
var chmodPath = WhichUtil.Which("chmod", trace: Trace);
if (!String.IsNullOrEmpty(chmodPath))
{
var arguments = $"600 {new FileInfo(_keyFile).FullName}";
using (var invoker = _context.CreateService<IProcessInvoker>())
{
var exitCode = invoker.ExecuteAsync(HostContext.GetDirectory(WellKnownDirectory.Root), chmodPath, arguments, null, default(CancellationToken)).GetAwaiter().GetResult();
if (exitCode == 0)
{
Trace.Info("Successfully set permissions for RSA key parameters file {0}", _keyFile);
}
else
{
Trace.Warning("Unable to succesfully set permissions for RSA key parameters file {0}. Received exit code {1} from {2}", _keyFile, exitCode, chmodPath);
}
}
}
else
{
Trace.Warning("Unable to locate chmod to set permissions for RSA key parameters file {0}.", _keyFile);
}
}
else
{
Trace.Info("Found existing RSA key parameters file {0}", _keyFile);
rsa = new RSACryptoServiceProvider();
rsa.ImportParameters(IOUtil.LoadObject<RSAParametersSerializable>(_keyFile).RSAParameters);
}
return rsa;
}
public void DeleteKey()
{
if (File.Exists(_keyFile))
{
Trace.Info("Deleting RSA key parameters file {0}", _keyFile);
File.Delete(_keyFile);
}
}
public RSACryptoServiceProvider GetKey()
{
if (!File.Exists(_keyFile))
{
throw new CryptographicException($"RSA key file {_keyFile} was not found");
}
Trace.Info("Loading RSA key parameters from file {0}", _keyFile);
var parameters = IOUtil.LoadObject<RSAParametersSerializable>(_keyFile).RSAParameters;
var rsa = new RSACryptoServiceProvider();
rsa.ImportParameters(parameters);
return rsa;
}
void IRunnerService.Initialize(IHostContext context)
{
base.Initialize(context);
_context = context;
_keyFile = context.GetConfigFile(WellKnownConfigFile.RSACredentials);
}
}
}
#endif

View File

@@ -0,0 +1,63 @@
using System;
using System.Linq;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener.Configuration
{
#if OS_WINDOWS
[ServiceLocator(Default = typeof(WindowsServiceControlManager))]
public interface IWindowsServiceControlManager : IRunnerService
{
void ConfigureService(RunnerSettings settings, CommandSettings command);
void UnconfigureService();
}
#endif
#if !OS_WINDOWS
#if OS_LINUX
[ServiceLocator(Default = typeof(SystemDControlManager))]
#elif OS_OSX
[ServiceLocator(Default = typeof(OsxServiceControlManager))]
#endif
public interface ILinuxServiceControlManager : IRunnerService
{
void GenerateScripts(RunnerSettings settings);
}
#endif
public class ServiceControlManager : RunnerService
{
public void CalculateServiceName(RunnerSettings settings, string serviceNamePattern, string serviceDisplayNamePattern, out string serviceName, out string serviceDisplayName)
{
Trace.Entering();
serviceName = string.Empty;
serviceDisplayName = string.Empty;
Uri accountUri = new Uri(settings.ServerUrl);
string accountName = string.Empty;
if (accountUri.Host.EndsWith(".githubusercontent.com", StringComparison.OrdinalIgnoreCase))
{
accountName = accountUri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
}
else
{
accountName = accountUri.Host.Split('.').FirstOrDefault();
}
if (string.IsNullOrEmpty(accountName))
{
throw new InvalidOperationException($"Cannot find GitHub organization name from server url: '{settings.ServerUrl}'");
}
serviceName = StringUtil.Format(serviceNamePattern, accountName, settings.PoolName, settings.AgentName);
serviceDisplayName = StringUtil.Format(serviceDisplayNamePattern, accountName, settings.PoolName, settings.AgentName);
Trace.Info($"Service name '{serviceName}' display name '{serviceDisplayName}' will be used for service configuration.");
}
}
}

View File

@@ -0,0 +1,55 @@
#if OS_LINUX
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener.Configuration
{
public class SystemDControlManager : ServiceControlManager, ILinuxServiceControlManager
{
// This is the name you would see when you do `systemctl list-units | grep runner`
private const string _svcNamePattern = "actions.runner.{0}.{1}.{2}.service";
private const string _svcDisplayPattern = "GitHub Actions Runner ({0}.{1}.{2})";
private const string _shTemplate = "systemd.svc.sh.template";
private const string _shName = "svc.sh";
public void GenerateScripts(RunnerSettings settings)
{
try
{
string serviceName;
string serviceDisplayName;
CalculateServiceName(settings, _svcNamePattern, _svcDisplayPattern, out serviceName, out serviceDisplayName);
string svcShPath = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), _shName);
string svcShContent = File.ReadAllText(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), _shTemplate));
var tokensToReplace = new Dictionary<string, string>
{
{ "{{SvcDescription}}", serviceDisplayName },
{ "{{SvcNameVar}}", serviceName }
};
svcShContent = tokensToReplace.Aggregate(
svcShContent,
(current, item) => current.Replace(item.Key, item.Value));
File.WriteAllText(svcShPath, svcShContent, new UTF8Encoding(false));
var unixUtil = HostContext.CreateService<IUnixUtil>();
unixUtil.ChmodAsync("755", svcShPath).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Trace.Error(ex);
throw;
}
}
}
}
#endif

View File

@@ -0,0 +1,94 @@
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using System;
using System.IO;
using System.Security.Principal;
namespace GitHub.Runner.Listener.Configuration
{
public static class Validators
{
private static String UriHttpScheme = "http";
private static String UriHttpsScheme = "https";
public static bool ServerUrlValidator(string value)
{
try
{
Uri uri;
if (Uri.TryCreate(value, UriKind.Absolute, out uri))
{
if (uri.Scheme.Equals(UriHttpScheme, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals(UriHttpsScheme, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
catch (Exception)
{
return false;
}
return false;
}
public static bool AuthSchemeValidator(string value)
{
return CredentialManager.CredentialTypes.ContainsKey(value);
}
public static bool FilePathValidator(string value)
{
var directoryInfo = new DirectoryInfo(value);
if (!directoryInfo.Exists)
{
try
{
Directory.CreateDirectory(value);
}
catch (Exception)
{
return false;
}
}
return true;
}
public static bool BoolValidator(string value)
{
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "Y", StringComparison.CurrentCultureIgnoreCase) ||
string.Equals(value, "N", StringComparison.CurrentCultureIgnoreCase);
}
public static bool NonEmptyValidator(string value)
{
return !string.IsNullOrEmpty(value);
}
public static bool NTAccountValidator(string arg)
{
if (string.IsNullOrEmpty(arg) || String.IsNullOrEmpty(arg.TrimStart('.', '\\')))
{
return false;
}
try
{
var logonAccount = arg.TrimStart('.');
NTAccount ntaccount = new NTAccount(logonAccount);
SecurityIdentifier sid = (SecurityIdentifier)ntaccount.Translate(typeof(SecurityIdentifier));
}
catch (IdentityNotMappedException)
{
return false;
}
return true;
}
}
}

View File

@@ -0,0 +1,172 @@
#if OS_WINDOWS
using System;
using System.IO;
using System.Linq;
using System.Security;
using System.Security.Principal;
using System.Text;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener.Configuration
{
public class WindowsServiceControlManager : ServiceControlManager, IWindowsServiceControlManager
{
public const string WindowsServiceControllerName = "RunnerService.exe";
private const string ServiceNamePattern = "actionsrunner.{0}.{1}.{2}";
private const string ServiceDisplayNamePattern = "GitHub Actions Runner ({0}.{1}.{2})";
private INativeWindowsServiceHelper _windowsServiceHelper;
private ITerminal _term;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_windowsServiceHelper = HostContext.GetService<INativeWindowsServiceHelper>();
_term = HostContext.GetService<ITerminal>();
}
public void ConfigureService(RunnerSettings settings, CommandSettings command)
{
Trace.Entering();
if (!_windowsServiceHelper.IsRunningInElevatedMode())
{
Trace.Error("Needs Administrator privileges for configure runner as windows service.");
throw new SecurityException("Needs Administrator privileges for configuring runner as windows service.");
}
// We use NetworkService as default account for actions runner
NTAccount defaultServiceAccount = _windowsServiceHelper.GetDefaultServiceAccount();
string logonAccount = command.GetWindowsLogonAccount(defaultValue: defaultServiceAccount.ToString(), descriptionMsg: "User account to use for the service");
string domainName;
string userName;
GetAccountSegments(logonAccount, out domainName, out userName);
if ((string.IsNullOrEmpty(domainName) || domainName.Equals(".", StringComparison.CurrentCultureIgnoreCase)) && !logonAccount.Contains('@'))
{
logonAccount = String.Format("{0}\\{1}", Environment.MachineName, userName);
domainName = Environment.MachineName;
}
Trace.Info("LogonAccount after transforming: {0}, user: {1}, domain: {2}", logonAccount, userName, domainName);
string logonPassword = string.Empty;
if (!defaultServiceAccount.Equals(new NTAccount(logonAccount)) && !NativeWindowsServiceHelper.IsWellKnownIdentity(logonAccount))
{
while (true)
{
logonPassword = command.GetWindowsLogonPassword(logonAccount);
if (_windowsServiceHelper.IsValidCredential(domainName, userName, logonPassword))
{
Trace.Info("Credential validation succeed");
break;
}
else
{
if (!command.Unattended)
{
Trace.Info("Invalid credential entered");
_term.WriteLine("Invalid windows credentials entered. Try again or ctrl-c to quit");
}
else
{
throw new SecurityException("Invalid windows credentials entered. Try again or ctrl-c to quit");
}
}
}
}
string serviceName;
string serviceDisplayName;
CalculateServiceName(settings, ServiceNamePattern, ServiceDisplayNamePattern, out serviceName, out serviceDisplayName);
if (_windowsServiceHelper.IsServiceExists(serviceName))
{
_term.WriteLine($"The service already exists: {serviceName}, it will be replaced");
_windowsServiceHelper.UninstallService(serviceName);
}
Trace.Info("Verifying if the account has LogonAsService permission");
if (_windowsServiceHelper.IsUserHasLogonAsServicePrivilege(domainName, userName))
{
Trace.Info($"Account: {logonAccount} already has Logon As Service Privilege.");
}
else
{
if (!_windowsServiceHelper.GrantUserLogonAsServicePrivilege(domainName, userName))
{
throw new InvalidOperationException($"Cannot grant LogonAsService permission to the user {logonAccount}");
}
}
// grant permission for runner root folder and work folder
Trace.Info("Create local group and grant folder permission to service logon account.");
string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
string workFolder = HostContext.GetDirectory(WellKnownDirectory.Work);
Directory.CreateDirectory(workFolder);
_windowsServiceHelper.GrantDirectoryPermissionForAccount(logonAccount, new[] { runnerRoot, workFolder });
_term.WriteLine($"Granting file permissions to '{logonAccount}'.");
// install service.
_windowsServiceHelper.InstallService(serviceName, serviceDisplayName, logonAccount, logonPassword);
// create .service file with service name.
SaveServiceSettings(serviceName);
Trace.Info("Configuration was successful, trying to start the service");
_windowsServiceHelper.StartService(serviceName);
}
public void UnconfigureService()
{
if (!_windowsServiceHelper.IsRunningInElevatedMode())
{
Trace.Error("Needs Administrator privileges for unconfigure windows service runner.");
throw new SecurityException("Needs Administrator privileges for unconfiguring runner that running as windows service.");
}
string serviceConfigPath = HostContext.GetConfigFile(WellKnownConfigFile.Service);
string serviceName = File.ReadAllText(serviceConfigPath);
if (_windowsServiceHelper.IsServiceExists(serviceName))
{
_windowsServiceHelper.StopService(serviceName);
_windowsServiceHelper.UninstallService(serviceName);
// Delete local group we created during configure.
string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
string workFolder = HostContext.GetDirectory(WellKnownDirectory.Work);
_windowsServiceHelper.RevokeDirectoryPermissionForAccount(new[] { runnerRoot, workFolder });
}
IOUtil.DeleteFile(serviceConfigPath);
}
private void SaveServiceSettings(string serviceName)
{
string serviceConfigPath = HostContext.GetConfigFile(WellKnownConfigFile.Service);
if (File.Exists(serviceConfigPath))
{
IOUtil.DeleteFile(serviceConfigPath);
}
File.WriteAllText(serviceConfigPath, serviceName, new UTF8Encoding(false));
File.SetAttributes(serviceConfigPath, File.GetAttributes(serviceConfigPath) | FileAttributes.Hidden);
}
private void GetAccountSegments(string account, out string domain, out string user)
{
string[] segments = account.Split('\\');
domain = string.Empty;
user = account;
if (segments.Length == 2)
{
domain = segments[0];
user = segments[1];
}
}
}
}
#endif

View File

@@ -0,0 +1,909 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Services.WebApi;
using Pipelines = GitHub.DistributedTask.Pipelines;
using System.Linq;
using GitHub.Services.Common;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener
{
[ServiceLocator(Default = typeof(JobDispatcher))]
public interface IJobDispatcher : IRunnerService
{
TaskCompletionSource<bool> RunOnceJobCompleted { get; }
void Run(Pipelines.AgentJobRequestMessage message, bool runOnce = false);
bool Cancel(JobCancelMessage message);
Task WaitAsync(CancellationToken token);
TaskResult GetLocalRunJobResult(AgentJobRequestMessage message);
Task ShutdownAsync();
}
// This implementation of IDobDispatcher is not thread safe.
// It is base on the fact that the current design of runner is dequeue
// and process one message from message queue everytime.
// In addition, it only execute one job every time,
// and server will not send another job while this one is still running.
public sealed class JobDispatcher : RunnerService, IJobDispatcher
{
private readonly Lazy<Dictionary<long, TaskResult>> _localRunJobResult = new Lazy<Dictionary<long, TaskResult>>();
private int _poolId;
RunnerSettings _runnerSetting;
private static readonly string _workerProcessName = $"Runner.Worker{IOUtil.ExeExtension}";
// this is not thread-safe
private readonly Queue<Guid> _jobDispatchedQueue = new Queue<Guid>();
private readonly ConcurrentDictionary<Guid, WorkerDispatcher> _jobInfos = new ConcurrentDictionary<Guid, WorkerDispatcher>();
//allow up to 30sec for any data to be transmitted over the process channel
//timeout limit can be overwrite by environment GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT
private TimeSpan _channelTimeout;
private TaskCompletionSource<bool> _runOnceJobCompleted = new TaskCompletionSource<bool>();
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
// get pool id from config
var configurationStore = hostContext.GetService<IConfigurationStore>();
_runnerSetting = configurationStore.GetSettings();
_poolId = _runnerSetting.PoolId;
int channelTimeoutSeconds;
if (!int.TryParse(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT") ?? string.Empty, out channelTimeoutSeconds))
{
channelTimeoutSeconds = 30;
}
// _channelTimeout should in range [30, 300] seconds
_channelTimeout = TimeSpan.FromSeconds(Math.Min(Math.Max(channelTimeoutSeconds, 30), 300));
Trace.Info($"Set runner/worker IPC timeout to {_channelTimeout.TotalSeconds} seconds.");
}
public TaskCompletionSource<bool> RunOnceJobCompleted => _runOnceJobCompleted;
public void Run(Pipelines.AgentJobRequestMessage jobRequestMessage, bool runOnce = false)
{
Trace.Info($"Job request {jobRequestMessage.RequestId} for plan {jobRequestMessage.Plan.PlanId} job {jobRequestMessage.JobId} received.");
WorkerDispatcher currentDispatch = null;
if (_jobDispatchedQueue.Count > 0)
{
Guid dispatchedJobId = _jobDispatchedQueue.Dequeue();
if (_jobInfos.TryGetValue(dispatchedJobId, out currentDispatch))
{
Trace.Verbose($"Retrive previous WorkerDispather for job {currentDispatch.JobId}.");
}
}
WorkerDispatcher newDispatch = new WorkerDispatcher(jobRequestMessage.JobId, jobRequestMessage.RequestId);
if (runOnce)
{
Trace.Info("Start dispatcher for one time used runner.");
newDispatch.WorkerDispatch = RunOnceAsync(jobRequestMessage, currentDispatch, newDispatch.WorkerCancellationTokenSource.Token, newDispatch.WorkerCancelTimeoutKillTokenSource.Token);
}
else
{
newDispatch.WorkerDispatch = RunAsync(jobRequestMessage, currentDispatch, newDispatch.WorkerCancellationTokenSource.Token, newDispatch.WorkerCancelTimeoutKillTokenSource.Token);
}
_jobInfos.TryAdd(newDispatch.JobId, newDispatch);
_jobDispatchedQueue.Enqueue(newDispatch.JobId);
}
public bool Cancel(JobCancelMessage jobCancelMessage)
{
Trace.Info($"Job cancellation request {jobCancelMessage.JobId} received, cancellation timeout {jobCancelMessage.Timeout.TotalMinutes} minutes.");
WorkerDispatcher workerDispatcher;
if (!_jobInfos.TryGetValue(jobCancelMessage.JobId, out workerDispatcher))
{
Trace.Verbose($"Job request {jobCancelMessage.JobId} is not a current running job, ignore cancllation request.");
return false;
}
else
{
if (workerDispatcher.Cancel(jobCancelMessage.Timeout))
{
Trace.Verbose($"Fired cancellation token for job request {workerDispatcher.JobId}.");
}
return true;
}
}
public async Task WaitAsync(CancellationToken token)
{
WorkerDispatcher currentDispatch = null;
Guid dispatchedJobId;
if (_jobDispatchedQueue.Count > 0)
{
dispatchedJobId = _jobDispatchedQueue.Dequeue();
if (_jobInfos.TryGetValue(dispatchedJobId, out currentDispatch))
{
Trace.Verbose($"Retrive previous WorkerDispather for job {currentDispatch.JobId}.");
}
}
else
{
Trace.Verbose($"There is no running WorkerDispather needs to await.");
}
if (currentDispatch != null)
{
using (var registration = token.Register(() => { if (currentDispatch.Cancel(TimeSpan.FromSeconds(60))) { Trace.Verbose($"Fired cancellation token for job request {currentDispatch.JobId}."); } }))
{
try
{
Trace.Info($"Waiting WorkerDispather for job {currentDispatch.JobId} run to finish.");
await currentDispatch.WorkerDispatch;
Trace.Info($"Job request {currentDispatch.JobId} processed succeed.");
}
catch (Exception ex)
{
Trace.Error($"Worker Dispatch failed with an exception for job request {currentDispatch.JobId}.");
Trace.Error(ex);
}
finally
{
WorkerDispatcher workerDispatcher;
if (_jobInfos.TryRemove(currentDispatch.JobId, out workerDispatcher))
{
Trace.Verbose($"Remove WorkerDispather from {nameof(_jobInfos)} dictionary for job {currentDispatch.JobId}.");
workerDispatcher.Dispose();
}
}
}
}
}
public TaskResult GetLocalRunJobResult(AgentJobRequestMessage message)
{
return _localRunJobResult.Value[message.RequestId];
}
public async Task ShutdownAsync()
{
Trace.Info($"Shutting down JobDispatcher. Make sure all WorkerDispatcher has finished.");
WorkerDispatcher currentDispatch = null;
if (_jobDispatchedQueue.Count > 0)
{
Guid dispatchedJobId = _jobDispatchedQueue.Dequeue();
if (_jobInfos.TryGetValue(dispatchedJobId, out currentDispatch))
{
try
{
Trace.Info($"Ensure WorkerDispather for job {currentDispatch.JobId} run to finish, cancel any running job.");
await EnsureDispatchFinished(currentDispatch, cancelRunningJob: true);
}
catch (Exception ex)
{
Trace.Error($"Catching worker dispatch exception for job request {currentDispatch.JobId} durning job dispatcher shut down.");
Trace.Error(ex);
}
finally
{
WorkerDispatcher workerDispatcher;
if (_jobInfos.TryRemove(currentDispatch.JobId, out workerDispatcher))
{
Trace.Verbose($"Remove WorkerDispather from {nameof(_jobInfos)} dictionary for job {currentDispatch.JobId}.");
workerDispatcher.Dispose();
}
}
}
}
}
private async Task EnsureDispatchFinished(WorkerDispatcher jobDispatch, bool cancelRunningJob = false)
{
if (!jobDispatch.WorkerDispatch.IsCompleted)
{
if (cancelRunningJob)
{
// cancel running job when shutting down the runner.
// this will happen when runner get Ctrl+C or message queue loop crashed.
jobDispatch.WorkerCancellationTokenSource.Cancel();
// wait for worker process exit then return.
await jobDispatch.WorkerDispatch;
return;
}
// base on the current design, server will only send one job for a given runner everytime.
// if the runner received a new job request while a previous job request is still running, this typically indicate two situations
// 1. an runner bug cause server and runner mismatch on the state of the job request, ex. runner not renew jobrequest properly but think it still own the job reqest, however server already abandon the jobrequest.
// 2. a server bug or design change that allow server send more than one job request to an given runner that haven't finish previous job request.
var runnerServer = HostContext.GetService<IRunnerServer>();
TaskAgentJobRequest request = null;
try
{
request = await runnerServer.GetAgentRequestAsync(_poolId, jobDispatch.RequestId, CancellationToken.None);
}
catch (Exception ex)
{
// we can't even query for the jobrequest from server, something totally busted, stop runner/worker.
Trace.Error($"Catch exception while checking jobrequest {jobDispatch.JobId} status. Cancel running worker right away.");
Trace.Error(ex);
jobDispatch.WorkerCancellationTokenSource.Cancel();
// make sure worker process exit before we rethrow, otherwise we might leave orphan worker process behind.
await jobDispatch.WorkerDispatch;
// rethrow original exception
throw;
}
if (request.Result != null)
{
// job request has been finished, the server already has result.
// this means runner is busted since it still running that request.
// cancel the zombie worker, run next job request.
Trace.Error($"Received job request while previous job {jobDispatch.JobId} still running on worker. Cancel the previous job since the job request have been finished on server side with result: {request.Result.Value}.");
jobDispatch.WorkerCancellationTokenSource.Cancel();
// wait 45 sec for worker to finish.
Task completedTask = await Task.WhenAny(jobDispatch.WorkerDispatch, Task.Delay(TimeSpan.FromSeconds(45)));
if (completedTask != jobDispatch.WorkerDispatch)
{
// at this point, the job exectuion might encounter some dead lock and even not able to be canclled.
// no need to localize the exception string should never happen.
throw new InvalidOperationException($"Job dispatch process for {jobDispatch.JobId} has encountered unexpected error, the dispatch task is not able to be canceled within 45 seconds.");
}
}
else
{
// something seriously wrong on server side. stop runner from continue running.
// no need to localize the exception string should never happen.
throw new InvalidOperationException($"Server send a new job request while the previous job request {jobDispatch.JobId} haven't finished.");
}
}
try
{
await jobDispatch.WorkerDispatch;
Trace.Info($"Job request {jobDispatch.JobId} processed succeed.");
}
catch (Exception ex)
{
Trace.Error($"Worker Dispatch failed with an exception for job request {jobDispatch.JobId}.");
Trace.Error(ex);
}
finally
{
WorkerDispatcher workerDispatcher;
if (_jobInfos.TryRemove(jobDispatch.JobId, out workerDispatcher))
{
Trace.Verbose($"Remove WorkerDispather from {nameof(_jobInfos)} dictionary for job {jobDispatch.JobId}.");
workerDispatcher.Dispose();
}
}
}
private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
try
{
await RunAsync(message, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
}
finally
{
Trace.Info("Fire signal for one time used runner.");
_runOnceJobCompleted.TrySetResult(true);
}
}
private async Task RunAsync(Pipelines.AgentJobRequestMessage message, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
if (previousJobDispatch != null)
{
Trace.Verbose($"Make sure the previous job request {previousJobDispatch.JobId} has successfully finished on worker.");
await EnsureDispatchFinished(previousJobDispatch);
}
else
{
Trace.Verbose($"This is the first job request.");
}
var term = HostContext.GetService<ITerminal>();
term.WriteLine($"{DateTime.UtcNow:u}: Running job: {message.JobDisplayName}");
// first job request renew succeed.
TaskCompletionSource<int> firstJobRequestRenewed = new TaskCompletionSource<int>();
var notification = HostContext.GetService<IJobNotification>();
// lock renew cancellation token.
using (var lockRenewalTokenSource = new CancellationTokenSource())
using (var workerProcessCancelTokenSource = new CancellationTokenSource())
{
long requestId = message.RequestId;
Guid lockToken = Guid.Empty; // lockToken has never been used, keep this here of compat
// start renew job request
Trace.Info($"Start renew job request {requestId} for job {message.JobId}.");
Task renewJobRequest = RenewJobRequestAsync(_poolId, requestId, lockToken, firstJobRequestRenewed, lockRenewalTokenSource.Token);
// wait till first renew succeed or job request is canceled
// not even start worker if the first renew fail
await Task.WhenAny(firstJobRequestRenewed.Task, renewJobRequest, Task.Delay(-1, jobRequestCancellationToken));
if (renewJobRequest.IsCompleted)
{
// renew job request task complete means we run out of retry for the first job request renew.
Trace.Info($"Unable to renew job request for job {message.JobId} for the first time, stop dispatching job to worker.");
return;
}
if (jobRequestCancellationToken.IsCancellationRequested)
{
Trace.Info($"Stop renew job request for job {message.JobId}.");
// stop renew lock
lockRenewalTokenSource.Cancel();
// renew job request should never blows up.
await renewJobRequest;
// complete job request with result Cancelled
await CompleteJobRequestAsync(_poolId, message, lockToken, TaskResult.Canceled);
return;
}
HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}");
Task<int> workerProcessTask = null;
object _outputLock = new object();
List<string> workerOutput = new List<string>();
using (var processChannel = HostContext.CreateService<IProcessChannel>())
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
{
// Start the process channel.
// It's OK if StartServer bubbles an execption after the worker process has already started.
// The worker will shutdown after 30 seconds if it hasn't received the job message.
processChannel.StartServer(
// Delegate to start the child process.
startProcess: (string pipeHandleOut, string pipeHandleIn) =>
{
// Validate args.
ArgUtil.NotNullOrEmpty(pipeHandleOut, nameof(pipeHandleOut));
ArgUtil.NotNullOrEmpty(pipeHandleIn, nameof(pipeHandleIn));
if (HostContext.RunMode == RunMode.Normal)
{
// Save STDOUT from worker, worker will use STDOUT report unhandle exception.
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
{
if (!string.IsNullOrEmpty(stdout.Data))
{
lock (_outputLock)
{
workerOutput.Add(stdout.Data);
}
}
};
// Save STDERR from worker, worker will use STDERR on crash.
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
{
if (!string.IsNullOrEmpty(stderr.Data))
{
lock (_outputLock)
{
workerOutput.Add(stderr.Data);
}
}
};
}
else if (HostContext.RunMode == RunMode.Local)
{
processInvoker.OutputDataReceived += (object sender, ProcessDataReceivedEventArgs e) => Console.WriteLine(e.Data);
processInvoker.ErrorDataReceived += (object sender, ProcessDataReceivedEventArgs e) => Console.WriteLine(e.Data);
}
// Start the child process.
HostContext.WritePerfCounter("StartingWorkerProcess");
var assemblyDirectory = HostContext.GetDirectory(WellKnownDirectory.Bin);
string workerFileName = Path.Combine(assemblyDirectory, _workerProcessName);
workerProcessTask = processInvoker.ExecuteAsync(
workingDirectory: assemblyDirectory,
fileName: workerFileName,
arguments: "spawnclient " + pipeHandleOut + " " + pipeHandleIn,
environment: null,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
redirectStandardIn: null,
inheritConsoleHandler: false,
keepStandardInOpen: false,
highPriorityProcess: true,
cancellationToken: workerProcessCancelTokenSource.Token);
});
// Send the job request message.
// Kill the worker process if sending the job message times out. The worker
// process may have successfully received the job message.
try
{
Trace.Info($"Send job request message to worker for job {message.JobId}.");
HostContext.WritePerfCounter($"RunnerSendingJobToWorker_{message.JobId}");
using (var csSendJobRequest = new CancellationTokenSource(_channelTimeout))
{
await processChannel.SendAsync(
messageType: MessageType.NewJobRequest,
body: JsonUtility.ToString(message),
cancellationToken: csSendJobRequest.Token);
}
}
catch (OperationCanceledException)
{
// message send been cancelled.
// timeout 30 sec. kill worker.
Trace.Info($"Job request message sending for job {message.JobId} been cancelled, kill running worker.");
workerProcessCancelTokenSource.Cancel();
try
{
await workerProcessTask;
}
catch (OperationCanceledException)
{
Trace.Info("worker process has been killed.");
}
Trace.Info($"Stop renew job request for job {message.JobId}.");
// stop renew lock
lockRenewalTokenSource.Cancel();
// renew job request should never blows up.
await renewJobRequest;
// not finish the job request since the job haven't run on worker at all, we will not going to set a result to server.
return;
}
// we get first jobrequest renew succeed and start the worker process with the job message.
// send notification to machine provisioner.
var systemConnection = message.Resources.Endpoints.SingleOrDefault(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
var accessToken = systemConnection?.Authorization?.Parameters["AccessToken"];
await notification.JobStarted(message.JobId, accessToken, systemConnection.Url);
HostContext.WritePerfCounter($"SentJobToWorker_{requestId.ToString()}");
try
{
TaskResult resultOnAbandonOrCancel = TaskResult.Succeeded;
// wait for renewlock, worker process or cancellation token been fired.
var completedTask = await Task.WhenAny(renewJobRequest, workerProcessTask, Task.Delay(-1, jobRequestCancellationToken));
if (completedTask == workerProcessTask)
{
// worker finished successfully, complete job request with result, attach unhandled exception reported by worker, stop renew lock, job has finished.
int returnCode = await workerProcessTask;
Trace.Info($"Worker finished for job {message.JobId}. Code: " + returnCode);
string detailInfo = null;
if (!TaskResultUtil.IsValidReturnCode(returnCode))
{
detailInfo = string.Join(Environment.NewLine, workerOutput);
Trace.Info($"Return code {returnCode} indicate worker encounter an unhandled exception or app crash, attach worker stdout/stderr to JobRequest result.");
await LogWorkerProcessUnhandledException(message, detailInfo);
}
TaskResult result = TaskResultUtil.TranslateFromReturnCode(returnCode);
Trace.Info($"finish job request for job {message.JobId} with result: {result}");
term.WriteLine($"{DateTime.UtcNow:u}: Job {message.JobDisplayName} completed with result: {result}");
Trace.Info($"Stop renew job request for job {message.JobId}.");
// stop renew lock
lockRenewalTokenSource.Cancel();
// renew job request should never blows up.
await renewJobRequest;
// complete job request
await CompleteJobRequestAsync(_poolId, message, lockToken, result, detailInfo);
// print out unhandled exception happened in worker after we complete job request.
// when we run out of disk space, report back to server has higher priority.
if (!string.IsNullOrEmpty(detailInfo))
{
Trace.Error("Unhandled exception happened in worker:");
Trace.Error(detailInfo);
}
return;
}
else if (completedTask == renewJobRequest)
{
resultOnAbandonOrCancel = TaskResult.Abandoned;
}
else
{
resultOnAbandonOrCancel = TaskResult.Canceled;
}
// renew job request completed or job request cancellation token been fired for RunAsync(jobrequestmessage)
// cancel worker gracefully first, then kill it after worker cancel timeout
try
{
Trace.Info($"Send job cancellation message to worker for job {message.JobId}.");
using (var csSendCancel = new CancellationTokenSource(_channelTimeout))
{
var messageType = MessageType.CancelRequest;
if (HostContext.RunnerShutdownToken.IsCancellationRequested)
{
switch (HostContext.RunnerShutdownReason)
{
case ShutdownReason.UserCancelled:
messageType = MessageType.RunnerShutdown;
break;
case ShutdownReason.OperatingSystemShutdown:
messageType = MessageType.OperatingSystemShutdown;
break;
}
}
await processChannel.SendAsync(
messageType: messageType,
body: string.Empty,
cancellationToken: csSendCancel.Token);
}
}
catch (OperationCanceledException)
{
// message send been cancelled.
Trace.Info($"Job cancel message sending for job {message.JobId} been cancelled, kill running worker.");
workerProcessCancelTokenSource.Cancel();
try
{
await workerProcessTask;
}
catch (OperationCanceledException)
{
Trace.Info("worker process has been killed.");
}
}
// wait worker to exit
// if worker doesn't exit within timeout, then kill worker.
completedTask = await Task.WhenAny(workerProcessTask, Task.Delay(-1, workerCancelTimeoutKillToken));
// worker haven't exit within cancellation timeout.
if (completedTask != workerProcessTask)
{
Trace.Info($"worker process for job {message.JobId} haven't exit within cancellation timout, kill running worker.");
workerProcessCancelTokenSource.Cancel();
try
{
await workerProcessTask;
}
catch (OperationCanceledException)
{
Trace.Info("worker process has been killed.");
}
}
Trace.Info($"finish job request for job {message.JobId} with result: {resultOnAbandonOrCancel}");
term.WriteLine($"{DateTime.UtcNow:u}: Job {message.JobDisplayName} completed with result: {resultOnAbandonOrCancel}");
// complete job request with cancel result, stop renew lock, job has finished.
Trace.Info($"Stop renew job request for job {message.JobId}.");
// stop renew lock
lockRenewalTokenSource.Cancel();
// renew job request should never blows up.
await renewJobRequest;
// complete job request
await CompleteJobRequestAsync(_poolId, message, lockToken, resultOnAbandonOrCancel);
}
finally
{
// This should be the last thing to run so we don't notify external parties until actually finished
await notification.JobCompleted(message.JobId);
}
}
}
}
public async Task RenewJobRequestAsync(int poolId, long requestId, Guid lockToken, TaskCompletionSource<int> firstJobRequestRenewed, CancellationToken token)
{
var runnerServer = HostContext.GetService<IRunnerServer>();
TaskAgentJobRequest request = null;
int firstRenewRetryLimit = 5;
int encounteringError = 0;
// renew lock during job running.
// stop renew only if cancellation token for lock renew task been signal or exception still happen after retry.
while (!token.IsCancellationRequested)
{
try
{
request = await runnerServer.RenewAgentRequestAsync(poolId, requestId, lockToken, token);
Trace.Info($"Successfully renew job request {requestId}, job is valid till {request.LockedUntil.Value}");
if (!firstJobRequestRenewed.Task.IsCompleted)
{
// fire first renew succeed event.
firstJobRequestRenewed.TrySetResult(0);
}
if (encounteringError > 0)
{
encounteringError = 0;
runnerServer.SetConnectionTimeout(RunnerConnectionType.JobRequest, TimeSpan.FromSeconds(60));
HostContext.WritePerfCounter("JobRenewRecovered");
}
// renew again after 60 sec delay
await HostContext.Delay(TimeSpan.FromSeconds(60), token);
}
catch (TaskAgentJobNotFoundException)
{
// no need for retry. the job is not valid anymore.
Trace.Info($"TaskAgentJobNotFoundException received when renew job request {requestId}, job is no longer valid, stop renew job request.");
return;
}
catch (TaskAgentJobTokenExpiredException)
{
// no need for retry. the job is not valid anymore.
Trace.Info($"TaskAgentJobTokenExpiredException received renew job request {requestId}, job is no longer valid, stop renew job request.");
return;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// OperationCanceledException may caused by http timeout or _lockRenewalTokenSource.Cance();
// Stop renew only on cancellation token fired.
Trace.Info($"job renew has been canceled, stop renew job request {requestId}.");
return;
}
catch (Exception ex)
{
Trace.Error($"Catch exception during renew runner jobrequest {requestId}.");
Trace.Error(ex);
encounteringError++;
// retry
TimeSpan remainingTime = TimeSpan.Zero;
if (!firstJobRequestRenewed.Task.IsCompleted)
{
// retry 5 times every 10 sec for the first renew
if (firstRenewRetryLimit-- > 0)
{
remainingTime = TimeSpan.FromSeconds(10);
}
}
else
{
// retry till reach lockeduntil + 5 mins extra buffer.
remainingTime = request.LockedUntil.Value + TimeSpan.FromMinutes(5) - DateTime.UtcNow;
}
if (remainingTime > TimeSpan.Zero)
{
TimeSpan delayTime;
if (!firstJobRequestRenewed.Task.IsCompleted)
{
Trace.Info($"Retrying lock renewal for jobrequest {requestId}. The first job renew request has failed.");
delayTime = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
}
else
{
Trace.Info($"Retrying lock renewal for jobrequest {requestId}. Job is valid until {request.LockedUntil.Value}.");
if (encounteringError > 5)
{
delayTime = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30));
}
else
{
delayTime = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
}
}
// Re-establish connection to server in order to avoid affinity with server.
// Reduce connection timeout to 30 seconds (from 60s)
HostContext.WritePerfCounter("ResetJobRenewConnection");
await runnerServer.RefreshConnectionAsync(RunnerConnectionType.JobRequest, TimeSpan.FromSeconds(30));
try
{
// back-off before next retry.
await HostContext.Delay(delayTime, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
Trace.Info($"job renew has been canceled, stop renew job request {requestId}.");
}
}
else
{
Trace.Info($"Lock renewal has run out of retry, stop renew lock for jobrequest {requestId}.");
HostContext.WritePerfCounter("JobRenewReachLimit");
return;
}
}
}
}
// TODO: We need send detailInfo back to DT in order to add an issue for the job
private async Task CompleteJobRequestAsync(int poolId, Pipelines.AgentJobRequestMessage message, Guid lockToken, TaskResult result, string detailInfo = null)
{
Trace.Entering();
if (HostContext.RunMode == RunMode.Local)
{
_localRunJobResult.Value[message.RequestId] = result;
return;
}
if (PlanUtil.GetFeatures(message.Plan).HasFlag(PlanFeatures.JobCompletedPlanEvent))
{
Trace.Verbose($"Skip FinishAgentRequest call from Listener because Plan version is {message.Plan.Version}");
return;
}
var runnerServer = HostContext.GetService<IRunnerServer>();
int completeJobRequestRetryLimit = 5;
List<Exception> exceptions = new List<Exception>();
while (completeJobRequestRetryLimit-- > 0)
{
try
{
await runnerServer.FinishAgentRequestAsync(poolId, message.RequestId, lockToken, DateTime.UtcNow, result, CancellationToken.None);
return;
}
catch (TaskAgentJobNotFoundException)
{
Trace.Info($"TaskAgentJobNotFoundException received, job {message.JobId} is no longer valid.");
return;
}
catch (TaskAgentJobTokenExpiredException)
{
Trace.Info($"TaskAgentJobTokenExpiredException received, job {message.JobId} is no longer valid.");
return;
}
catch (Exception ex)
{
Trace.Error($"Catch exception during complete runner jobrequest {message.RequestId}.");
Trace.Error(ex);
exceptions.Add(ex);
}
// delay 5 seconds before next retry.
await Task.Delay(TimeSpan.FromSeconds(5));
}
// rethrow all catched exceptions during retry.
throw new AggregateException(exceptions);
}
// log an error issue to job level timeline record
private async Task LogWorkerProcessUnhandledException(Pipelines.AgentJobRequestMessage message, string errorMessage)
{
try
{
var systemConnection = message.Resources.Endpoints.SingleOrDefault(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection));
ArgUtil.NotNull(systemConnection, nameof(systemConnection));
var jobServer = HostContext.GetService<IJobServer>();
VssCredentials jobServerCredential = VssUtil.GetVssCredential(systemConnection);
Uri jobServerUrl = systemConnection.Url;
// Make sure SystemConnection Url match Config Url base for OnPremises server
if (!message.Variables.ContainsKey(Constants.Variables.System.ServerType) ||
string.Equals(message.Variables[Constants.Variables.System.ServerType]?.Value, "OnPremises", StringComparison.OrdinalIgnoreCase))
{
try
{
Uri result = null;
Uri configUri = new Uri(_runnerSetting.ServerUrl);
if (Uri.TryCreate(new Uri(configUri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)), jobServerUrl.PathAndQuery, out result))
{
//replace the schema and host portion of messageUri with the host from the
//server URI (which was set at config time)
jobServerUrl = result;
}
}
catch (InvalidOperationException ex)
{
//cannot parse the Uri - not a fatal error
Trace.Error(ex);
}
catch (UriFormatException ex)
{
//cannot parse the Uri - not a fatal error
Trace.Error(ex);
}
}
VssConnection jobConnection = VssUtil.CreateConnection(jobServerUrl, jobServerCredential);
await jobServer.ConnectAsync(jobConnection);
var timeline = await jobServer.GetTimelineAsync(message.Plan.ScopeIdentifier, message.Plan.PlanType, message.Plan.PlanId, message.Timeline.Id, CancellationToken.None);
ArgUtil.NotNull(timeline, nameof(timeline));
TimelineRecord jobRecord = timeline.Records.FirstOrDefault(x => x.Id == message.JobId && x.RecordType == "Job");
ArgUtil.NotNull(jobRecord, nameof(jobRecord));
jobRecord.ErrorCount++;
jobRecord.Issues.Add(new Issue() { Type = IssueType.Error, Message = errorMessage });
await jobServer.UpdateTimelineRecordsAsync(message.Plan.ScopeIdentifier, message.Plan.PlanType, message.Plan.PlanId, message.Timeline.Id, new TimelineRecord[] { jobRecord }, CancellationToken.None);
}
catch (Exception ex)
{
Trace.Error("Fail to report unhandled exception from Runner.Worker process");
Trace.Error(ex);
}
}
private class WorkerDispatcher : IDisposable
{
public long RequestId { get; }
public Guid JobId { get; }
public Task WorkerDispatch { get; set; }
public CancellationTokenSource WorkerCancellationTokenSource { get; private set; }
public CancellationTokenSource WorkerCancelTimeoutKillTokenSource { get; private set; }
private readonly object _lock = new object();
public WorkerDispatcher(Guid jobId, long requestId)
{
JobId = jobId;
RequestId = requestId;
WorkerCancelTimeoutKillTokenSource = new CancellationTokenSource();
WorkerCancellationTokenSource = new CancellationTokenSource();
}
public bool Cancel(TimeSpan timeout)
{
if (WorkerCancellationTokenSource != null && WorkerCancelTimeoutKillTokenSource != null)
{
lock (_lock)
{
if (WorkerCancellationTokenSource != null && WorkerCancelTimeoutKillTokenSource != null)
{
WorkerCancellationTokenSource.Cancel();
// make sure we have at least 60 seconds for cancellation.
if (timeout.TotalSeconds < 60)
{
timeout = TimeSpan.FromSeconds(60);
}
WorkerCancelTimeoutKillTokenSource.CancelAfter(timeout.Subtract(TimeSpan.FromSeconds(15)));
return true;
}
}
}
return false;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing)
{
if (disposing)
{
if (WorkerCancellationTokenSource != null || WorkerCancelTimeoutKillTokenSource != null)
{
lock (_lock)
{
if (WorkerCancellationTokenSource != null)
{
WorkerCancellationTokenSource.Dispose();
WorkerCancellationTokenSource = null;
}
if (WorkerCancelTimeoutKillTokenSource != null)
{
WorkerCancelTimeoutKillTokenSource.Dispose();
WorkerCancelTimeoutKillTokenSource = null;
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,407 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Capabilities;
using GitHub.Runner.Listener.Configuration;
using GitHub.Runner.Common.Util;
using GitHub.Services.Common;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using System.IO;
using System.Text;
using GitHub.Services.WebApi;
using GitHub.Services.OAuth;
using System.Diagnostics;
using System.Runtime.InteropServices;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener
{
[ServiceLocator(Default = typeof(MessageListener))]
public interface IMessageListener : IRunnerService
{
Task<Boolean> CreateSessionAsync(CancellationToken token);
Task DeleteSessionAsync();
Task<TaskAgentMessage> GetNextMessageAsync(CancellationToken token);
Task DeleteMessageAsync(TaskAgentMessage message);
}
public sealed class MessageListener : RunnerService, IMessageListener
{
private long? _lastMessageId;
private RunnerSettings _settings;
private ITerminal _term;
private IRunnerServer _runnerServer;
private TaskAgentSession _session;
private TimeSpan _getNextMessageRetryInterval;
private readonly TimeSpan _sessionCreationRetryInterval = TimeSpan.FromSeconds(30);
private readonly TimeSpan _sessionConflictRetryLimit = TimeSpan.FromMinutes(4);
private readonly TimeSpan _clockSkewRetryLimit = TimeSpan.FromMinutes(30);
private readonly Dictionary<string, int> _sessionCreationExceptionTracker = new Dictionary<string, int>();
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_term = HostContext.GetService<ITerminal>();
_runnerServer = HostContext.GetService<IRunnerServer>();
}
public async Task<Boolean> CreateSessionAsync(CancellationToken token)
{
Trace.Entering();
// Settings
var configManager = HostContext.GetService<IConfigurationManager>();
_settings = configManager.LoadSettings();
var serverUrl = _settings.ServerUrl;
Trace.Info(_settings);
// Capabilities.
Dictionary<string, string> systemCapabilities = await HostContext.GetService<ICapabilitiesManager>().GetCapabilitiesAsync(_settings, token);
// Create connection.
Trace.Info("Loading Credentials");
var credMgr = HostContext.GetService<ICredentialManager>();
VssCredentials creds = credMgr.LoadCredentials();
var agent = new TaskAgentReference
{
Id = _settings.AgentId,
Name = _settings.AgentName,
Version = BuildConstants.RunnerPackage.Version,
OSDescription = RuntimeInformation.OSDescription,
};
string sessionName = $"{Environment.MachineName ?? "RUNNER"}";
var taskAgentSession = new TaskAgentSession(sessionName, agent, systemCapabilities);
string errorMessage = string.Empty;
bool encounteringError = false;
while (true)
{
token.ThrowIfCancellationRequested();
Trace.Info($"Attempt to create session.");
try
{
Trace.Info("Connecting to the Agent Server...");
await _runnerServer.ConnectAsync(new Uri(serverUrl), creds);
Trace.Info("VssConnection created");
_term.WriteLine();
_term.WriteSuccessMessage("Connected to GitHub");
_term.WriteLine();
_session = await _runnerServer.CreateAgentSessionAsync(
_settings.PoolId,
taskAgentSession,
token);
Trace.Info($"Session created.");
if (encounteringError)
{
_term.WriteLine($"{DateTime.UtcNow:u}: Runner reconnected.");
_sessionCreationExceptionTracker.Clear();
encounteringError = false;
}
return true;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
Trace.Info("Session creation has been cancelled.");
throw;
}
catch (TaskAgentAccessTokenExpiredException)
{
Trace.Info("Agent OAuth token has been revoked. Session creation failed.");
throw;
}
catch (Exception ex)
{
Trace.Error("Catch exception during create session.");
Trace.Error(ex);
if (!IsSessionCreationExceptionRetriable(ex))
{
_term.WriteError($"Failed to create session. {ex.Message}");
return false;
}
if (!encounteringError) //print the message only on the first error
{
_term.WriteError($"{DateTime.UtcNow:u}: Runner connect error: {ex.Message}. Retrying until reconnected.");
encounteringError = true;
}
Trace.Info("Sleeping for {0} seconds before retrying.", _sessionCreationRetryInterval.TotalSeconds);
await HostContext.Delay(_sessionCreationRetryInterval, token);
}
}
}
public async Task DeleteSessionAsync()
{
if (_session != null && _session.SessionId != Guid.Empty)
{
using (var ts = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{
await _runnerServer.DeleteAgentSessionAsync(_settings.PoolId, _session.SessionId, ts.Token);
}
}
}
public async Task<TaskAgentMessage> GetNextMessageAsync(CancellationToken token)
{
Trace.Entering();
ArgUtil.NotNull(_session, nameof(_session));
ArgUtil.NotNull(_settings, nameof(_settings));
bool encounteringError = false;
int continuousError = 0;
string errorMessage = string.Empty;
Stopwatch heartbeat = new Stopwatch();
heartbeat.Restart();
while (true)
{
token.ThrowIfCancellationRequested();
TaskAgentMessage message = null;
try
{
message = await _runnerServer.GetAgentMessageAsync(_settings.PoolId,
_session.SessionId,
_lastMessageId,
token);
// Decrypt the message body if the session is using encryption
message = DecryptMessage(message);
if (message != null)
{
_lastMessageId = message.MessageId;
}
if (encounteringError) //print the message once only if there was an error
{
_term.WriteLine($"{DateTime.UtcNow:u}: Runner reconnected.");
encounteringError = false;
continuousError = 0;
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
Trace.Info("Get next message has been cancelled.");
throw;
}
catch (TaskAgentAccessTokenExpiredException)
{
Trace.Info("Agent OAuth token has been revoked. Unable to pull message.");
throw;
}
catch (Exception ex)
{
Trace.Error("Catch exception during get next message.");
Trace.Error(ex);
// don't retry if SkipSessionRecover = true, DT service will delete agent session to stop agent from taking more jobs.
if (ex is TaskAgentSessionExpiredException && !_settings.SkipSessionRecover && await CreateSessionAsync(token))
{
Trace.Info($"{nameof(TaskAgentSessionExpiredException)} received, recovered by recreate session.");
}
else if (!IsGetNextMessageExceptionRetriable(ex))
{
throw;
}
else
{
continuousError++;
//retry after a random backoff to avoid service throttling
//in case of there is a service error happened and all agents get kicked off of the long poll and all agent try to reconnect back at the same time.
if (continuousError <= 5)
{
// random backoff [15, 30]
_getNextMessageRetryInterval = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), _getNextMessageRetryInterval);
}
else
{
// more aggressive backoff [30, 60]
_getNextMessageRetryInterval = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(60), _getNextMessageRetryInterval);
}
if (!encounteringError)
{
//print error only on the first consecutive error
_term.WriteError($"{DateTime.UtcNow:u}: Runner connect error: {ex.Message}. Retrying until reconnected.");
encounteringError = true;
}
// re-create VssConnection before next retry
await _runnerServer.RefreshConnectionAsync(RunnerConnectionType.MessageQueue, TimeSpan.FromSeconds(60));
Trace.Info("Sleeping for {0} seconds before retrying.", _getNextMessageRetryInterval.TotalSeconds);
await HostContext.Delay(_getNextMessageRetryInterval, token);
}
}
if (message == null)
{
if (heartbeat.Elapsed > TimeSpan.FromMinutes(30))
{
Trace.Info($"No message retrieved from session '{_session.SessionId}' within last 30 minutes.");
heartbeat.Restart();
}
else
{
Trace.Verbose($"No message retrieved from session '{_session.SessionId}'.");
}
continue;
}
Trace.Info($"Message '{message.MessageId}' received from session '{_session.SessionId}'.");
return message;
}
}
public async Task DeleteMessageAsync(TaskAgentMessage message)
{
Trace.Entering();
ArgUtil.NotNull(_session, nameof(_session));
if (message != null && _session.SessionId != Guid.Empty)
{
using (var cs = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{
await _runnerServer.DeleteAgentMessageAsync(_settings.PoolId, message.MessageId, _session.SessionId, cs.Token);
}
}
}
private TaskAgentMessage DecryptMessage(TaskAgentMessage message)
{
if (_session.EncryptionKey == null ||
_session.EncryptionKey.Value.Length == 0 ||
message == null ||
message.IV == null ||
message.IV.Length == 0)
{
return message;
}
using (var aes = Aes.Create())
using (var decryptor = GetMessageDecryptor(aes, message))
using (var body = new MemoryStream(Convert.FromBase64String(message.Body)))
using (var cryptoStream = new CryptoStream(body, decryptor, CryptoStreamMode.Read))
using (var bodyReader = new StreamReader(cryptoStream, Encoding.UTF8))
{
message.Body = bodyReader.ReadToEnd();
}
return message;
}
private ICryptoTransform GetMessageDecryptor(
Aes aes,
TaskAgentMessage message)
{
if (_session.EncryptionKey.Encrypted)
{
// The agent session encryption key uses the AES symmetric algorithm
var keyManager = HostContext.GetService<IRSAKeyManager>();
using (var rsa = keyManager.GetKey())
{
return aes.CreateDecryptor(rsa.Decrypt(_session.EncryptionKey.Value, RSAEncryptionPadding.OaepSHA1), message.IV);
}
}
else
{
return aes.CreateDecryptor(_session.EncryptionKey.Value, message.IV);
}
}
private bool IsGetNextMessageExceptionRetriable(Exception ex)
{
if (ex is TaskAgentNotFoundException ||
ex is TaskAgentPoolNotFoundException ||
ex is TaskAgentSessionExpiredException ||
ex is AccessDeniedException ||
ex is VssUnauthorizedException)
{
Trace.Info($"Non-retriable exception: {ex.Message}");
return false;
}
else
{
Trace.Info($"Retriable exception: {ex.Message}");
return true;
}
}
private bool IsSessionCreationExceptionRetriable(Exception ex)
{
if (ex is TaskAgentNotFoundException)
{
Trace.Info("The agent no longer exists on the server. Stopping the runner.");
_term.WriteError("The runner no longer exists on the server. Please reconfigure the runner.");
return false;
}
else if (ex is TaskAgentSessionConflictException)
{
Trace.Info("The session for this runner already exists.");
_term.WriteError("A session for this runner already exists.");
if (_sessionCreationExceptionTracker.ContainsKey(nameof(TaskAgentSessionConflictException)))
{
_sessionCreationExceptionTracker[nameof(TaskAgentSessionConflictException)]++;
if (_sessionCreationExceptionTracker[nameof(TaskAgentSessionConflictException)] * _sessionCreationRetryInterval.TotalSeconds >= _sessionConflictRetryLimit.TotalSeconds)
{
Trace.Info("The session conflict exception have reached retry limit.");
_term.WriteError($"Stop retry on SessionConflictException after retried for {_sessionConflictRetryLimit.TotalSeconds} seconds.");
return false;
}
}
else
{
_sessionCreationExceptionTracker[nameof(TaskAgentSessionConflictException)] = 1;
}
Trace.Info("The session conflict exception haven't reached retry limit.");
return true;
}
else if (ex is VssOAuthTokenRequestException && ex.Message.Contains("Current server time is"))
{
Trace.Info("Local clock might skewed.");
_term.WriteError("The local machine's clock may be out of sync with the server time by more than five minutes. Please sync your clock with your domain or internet time and try again.");
if (_sessionCreationExceptionTracker.ContainsKey(nameof(VssOAuthTokenRequestException)))
{
_sessionCreationExceptionTracker[nameof(VssOAuthTokenRequestException)]++;
if (_sessionCreationExceptionTracker[nameof(VssOAuthTokenRequestException)] * _sessionCreationRetryInterval.TotalSeconds >= _clockSkewRetryLimit.TotalSeconds)
{
Trace.Info("The OAuth token request exception have reached retry limit.");
_term.WriteError($"Stopped retrying OAuth token request exception after {_clockSkewRetryLimit.TotalSeconds} seconds.");
return false;
}
}
else
{
_sessionCreationExceptionTracker[nameof(VssOAuthTokenRequestException)] = 1;
}
Trace.Info("The OAuth token request exception haven't reached retry limit.");
return true;
}
else if (ex is TaskAgentPoolNotFoundException ||
ex is AccessDeniedException ||
ex is VssUnauthorizedException)
{
Trace.Info($"Non-retriable exception: {ex.Message}");
return false;
}
else
{
Trace.Info($"Retriable exception: {ex.Message}");
return true;
}
}
}
}

View File

@@ -0,0 +1,140 @@
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using System;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace GitHub.Runner.Listener
{
public static class Program
{
public static int Main(string[] args)
{
using (HostContext context = new HostContext("Runner"))
{
return MainAsync(context, args).GetAwaiter().GetResult();
}
}
// Return code definition: (this will be used by service host to determine whether it will re-launch Runner.Listener)
// 0: Runner exit
// 1: Terminate failure
// 2: Retriable failure
// 3: Exit for self update
public async static Task<int> MainAsync(IHostContext context, string[] args)
{
Tracing trace = context.GetTrace(nameof(GitHub.Runner.Listener));
trace.Info($"Runner is built for {Constants.Runner.Platform} ({Constants.Runner.PlatformArchitecture}) - {BuildConstants.RunnerPackage.PackageName}.");
trace.Info($"RuntimeInformation: {RuntimeInformation.OSDescription}.");
context.WritePerfCounter("RunnerProcessStarted");
var terminal = context.GetService<ITerminal>();
// Validate the binaries intended for one OS are not running on a different OS.
switch (Constants.Runner.Platform)
{
case Constants.OSPlatform.Linux:
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
terminal.WriteLine("This runner version is built for Linux. Please install a correct build for your OS.");
return Constants.Runner.ReturnCode.TerminatedError;
}
break;
case Constants.OSPlatform.OSX:
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
terminal.WriteLine("This runner version is built for OSX. Please install a correct build for your OS.");
return Constants.Runner.ReturnCode.TerminatedError;
}
break;
case Constants.OSPlatform.Windows:
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
terminal.WriteLine("This runner version is built for Windows. Please install a correct build for your OS.");
return Constants.Runner.ReturnCode.TerminatedError;
}
break;
default:
terminal.WriteLine($"Running the runner on this platform is not supported. The current platform is {RuntimeInformation.OSDescription} and it was built for {Constants.Runner.Platform.ToString()}.");
return Constants.Runner.ReturnCode.TerminatedError;
}
try
{
trace.Info($"Version: {BuildConstants.RunnerPackage.Version}");
trace.Info($"Commit: {BuildConstants.Source.CommitHash}");
trace.Info($"Culture: {CultureInfo.CurrentCulture.Name}");
trace.Info($"UI Culture: {CultureInfo.CurrentUICulture.Name}");
// Validate directory permissions.
string runnerDirectory = context.GetDirectory(WellKnownDirectory.Root);
trace.Info($"Validating directory permissions for: '{runnerDirectory}'");
try
{
IOUtil.ValidateExecutePermission(runnerDirectory);
}
catch (Exception e)
{
terminal.WriteError($"An error occurred: {e.Message}");
trace.Error(e);
return Constants.Runner.ReturnCode.TerminatedError;
}
// Add environment variables from .env file
string envFile = Path.Combine(context.GetDirectory(WellKnownDirectory.Root), ".env");
if (File.Exists(envFile))
{
var envContents = File.ReadAllLines(envFile);
foreach (var env in envContents)
{
if (!string.IsNullOrEmpty(env) && env.IndexOf('=') > 0)
{
string envKey = env.Substring(0, env.IndexOf('='));
string envValue = env.Substring(env.IndexOf('=') + 1);
Environment.SetEnvironmentVariable(envKey, envValue);
}
}
}
// Parse the command line args.
var command = new CommandSettings(context, args);
trace.Info("Arguments parsed");
// Up front validation, warn for unrecognized commandline args.
var unknownCommandlines = command.Validate();
if (unknownCommandlines.Count > 0)
{
terminal.WriteError($"Unrecognized command-line input arguments: '{string.Join(", ", unknownCommandlines)}'. For usage refer to: .\\config.cmd --help or ./config.sh --help");
}
// Defer to the Runner class to execute the command.
IRunner runner = context.GetService<IRunner>();
try
{
return await runner.ExecuteCommand(command);
}
catch (OperationCanceledException) when (context.RunnerShutdownToken.IsCancellationRequested)
{
trace.Info("Runner execution been cancelled.");
return Constants.Runner.ReturnCode.Success;
}
catch (NonRetryableException e)
{
terminal.WriteError($"An error occurred: {e.Message}");
trace.Error(e);
return Constants.Runner.ReturnCode.TerminatedError;
}
}
catch (Exception e)
{
terminal.WriteError($"An error occurred: {e.Message}");
trace.Error(e);
return Constants.Runner.ReturnCode.RetryableError;
}
}
}
}

View File

@@ -0,0 +1,70 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<OutputType>Exe</OutputType>
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
<NoWarn>NU1701;NU1603</NoWarn>
<Version>$(Version)</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Sdk\Sdk.csproj" />
<ProjectReference Include="..\Runner.Sdk\Runner.Sdk.csproj" />
<ProjectReference Include="..\Runner.Common\Runner.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Win32.Registry" Version="4.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="4.4.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.4.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="4.4.0" />
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="3.19.4" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DebugType>portable</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,461 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Services.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Listener
{
[ServiceLocator(Default = typeof(SelfUpdater))]
public interface ISelfUpdater : IRunnerService
{
Task<bool> SelfUpdate(AgentRefreshMessage updateMessage, IJobDispatcher jobDispatcher, bool restartInteractiveRunner, CancellationToken token);
}
public class SelfUpdater : RunnerService, ISelfUpdater
{
private static string _packageType = "agent";
private static string _platform = BuildConstants.RunnerPackage.PackageName;
private PackageMetadata _targetPackage;
private ITerminal _terminal;
private IRunnerServer _runnerServer;
private int _poolId;
private int _agentId;
public override void Initialize(IHostContext hostContext)
{
base.Initialize(hostContext);
_terminal = hostContext.GetService<ITerminal>();
_runnerServer = HostContext.GetService<IRunnerServer>();
var configStore = HostContext.GetService<IConfigurationStore>();
var settings = configStore.GetSettings();
_poolId = settings.PoolId;
_agentId = settings.AgentId;
}
public async Task<bool> SelfUpdate(AgentRefreshMessage updateMessage, IJobDispatcher jobDispatcher, bool restartInteractiveRunner, CancellationToken token)
{
if (!await UpdateNeeded(updateMessage.TargetVersion, token))
{
Trace.Info($"Can't find available update package.");
return false;
}
Trace.Info($"An update is available.");
// Print console line that warn user not shutdown runner.
await UpdateRunnerUpdateStateAsync("Runner update in progress, do not shutdown runner.");
await UpdateRunnerUpdateStateAsync($"Downloading {_targetPackage.Version} runner");
await DownloadLatestRunner(token);
Trace.Info($"Download latest runner and unzip into runner root.");
// wait till all running job finish
await UpdateRunnerUpdateStateAsync("Waiting for current job finish running.");
await jobDispatcher.WaitAsync(token);
Trace.Info($"All running job has exited.");
// delete runner backup
DeletePreviousVersionRunnerBackup(token);
Trace.Info($"Delete old version runner backup.");
// generate update script from template
await UpdateRunnerUpdateStateAsync("Generate and execute update script.");
string updateScript = GenerateUpdateScript(restartInteractiveRunner);
Trace.Info($"Generate update script into: {updateScript}");
// kick off update script
Process invokeScript = new Process();
#if OS_WINDOWS
invokeScript.StartInfo.FileName = WhichUtil.Which("cmd.exe", trace: Trace);
invokeScript.StartInfo.Arguments = $"/c \"{updateScript}\"";
#elif (OS_OSX || OS_LINUX)
invokeScript.StartInfo.FileName = WhichUtil.Which("bash", trace: Trace);
invokeScript.StartInfo.Arguments = $"\"{updateScript}\"";
#endif
invokeScript.Start();
Trace.Info($"Update script start running");
await UpdateRunnerUpdateStateAsync("Runner will exit shortly for update, should back online within 10 seconds.");
return true;
}
private async Task<bool> UpdateNeeded(string targetVersion, CancellationToken token)
{
// when talk to old version server, always prefer latest package.
// old server won't send target version as part of update message.
if (string.IsNullOrEmpty(targetVersion))
{
var packages = await _runnerServer.GetPackagesAsync(_packageType, _platform, 1, token);
if (packages == null || packages.Count == 0)
{
Trace.Info($"There is no package for {_packageType} and {_platform}.");
return false;
}
_targetPackage = packages.FirstOrDefault();
}
else
{
_targetPackage = await _runnerServer.GetPackageAsync(_packageType, _platform, targetVersion, token);
if (_targetPackage == null)
{
Trace.Info($"There is no package for {_packageType} and {_platform} with version {targetVersion}.");
return false;
}
}
Trace.Info($"Version '{_targetPackage.Version}' of '{_targetPackage.Type}' package available in server.");
PackageVersion serverVersion = new PackageVersion(_targetPackage.Version);
Trace.Info($"Current running runner version is {BuildConstants.RunnerPackage.Version}");
PackageVersion runnerVersion = new PackageVersion(BuildConstants.RunnerPackage.Version);
return serverVersion.CompareTo(runnerVersion) > 0;
}
/// <summary>
/// _work
/// \_update
/// \bin
/// \externals
/// \run.sh
/// \run.cmd
/// \package.zip //temp download .zip/.tar.gz
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
private async Task DownloadLatestRunner(CancellationToken token)
{
string latestRunnerDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), Constants.Path.UpdateDirectory);
IOUtil.DeleteDirectory(latestRunnerDirectory, token);
Directory.CreateDirectory(latestRunnerDirectory);
int runnerSuffix = 1;
string archiveFile = null;
bool downloadSucceeded = false;
try
{
// Download the runner, using multiple attempts in order to be resilient against any networking/CDN issues
for (int attempt = 1; attempt <= Constants.RunnerDownloadRetryMaxAttempts; attempt++)
{
// Generate an available package name, and do our best effort to clean up stale local zip files
while (true)
{
if (_targetPackage.Platform.StartsWith("win"))
{
archiveFile = Path.Combine(latestRunnerDirectory, $"runner{runnerSuffix}.zip");
}
else
{
archiveFile = Path.Combine(latestRunnerDirectory, $"runner{runnerSuffix}.tar.gz");
}
try
{
// delete .zip file
if (!string.IsNullOrEmpty(archiveFile) && File.Exists(archiveFile))
{
Trace.Verbose("Deleting latest runner package zip '{0}'", archiveFile);
IOUtil.DeleteFile(archiveFile);
}
break;
}
catch (Exception ex)
{
// couldn't delete the file for whatever reason, so generate another name
Trace.Warning("Failed to delete runner package zip '{0}'. Exception: {1}", archiveFile, ex);
runnerSuffix++;
}
}
// Allow a 15-minute package download timeout, which is good enough to update the runner from a 1 Mbit/s ADSL connection.
if (!int.TryParse(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_DOWNLOAD_TIMEOUT") ?? string.Empty, out int timeoutSeconds))
{
timeoutSeconds = 15 * 60;
}
Trace.Info($"Attempt {attempt}: save latest runner into {archiveFile}.");
using (var downloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
using (var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(downloadTimeout.Token, token))
{
try
{
Trace.Info($"Download runner: begin download");
//open zip stream in async mode
using (HttpClient httpClient = new HttpClient(HostContext.CreateHttpClientHandler()))
using (FileStream fs = new FileStream(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
using (Stream result = await httpClient.GetStreamAsync(_targetPackage.DownloadUrl))
{
//81920 is the default used by System.IO.Stream.CopyTo and is under the large object heap threshold (85k).
await result.CopyToAsync(fs, 81920, downloadCts.Token);
await fs.FlushAsync(downloadCts.Token);
}
Trace.Info($"Download runner: finished download");
downloadSucceeded = true;
break;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
Trace.Info($"Runner download has been canceled.");
throw;
}
catch (Exception ex)
{
if (downloadCts.Token.IsCancellationRequested)
{
Trace.Warning($"Runner download has timed out after {timeoutSeconds} seconds");
}
Trace.Warning($"Failed to get package '{archiveFile}' from '{_targetPackage.DownloadUrl}'. Exception {ex}");
}
}
}
if (!downloadSucceeded)
{
throw new TaskCanceledException($"Runner package '{archiveFile}' failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts");
}
// If we got this far, we know that we've successfully downloaded the runner package
if (archiveFile.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(archiveFile, latestRunnerDirectory);
}
else if (archiveFile.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
string tar = WhichUtil.Which("tar", trace: Trace);
if (string.IsNullOrEmpty(tar))
{
throw new NotSupportedException($"tar -xzf");
}
// tar -xzf
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
{
processInvoker.OutputDataReceived += new EventHandler<ProcessDataReceivedEventArgs>((sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
Trace.Info(args.Data);
}
});
processInvoker.ErrorDataReceived += new EventHandler<ProcessDataReceivedEventArgs>((sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
Trace.Error(args.Data);
}
});
int exitCode = await processInvoker.ExecuteAsync(latestRunnerDirectory, tar, $"-xzf \"{archiveFile}\"", null, token);
if (exitCode != 0)
{
throw new NotSupportedException($"Can't use 'tar -xzf' extract archive file: {archiveFile}. return code: {exitCode}.");
}
}
}
else
{
throw new NotSupportedException($"{archiveFile}");
}
Trace.Info($"Finished getting latest runner package at: {latestRunnerDirectory}.");
}
finally
{
try
{
// delete .zip file
if (!string.IsNullOrEmpty(archiveFile) && File.Exists(archiveFile))
{
Trace.Verbose("Deleting latest runner package zip: {0}", archiveFile);
IOUtil.DeleteFile(archiveFile);
}
}
catch (Exception ex)
{
//it is not critical if we fail to delete the .zip file
Trace.Warning("Failed to delete runner package zip '{0}'. Exception: {1}", archiveFile, ex);
}
}
// copy latest runner into runner root folder
// copy bin from _work/_update -> bin.version under root
string binVersionDir = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"{Constants.Path.BinDirectory}.{_targetPackage.Version}");
Directory.CreateDirectory(binVersionDir);
Trace.Info($"Copy {Path.Combine(latestRunnerDirectory, Constants.Path.BinDirectory)} to {binVersionDir}.");
IOUtil.CopyDirectory(Path.Combine(latestRunnerDirectory, Constants.Path.BinDirectory), binVersionDir, token);
// copy externals from _work/_update -> externals.version under root
string externalsVersionDir = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"{Constants.Path.ExternalsDirectory}.{_targetPackage.Version}");
Directory.CreateDirectory(externalsVersionDir);
Trace.Info($"Copy {Path.Combine(latestRunnerDirectory, Constants.Path.ExternalsDirectory)} to {externalsVersionDir}.");
IOUtil.CopyDirectory(Path.Combine(latestRunnerDirectory, Constants.Path.ExternalsDirectory), externalsVersionDir, token);
// copy and replace all .sh/.cmd files
Trace.Info($"Copy any remaining .sh/.cmd files into runner root.");
foreach (FileInfo file in new DirectoryInfo(latestRunnerDirectory).GetFiles() ?? new FileInfo[0])
{
// Copy and replace the file.
file.CopyTo(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), file.Name), true);
}
}
private void DeletePreviousVersionRunnerBackup(CancellationToken token)
{
// delete previous backup runner (back compat, can be remove after serval sprints)
// bin.bak.2.99.0
// externals.bak.2.99.0
foreach (string existBackUp in Directory.GetDirectories(HostContext.GetDirectory(WellKnownDirectory.Root), "*.bak.*"))
{
Trace.Info($"Delete existing runner backup at {existBackUp}.");
try
{
IOUtil.DeleteDirectory(existBackUp, token);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
Trace.Error(ex);
Trace.Info($"Catch exception during delete backup folder {existBackUp}, ignore this error try delete the backup folder on next auto-update.");
}
}
// delete old bin.2.99.0 folder, only leave the current version and the latest download version
var allBinDirs = Directory.GetDirectories(HostContext.GetDirectory(WellKnownDirectory.Root), "bin.*");
if (allBinDirs.Length > 2)
{
// there are more than 2 bin.version folder.
// delete older bin.version folders.
foreach (var oldBinDir in allBinDirs)
{
if (string.Equals(oldBinDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"bin"), StringComparison.OrdinalIgnoreCase) ||
string.Equals(oldBinDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"bin.{BuildConstants.RunnerPackage.Version}"), StringComparison.OrdinalIgnoreCase) ||
string.Equals(oldBinDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"bin.{_targetPackage.Version}"), StringComparison.OrdinalIgnoreCase))
{
// skip for current runner version
continue;
}
Trace.Info($"Delete runner bin folder's backup at {oldBinDir}.");
try
{
IOUtil.DeleteDirectory(oldBinDir, token);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
Trace.Error(ex);
Trace.Info($"Catch exception during delete backup folder {oldBinDir}, ignore this error try delete the backup folder on next auto-update.");
}
}
}
// delete old externals.2.99.0 folder, only leave the current version and the latest download version
var allExternalsDirs = Directory.GetDirectories(HostContext.GetDirectory(WellKnownDirectory.Root), "externals.*");
if (allExternalsDirs.Length > 2)
{
// there are more than 2 externals.version folder.
// delete older externals.version folders.
foreach (var oldExternalDir in allExternalsDirs)
{
if (string.Equals(oldExternalDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"externals"), StringComparison.OrdinalIgnoreCase) ||
string.Equals(oldExternalDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"externals.{BuildConstants.RunnerPackage.Version}"), StringComparison.OrdinalIgnoreCase) ||
string.Equals(oldExternalDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"externals.{_targetPackage.Version}"), StringComparison.OrdinalIgnoreCase))
{
// skip for current runner version
continue;
}
Trace.Info($"Delete runner externals folder's backup at {oldExternalDir}.");
try
{
IOUtil.DeleteDirectory(oldExternalDir, token);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
Trace.Error(ex);
Trace.Info($"Catch exception during delete backup folder {oldExternalDir}, ignore this error try delete the backup folder on next auto-update.");
}
}
}
}
private string GenerateUpdateScript(bool restartInteractiveRunner)
{
int processId = Process.GetCurrentProcess().Id;
string updateLog = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Diag), $"SelfUpdate-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss")}.log");
string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
#if OS_WINDOWS
string templateName = "update.cmd.template";
#else
string templateName = "update.sh.template";
#endif
string templatePath = Path.Combine(runnerRoot, $"bin.{_targetPackage.Version}", templateName);
string template = File.ReadAllText(templatePath);
template = template.Replace("_PROCESS_ID_", processId.ToString());
template = template.Replace("_RUNNER_PROCESS_NAME_", $"Runner.Listener{IOUtil.ExeExtension}");
template = template.Replace("_ROOT_FOLDER_", runnerRoot);
template = template.Replace("_EXIST_RUNNER_VERSION_", BuildConstants.RunnerPackage.Version);
template = template.Replace("_DOWNLOAD_RUNNER_VERSION_", _targetPackage.Version);
template = template.Replace("_UPDATE_LOG_", updateLog);
template = template.Replace("_RESTART_INTERACTIVE_RUNNER_", restartInteractiveRunner ? "1" : "0");
#if OS_WINDOWS
string scriptName = "_update.cmd";
#else
string scriptName = "_update.sh";
#endif
string updateScript = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), scriptName);
if (File.Exists(updateScript))
{
IOUtil.DeleteFile(updateScript);
}
File.WriteAllText(updateScript, template);
return updateScript;
}
private async Task UpdateRunnerUpdateStateAsync(string currentState)
{
_terminal.WriteLine(currentState);
try
{
await _runnerServer.UpdateAgentUpdateStateAsync(_poolId, _agentId, currentState);
}
catch (VssResourceNotFoundException)
{
// ignore VssResourceNotFoundException, this exception means the runner is configured against a old server that doesn't support report runner update detail.
Trace.Info($"Catch VssResourceNotFoundException during report update state, ignore this error for backcompat.");
}
catch (Exception ex)
{
Trace.Error(ex);
Trace.Info($"Catch exception during report update state, ignore this error and continue auto-update.");
}
}
}
}