391 lines
15 KiB
C#
391 lines
15 KiB
C#
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using EpicGames.Core;
|
|
using EpicGames.Horde;
|
|
using HordeAgent.Leases;
|
|
using HordeAgent.Leases.Handlers;
|
|
using HordeAgent.Services;
|
|
using HordeAgent.Utility;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.FileProviders;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using Polly;
|
|
using IConfigurationSource = Microsoft.Extensions.Configuration.IConfigurationSource;
|
|
using JsonConfigurationSource = Microsoft.Extensions.Configuration.Json.JsonConfigurationSource;
|
|
|
|
namespace HordeAgent
|
|
{
|
|
/// <summary>
|
|
/// Injectable list of existing services
|
|
/// </summary>
|
|
class DefaultServices
|
|
{
|
|
/// <summary>
|
|
/// The base configuration object
|
|
/// </summary>
|
|
public IConfiguration Configuration { get; }
|
|
|
|
/// <summary>
|
|
/// List of service descriptors
|
|
/// </summary>
|
|
public IEnumerable<ServiceDescriptor> Descriptors { get; }
|
|
|
|
/// <summary>
|
|
/// Constructor
|
|
/// </summary>
|
|
public DefaultServices(IConfiguration configuration, IEnumerable<ServiceDescriptor> descriptors)
|
|
{
|
|
Configuration = configuration;
|
|
Descriptors = descriptors;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Entry point
|
|
/// </summary>
|
|
public static class AgentApp
|
|
{
|
|
/// <summary>
|
|
/// Name of the http client
|
|
/// </summary>
|
|
public const string HordeServerClientName = "HordeServer";
|
|
|
|
/// <summary>
|
|
/// Path to the root application directory
|
|
/// </summary>
|
|
public static DirectoryReference AppDir { get; } = GetAppDir();
|
|
|
|
/// <summary>
|
|
/// Path to the default data directory
|
|
/// </summary>
|
|
public static DirectoryReference DataDir { get; private set; } = DirectoryReference.Combine(AppDir, "Data");
|
|
|
|
/// <summary>
|
|
/// The launch arguments
|
|
/// </summary>
|
|
public static IReadOnlyList<string> Args { get; private set; } = null!;
|
|
|
|
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file
|
|
/// <summary>
|
|
/// Whether agent is packaged as a self-contained package where the .NET runtime is included.
|
|
/// </summary>
|
|
public static bool IsSelfContained => String.IsNullOrEmpty(Assembly.GetExecutingAssembly().Location);
|
|
#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file
|
|
|
|
/// <summary>
|
|
/// The current application version from compile-time generated version
|
|
/// </summary>
|
|
public static string Version { get; } = VersionInfo.Version;
|
|
|
|
/// <summary>
|
|
/// Default settings for json serialization
|
|
/// </summary>
|
|
public static JsonSerializerOptions DefaultJsonSerializerOptions { get; } = new JsonSerializerOptions { AllowTrailingCommas = true, PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
|
|
|
/// <summary>
|
|
/// Entry point
|
|
/// </summary>
|
|
/// <param name="args">Command-line arguments</param>
|
|
/// <returns>Exit code</returns>
|
|
public static async Task<int> Main(string[] args)
|
|
{
|
|
AgentApp.Args = args;
|
|
|
|
CommandLineArguments arguments = new CommandLineArguments(args);
|
|
|
|
Dictionary<string, string?> configOverrides = new Dictionary<string, string?>();
|
|
if (arguments.TryGetValue("-Server=", out string? serverOverride))
|
|
{
|
|
configOverrides.Add($"{AgentSettings.SectionName}:{nameof(AgentSettings.Server)}", serverOverride);
|
|
}
|
|
if (arguments.TryGetValue("-WorkingDir=", out string? workingDirOverride))
|
|
{
|
|
configOverrides.Add($"{AgentSettings.SectionName}:{nameof(AgentSettings.WorkingDir)}", workingDirOverride);
|
|
}
|
|
|
|
List<FileReference> configFilesFromArgs = SplitPath(arguments.GetStringOrDefault("-ConfigFiles=", ""));
|
|
List<string> configSourcesInstalled = [];
|
|
|
|
// Create the base configuration data by just reading from the application directory.
|
|
// We need to check some settings before being able to read user configuration files.
|
|
IConfiguration configuration = CreateConfig(false, [], configOverrides, out _);
|
|
AgentSettings settings = BindSettings(configuration);
|
|
|
|
if (settings.Installed)
|
|
{
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
DirectoryReference? commonAppDataDir = DirectoryReference.GetSpecialFolder(Environment.SpecialFolder.CommonApplicationData);
|
|
if (commonAppDataDir != null)
|
|
{
|
|
DataDir = DirectoryReference.Combine(commonAppDataDir, "Epic", "Horde", "Agent");
|
|
await CopyDefaultConfigFilesAsync(DataDir);
|
|
}
|
|
}
|
|
|
|
List<FileReference> agentConfigFiles = [FileReference.Combine(DataDir, "agent.json")];
|
|
agentConfigFiles.AddRange(configFilesFromArgs);
|
|
configuration = CreateConfig(true, agentConfigFiles, configOverrides, out configSourcesInstalled);
|
|
settings = BindSettings(configuration);
|
|
}
|
|
|
|
using ILoggerFactory loggerFactory = Logging.CreateLoggerFactory(configuration);
|
|
|
|
{
|
|
ILogger logger = loggerFactory.CreateLogger(typeof(AgentApp));
|
|
foreach (string configSource in configSourcesInstalled)
|
|
{
|
|
logger.LogInformation("Config source: {ConfigSource}", configSource);
|
|
}
|
|
logger.LogInformation("Logs dir: {LogsDir}", settings.LogsDir);
|
|
}
|
|
|
|
IServiceCollection services = new ServiceCollection();
|
|
services.AddCommandsFromAssembly(Assembly.GetExecutingAssembly());
|
|
services.AddSingleton(loggerFactory);
|
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
{
|
|
// Prioritize agent execution time over any job its running.
|
|
// We've seen file copying starving the agent communication to the Horde server, causing a disconnect.
|
|
// Increasing the process priority is speculative fix to combat this.
|
|
using (Process process = Process.GetCurrentProcess())
|
|
{
|
|
process.PriorityClass = ProcessPriorityClass.High;
|
|
}
|
|
}
|
|
|
|
// Add all the default
|
|
IConfigurationSection configSection = configuration.GetSection(AgentSettings.SectionName);
|
|
services.AddOptions<AgentSettings>().Configure(options => configSection.Bind(options)).ValidateDataAnnotations();
|
|
|
|
ServerProfile serverProfile = settings.GetCurrentServerProfile();
|
|
if (String.IsNullOrEmpty(serverProfile.Url.Scheme) || String.IsNullOrEmpty(serverProfile.Url.Host))
|
|
{
|
|
ILogger logger = loggerFactory.CreateLogger(typeof(AgentApp));
|
|
logger.LogError("\"{Url}\" is an invalid server url. The specified url must have a valid scheme and host name.", serverProfile.Url);
|
|
return 1;
|
|
}
|
|
|
|
OpenTelemetryHelper.Configure(services, settings.OpenTelemetry);
|
|
|
|
services.AddSingleton<IClock, DefaultClock>();
|
|
services.AddHorde(options =>
|
|
{
|
|
options.ServerUrl = serverProfile.Url;
|
|
options.AccessToken = serverProfile.GetAuthToken();
|
|
options.AllowAuthPrompt = serverProfile.UseInteractiveAuth;
|
|
options.BackendCache.CacheDir = DirectoryReference.Combine(settings.WorkingDir, "Saved", "Bundles").FullName;
|
|
options.BackendCache.MaxSize = settings.BundleCacheSize * 1024 * 1024;
|
|
});
|
|
|
|
services.AddHttpClient(AwsInstanceLifecycleService.HttpClientName)
|
|
.AddTransientHttpErrorPolicy(builder =>
|
|
{
|
|
return builder.WaitAndRetryAsync(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5) });
|
|
});
|
|
services.AddSingleton<AwsInstanceLifecycleService>();
|
|
if (settings.EnableAwsEc2Support)
|
|
{
|
|
services.AddHostedService(sp => sp.GetRequiredService<AwsInstanceLifecycleService>());
|
|
}
|
|
|
|
services.AddSingleton<GrpcService>();
|
|
services.AddSingleton<TelemetryService>();
|
|
services.AddHostedService(sp => sp.GetRequiredService<TelemetryService>());
|
|
|
|
services.AddSingleton<JobHandlerFactory>();
|
|
services.AddSingleton<StatusService>();
|
|
services.AddHostedService<StatusService>(sp => sp.GetRequiredService<StatusService>());
|
|
services.AddSingleton<ManagementService>();
|
|
services.AddHostedService<ManagementService>(sp => sp.GetRequiredService<ManagementService>());
|
|
|
|
services.AddSingleton<LeaseHandlerFactory, ComputeHandlerFactory>();
|
|
services.AddSingleton<LeaseHandlerFactory, ConformHandlerFactory>();
|
|
services.AddSingleton<LeaseHandlerFactory, JobHandlerFactory>(x => x.GetRequiredService<JobHandlerFactory>());
|
|
services.AddSingleton<LeaseHandlerFactory, RestartHandlerFactory>();
|
|
services.AddSingleton<LeaseHandlerFactory, ShutdownHandlerFactory>();
|
|
services.AddSingleton<LeaseHandlerFactory, UpgradeHandlerFactory>();
|
|
|
|
services.AddSingleton<CapabilitiesService>();
|
|
services.AddSingleton<ISessionFactory, SessionFactory>();
|
|
services.AddSingleton<WorkerService>();
|
|
services.AddSingleton<IWorkerService>(sp => sp.GetRequiredService<WorkerService>());
|
|
services.AddSingleton<LeaseLoggerFactory>();
|
|
services.AddHostedService(sp => sp.GetRequiredService<WorkerService>());
|
|
|
|
services.AddSingleton<ComputeListenerService>();
|
|
services.AddHostedService(sp => sp.GetRequiredService<ComputeListenerService>());
|
|
|
|
services.AddMemoryCache();
|
|
|
|
services.AddSingleton<ISystemMetrics>(sp => CreateSystemMetrics(settings.WorkingDir, sp.GetRequiredService<ILogger<ISystemMetrics>>()));
|
|
|
|
// Allow commands to augment the service collection for their own DI service providers
|
|
services.AddSingleton<DefaultServices>(x => new DefaultServices(configuration, services));
|
|
|
|
// Execute all the commands
|
|
await using ServiceProvider serviceProvider = services.BuildServiceProvider();
|
|
return await CommandHost.RunAsync(arguments, serviceProvider, typeof(Commands.Service.RunCommand));
|
|
}
|
|
|
|
static ISystemMetrics CreateSystemMetrics(DirectoryReference workingDir, ILogger logger)
|
|
{
|
|
try
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
{
|
|
return new WindowsSystemMetrics(workingDir);
|
|
}
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
{
|
|
return new LinuxSystemMetrics(workingDir);
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
logger.LogError(e, "Unable to initialize system metric collector for telemetry. Disabling. Reason: {Message}", e.Message);
|
|
}
|
|
return new DefaultSystemMetrics();
|
|
}
|
|
|
|
static IConfiguration CreateConfig(bool readInstalledConfig, List<FileReference> agentConfigFiles, Dictionary<string, string?> configOverrides, out List<string> configSources)
|
|
{
|
|
configSources = new List<string>();
|
|
string? environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
|
if (String.IsNullOrEmpty(environment))
|
|
{
|
|
environment = "Production";
|
|
}
|
|
|
|
IConfigurationBuilder builder = new ConfigurationBuilder();
|
|
if (readInstalledConfig && OperatingSystem.IsWindows())
|
|
{
|
|
static bool IncludeRegistrySetting(string name)
|
|
=> !String.Equals(name, $"{AgentSettings.SectionName}:Installed", StringComparison.OrdinalIgnoreCase);
|
|
|
|
builder = builder.Add(new RegistryConfigurationSource(Registry.LocalMachine, "SOFTWARE\\Epic Games\\Horde\\Agent", AgentSettings.SectionName, IncludeRegistrySetting));
|
|
}
|
|
|
|
string basePath = AppDir.FullName;
|
|
builder.SetBasePath(basePath)
|
|
.AddJsonFile("appsettings.json", optional: false)
|
|
.AddJsonFile("appsettings.Build.json", optional: true) // specific settings for builds (installer/dockerfile)
|
|
.AddJsonFile($"appsettings.{environment}.json", optional: true) // environment variable overrides, also used in k8s setups with Helm
|
|
.AddJsonFile("appsettings.User.json", optional: true);
|
|
|
|
foreach (FileReference configFile in agentConfigFiles.Where(path => Path.Exists(path.FullName)))
|
|
{
|
|
// Adding a JSON file outside the common base path requires adding a completely separate JsonConfigurationSource entry
|
|
builder.Add(new JsonConfigurationSource()
|
|
{
|
|
Path = configFile.GetFileName(),
|
|
Optional = true,
|
|
ReloadOnChange = true,
|
|
FileProvider = new PhysicalFileProvider(configFile.Directory.FullName),
|
|
});
|
|
}
|
|
|
|
foreach (IConfigurationSource source in builder.Sources)
|
|
{
|
|
switch (source)
|
|
{
|
|
case JsonConfigurationSource jcs:
|
|
string path = jcs.FileProvider is PhysicalFileProvider pfp
|
|
? Path.Join(pfp.Root, jcs.Path)
|
|
: Path.Join(basePath, jcs.Path); // Use shared base path if file provider is missing
|
|
configSources.Add("JSON file: " + path);
|
|
break;
|
|
case RegistryConfigurationSource rcs:
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
configSources.Add("Windows Registry key: " + rcs.GetRegistryKey());
|
|
}
|
|
break;
|
|
case null:
|
|
throw new ArgumentException(nameof(source));
|
|
}
|
|
}
|
|
|
|
return builder
|
|
.AddInMemoryCollection(configOverrides)
|
|
.AddEnvironmentVariables()
|
|
.Build();
|
|
}
|
|
|
|
internal static AgentSettings BindSettings(IConfiguration configuration)
|
|
{
|
|
AgentSettings settings = new AgentSettings();
|
|
configuration.GetSection(AgentSettings.SectionName).Bind(settings);
|
|
return settings;
|
|
}
|
|
|
|
static async Task CopyDefaultConfigFilesAsync(DirectoryReference configDir)
|
|
{
|
|
DirectoryReference.CreateDirectory(configDir);
|
|
|
|
DirectoryReference defaultsDir = DirectoryReference.Combine(AppDir, "Defaults");
|
|
if (DirectoryReference.Exists(defaultsDir))
|
|
{
|
|
foreach (FileReference sourceFile in DirectoryReference.EnumerateFiles(defaultsDir, "*.json"))
|
|
{
|
|
FileReference targetFile = FileReference.Combine(configDir, sourceFile.GetFileName());
|
|
if (!FileReference.Exists(targetFile))
|
|
{
|
|
using FileStream targetStream = FileReference.Open(targetFile, FileMode.Create, FileAccess.Write, FileShare.Read);
|
|
using FileStream sourceStream = FileReference.Open(sourceFile, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
await sourceStream.CopyToAsync(targetStream);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the application directory
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[SuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "Has fallback handling")]
|
|
static DirectoryReference GetAppDir()
|
|
{
|
|
string? directoryName = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
|
if (!String.IsNullOrEmpty(directoryName))
|
|
{
|
|
return new DirectoryReference(directoryName);
|
|
}
|
|
|
|
// When C# project is packaged as a single file, GetExecutingAssembly above does not work
|
|
return DirectoryReference.FromFile(new FileReference(Environment.ProcessPath!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits a string containing multiple file paths
|
|
/// Uses platform-specific path separators: semicolon (;) on Windows, colon (:) on other operating systems.
|
|
/// </summary>
|
|
/// <param name="paths">String containing one or more paths separated by the platform-specific separator</param>
|
|
/// <returns>List of FileReference objects, empty list if input is null or empty</returns>
|
|
internal static List<FileReference> SplitPath(string paths)
|
|
{
|
|
if (String.IsNullOrEmpty(paths))
|
|
{
|
|
return new List<FileReference>();
|
|
}
|
|
|
|
char separator = OperatingSystem.IsWindows() ? ';' : ':';
|
|
return paths
|
|
.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(path => path.Trim())
|
|
.Where(path => !String.IsNullOrEmpty(path))
|
|
.Select(path => new FileReference(path))
|
|
.ToList();
|
|
}
|
|
}
|
|
}
|