Files
UnrealEngine/Engine/Source/Programs/Horde/HordeServer/ServerApp.cs
2025-05-18 13:04:45 +08:00

506 lines
21 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using EpicGames.Core;
using HordeServer.Commands;
using HordeServer.Plugins;
using HordeServer.Utilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Win32;
using Serilog;
using Serilog.Configuration;
using Serilog.Exceptions;
using Serilog.Exceptions.Core;
using Serilog.Exceptions.Grpc.Destructurers;
using Serilog.Extensions.Logging;
using Serilog.Formatting.Json;
using Serilog.Sinks.SystemConsole.Themes;
namespace HordeServer
{
using ILogger = Microsoft.Extensions.Logging.ILogger;
static class LoggerExtensions
{
class DatadogVersionLogEnricher : Serilog.Core.ILogEventEnricher
{
public void Enrich(Serilog.Events.LogEvent logEvent, Serilog.Core.ILogEventPropertyFactory propertyFactory)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("dd.version", ServerApp.Version));
}
}
public static LoggerConfiguration Console(this LoggerSinkConfiguration sinkConfig, ServerSettings settings)
{
if (settings.LogJsonToStdOut)
{
return sinkConfig.Console(new JsonFormatter(renderMessage: true));
}
else
{
ConsoleTheme theme;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Environment.OSVersion.Version < new Version(10, 0))
{
theme = SystemConsoleTheme.Literate;
}
else
{
theme = AnsiConsoleTheme.Code;
}
return sinkConfig.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:w3}] {Indent}{Message:l}{NewLine}{Exception}", theme: theme, restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Debug);
}
}
public static LoggerConfiguration WithHordeConfig(this LoggerConfiguration configuration, ServerSettings settings)
{
if (settings.OpenTelemetry.EnableDatadogCompatibility)
{
configuration = configuration.Enrich.With<DatadogVersionLogEnricher>();
configuration = configuration.Enrich.With<OpenTelemetryDatadogLogEnricher>();
}
return configuration;
}
}
class ServerApp
{
public static SemVer Version { get; } = GetVersion();
public static string DeploymentEnvironment { get; } = GetEnvironment();
public static string SessionId { get; } = Guid.NewGuid().ToString("n");
public static DirectoryReference AppDir { get; } = GetAppDir();
public static DirectoryReference DataDir => s_dataDir;
public static DirectoryReference ConfigDir => s_configDir;
public static FileReference ServerConfigFile => s_serverConfigFile ?? throw new InvalidOperationException("ServerConfigFile has not been initialized");
public static Type[] ConfigSchemas = FindSchemaTypes();
public static IPluginCollection Plugins => s_pluginCollection ?? throw new InvalidOperationException("Plugin collection has not been initialized");
private static DirectoryReference s_dataDir = DirectoryReference.Combine(GetAppDir(), "Data");
private static DirectoryReference s_configDir = DirectoryReference.Combine(GetAppDir(), "Defaults");
private static FileReference? s_serverConfigFile;
private static IPluginCollection? s_pluginCollection;
static Type[] FindSchemaTypes()
{
List<Type> schemaTypes = new List<Type>();
foreach (Type type in Assembly.GetExecutingAssembly().GetTypes())
{
if (type.GetCustomAttribute<JsonSchemaAttribute>() != null)
{
schemaTypes.Add(type);
}
}
return schemaTypes.ToArray();
}
static SemVer GetVersion()
{
FileVersionInfo versionInfo = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location);
if (String.IsNullOrEmpty(versionInfo.ProductVersion))
{
return SemVer.Parse("0.0.0");
}
else
{
return SemVer.Parse(versionInfo.ProductVersion);
}
}
public static async Task<int> Main(string[] args)
{
CommandLineArguments arguments = new CommandLineArguments(args);
// 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 baseConfig = CreateConfig(false, null);
ServerSettings baseServerSettings = new ServerSettings();
Startup.BindServerSettings(baseConfig, baseServerSettings);
// Set the default data directory
if (baseServerSettings.DataDir != null)
{
if (Path.IsPathRooted(baseServerSettings.DataDir))
{
DirectoryReference? dataDir = DirectoryReference.FromString(baseServerSettings.DataDir);
if (dataDir != null)
{
s_dataDir = dataDir;
}
}
else
{
s_dataDir = DirectoryReference.Combine(GetAppDir(), baseServerSettings.DataDir);
}
}
else if (baseServerSettings.Installed && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
DirectoryReference? commonDataDir = DirectoryReference.GetSpecialFolder(Environment.SpecialFolder.CommonApplicationData);
if (commonDataDir != null)
{
s_dataDir = DirectoryReference.Combine(commonDataDir, "Epic", "Horde", "Server");
}
}
// For installed builds, copy default config files to the data dir and use that as the config dir instead
if (baseServerSettings.Installed)
{
await CopyDefaultConfigFilesAsync(s_configDir, s_dataDir, CancellationToken.None);
s_configDir = s_dataDir;
}
// Create the final configuration, including the server.json file
s_serverConfigFile = FileReference.Combine(s_configDir, "server.json");
IConfiguration config = CreateConfig(baseServerSettings.Installed, s_serverConfigFile);
// Bind the complete settings
ServerSettings serverSettings = new ServerSettings();
Startup.BindServerSettings(config, serverSettings);
DirectoryReference logDir = DirectoryReference.Combine(DataDir, "Logs");
Serilog.Log.Logger = new LoggerConfiguration()
.WithHordeConfig(serverSettings)
.Enrich.FromLogContext()
.Enrich.WithExceptionDetails(new DestructuringOptionsBuilder()
.WithDefaultDestructurers()
.WithDestructurers(new[] { new RpcExceptionDestructurer() }))
.WriteTo.Console(serverSettings)
.WriteTo.File(Path.Combine(logDir.FullName, "Log.txt"), outputTemplate: "[{Timestamp:HH:mm:ss} {Level:w3}] {Indent}{Message:l}{NewLine}{Exception}", rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true, fileSizeLimitBytes: 20 * 1024 * 1024, retainedFileCountLimit: 10)
.WriteTo.File(new JsonFormatter(renderMessage: true), Path.Combine(logDir.FullName, "Log.json"), rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true, fileSizeLimitBytes: 20 * 1024 * 1024, retainedFileCountLimit: 10)
.ReadFrom.Configuration(config)
.CreateLogger();
#pragma warning disable CA2000 // Dispose objects before losing scope
ILogger startupLogger = new SerilogLoggerFactory().CreateLogger(typeof(ServerApp).FullName ?? "ServerApp");
#pragma warning restore CA2000 // Dispose objects before losing scope
serverSettings.Validate(startupLogger);
try
{
ServiceCollection services = new ServiceCollection();
services.AddCommandsFromAssembly(Assembly.GetExecutingAssembly());
services.AddLogging(builder => builder.AddSerilog());
services.AddSingleton<IConfiguration>(config);
services.AddSingleton<ServerSettings>(serverSettings);
services.Configure<ServerSettings>(x => Startup.BindServerSettings(config, x));
services.AddTransient<IServerStartup, Startup>();
ServerInfo serverInfo = new ServerInfo(config, Options.Create(serverSettings));
services.AddSingleton<IServerInfo>(serverInfo);
s_pluginCollection = CreatePluginCollection(config, startupLogger);
services.AddSingleton<IPluginCollection>(s_pluginCollection);
foreach (Assembly pluginAssembly in s_pluginCollection.LoadedPlugins.Select(x => x.Assembly).Distinct())
{
services.AddCommandsFromAssembly(pluginAssembly);
}
#pragma warning disable ASP0000 // Do not call 'IServiceCollection.BuildServiceProvider' in 'ConfigureServices'
await using (ServiceProvider serviceProvider = services.BuildServiceProvider())
{
return await CommandHost.RunAsync(arguments, serviceProvider, typeof(ServerCommand));
}
#pragma warning restore ASP0000 // Do not call 'IServiceCollection.BuildServiceProvider' in 'ConfigureServices'
}
catch (Exception ex)
{
startupLogger.LogError(ex, "Uncaught exception: {Message}", ex.Message);
throw;
}
}
internal static void InitializePluginsForTests()
{
s_pluginCollection ??= CreatePluginCollection(new ConfigurationBuilder().Build(), NullLogger.Instance);
}
/// <summary>
/// Creates a plugin collection by loading and configuring plugins from assemblies based on configuration settings.
///
/// The method performs the following steps:
/// 1. Loads plugin configurations from the "Horde.Plugins" configuration section
/// 2. Scans the application directory for DLL files matching the pattern "HordeServer.*.dll"
/// 3. Loads each matching assembly and searches for types decorated with <see cref="PluginAttribute"/>
/// 4. Enables plugins based on configuration settings and default values
/// 5. Validates that all plugins enabled in configuration are found
/// </summary>
/// <returns>An initialized <see cref="IPluginCollection"/> containing all successfully loaded and enabled plugins.</returns>
/// <exception cref="InvalidOperationException">Thrown when one or more plugins enabled in the configuration file cannot be found.</exception>
static IPluginCollection CreatePluginCollection(IConfiguration configuration, ILogger logger)
{
Dictionary<string, PluginServerConfig> pluginConfigs = new Dictionary<string, PluginServerConfig>(StringComparer.OrdinalIgnoreCase);
configuration.GetSection("Horde").GetSection("Plugins").Bind(pluginConfigs);
List<FileReference> files = new List<FileReference>();
foreach (FileInfo fileInfo in AppDir.ToDirectoryInfo().EnumerateFiles())
{
const string Prefix = "HordeServer.";
const string Suffix = ".dll";
if (fileInfo.Name.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) && fileInfo.Name.EndsWith(Suffix, StringComparison.OrdinalIgnoreCase) && fileInfo.Name.Length > Prefix.Length + Suffix.Length)
{
files.Add(new FileReference(fileInfo));
}
}
HashSet<string> missingPlugins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach ((string name, PluginServerConfig pluginConfig) in pluginConfigs)
{
if (pluginConfig.Enabled ?? true)
{
missingPlugins.Add(name);
}
}
PluginCollection pluginCollection = new PluginCollection();
foreach (FileReference file in files)
{
logger.LogInformation("Loading {File}", file);
try
{
Assembly assembly = Assembly.LoadFrom(file.FullName);
foreach (Type type in assembly.GetExportedTypes())
{
PluginAttribute? pluginAttribute = type.GetCustomAttribute<PluginAttribute>();
if (pluginAttribute != null)
{
bool? enabled = null;
if (pluginConfigs.TryGetValue(pluginAttribute.Name, out PluginServerConfig? pluginConfig))
{
enabled = pluginConfig.Enabled;
}
if (enabled ?? pluginAttribute.EnabledByDefault)
{
logger.LogDebug("Added plugin '{Plugin}'", pluginAttribute.Name);
pluginCollection.Add(type);
missingPlugins.Remove(pluginAttribute.Name);
}
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error loading plugins from {File}: {Message}", file, ex.Message);
throw;
}
}
if (missingPlugins.Count > 0)
{
throw new InvalidOperationException($"Unable to find plugin(s) enabled in config file: {StringUtils.FormatList(missingPlugins)}");
}
return pluginCollection;
}
// Used by WebApplicationFactory in controller tests. Uses reflection to call this exact function signature.
public static IHostBuilder CreateHostBuilder(string[] args) => ServerCommand.CreateHostBuilderForTesting(args);
/// <summary>
/// Gets the current environment
/// </summary>
/// <returns></returns>
static string GetEnvironment()
{
string? environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (String.IsNullOrEmpty(environment))
{
environment = "Production";
}
return environment;
}
/// <summary>
/// Get the application directory
/// </summary>
/// <returns></returns>
static DirectoryReference GetAppDir()
{
return new FileReference(Assembly.GetExecutingAssembly().Location).Directory;
}
/// <summary>
/// List of config settings to rename to new locations
/// </summary>
static readonly KeyValuePair<string, string>[] s_renamedConfigValues = new[]
{
// Build plugin
KeyValuePair.Create("Horde:UseLocalPerforceEnv", "Horde:Plugins:Build:UseLocalPerforceEnv"),
KeyValuePair.Create("Horde:PerforceConnectionPoolSize", "Horde:Plugins:Build:PerforceConnectionPoolSize"),
KeyValuePair.Create("Horde:EnableConformTasks", "Horde:Plugins:Build:EnableConformTasks"),
KeyValuePair.Create("Horde:P4SwarmUrl", "Horde:Plugins:Build:P4SwarmUrl"),
KeyValuePair.Create("Horde:RobomergeUrl", "Horde:Plugins:Build:RobomergeUrl"),
KeyValuePair.Create("Horde:CommitsViewerUrl", "Horde:Plugins:Build:CommitsViewerUrl"),
KeyValuePair.Create("Horde:JiraUsername", "Horde:Plugins:Build:JiraUsername"),
KeyValuePair.Create("Horde:JiraApiToken", "Horde:Plugins:Build:JiraApiToken"),
KeyValuePair.Create("Horde:JiraUrl", "Horde:Plugins:Build:JiraUrl"),
KeyValuePair.Create("Horde:SharedDeviceCheckoutDays", "Horde:Plugins:Build:SharedDeviceCheckoutDays"),
KeyValuePair.Create("Horde:DeviceProblemCooldownMinutes", "Horde:Plugins:Build:DeviceProblemCooldownMinutes"),
KeyValuePair.Create("Horde:DeviceReportChannel", "Horde:Plugins:Build:DeviceReportChannel"),
KeyValuePair.Create("Horde:DisableSchedules", "Horde:Plugins:Build:DisableSchedules"),
KeyValuePair.Create("Horde:SlackToken", "Horde:Plugins:Build:SlackToken"),
KeyValuePair.Create("Horde:SlackSocketToken", "Horde:Plugins:Build:SlackSocketToken"),
KeyValuePair.Create("Horde:SlackAdminToken", "Horde:Plugins:Build:SlackAdminToken"),
KeyValuePair.Create("Horde:SlackUsers", "Horde:Plugins:Build:SlackUsers"),
KeyValuePair.Create("Horde:SlackErrorPrefix", "Horde:Plugins:Build:SlackErrorPrefix"),
KeyValuePair.Create("Horde:SlackWarningPrefix", "Horde:Plugins:Build:SlackWarningPrefix"),
KeyValuePair.Create("Horde:ConfigNotificationChannel", "Horde:Plugins:Build:ConfigNotificationChannel"),
KeyValuePair.Create("Horde:UpdateStreamsNotificationChannel", "Horde:Plugins:Build:UpdateStreamsNotificationChannel"),
KeyValuePair.Create("Horde:JobNotificationChannel", "Horde:Plugins:Build:JobNotificationChannel"),
KeyValuePair.Create("Horde:AgentNotificationChannel", "Horde:Plugins:Build:AgentNotificationChannel"),
KeyValuePair.Create("Horde:TestDataRetainMonths", "Horde:Plugins:Build:TestDataRetainMonths"),
KeyValuePair.Create("Horde:BlockCacheDir", "Horde:Plugins:Build:BlockCacheDir"),
KeyValuePair.Create("Horde:BlockCacheSize", "Horde:Plugins:Build:BlockCacheSize"),
KeyValuePair.Create("Horde:Perforce:", "Horde:Plugins:Build:Perforce:"),
KeyValuePair.Create("Horde:Commits:", "Horde:Plugins:Build:Commits:"),
// Compute plugin
KeyValuePair.Create("Horde:EnableUpgradeTasks", "Horde:Plugins:Compute:EnableUpgradeTasks"),
KeyValuePair.Create("Horde:FleetManagerV2", "Horde:Plugins:Compute:FleetManagerV2"),
KeyValuePair.Create("Horde:FleetManagerV2Config", "Horde:Plugins:Compute:FleetManagerV2Config"),
KeyValuePair.Create("Horde:AwsAutoScalingQueueUrls", "Horde:Plugins:Compute:AwsAutoScalingQueueUrls"),
KeyValuePair.Create("Horde:AutoEnrollAgents", "Horde:Plugins:Compute:AutoEnrollAgents"),
KeyValuePair.Create("Horde:DefaultAgentPoolSizeStrategy", "Horde:Plugins:Compute:DefaultAgentPoolSizeStrategy"),
KeyValuePair.Create("Horde:AgentPoolScaleOutCooldownSeconds", "Horde:Plugins:Compute:AgentPoolScaleOutCooldownSeconds"),
KeyValuePair.Create("Horde:AgentPoolScaleInCooldownSeconds", "Horde:Plugins:Compute:AgentPoolScaleInCooldownSeconds"),
KeyValuePair.Create("Horde:ComputeTunnelPort", "Horde:Plugins:Compute:ComputeTunnelPort"),
KeyValuePair.Create("Horde:ComputeTunnelAddress", "Horde:Plugins:Compute:ComputeTunnelAddress"),
KeyValuePair.Create("Horde:WithAws", "Horde:Plugins:Compute:WithAws"),
// Storage plugin
KeyValuePair.Create("Horde:BundleCacheDir", "Horde:Plugins:Storage:BundleCacheDir"),
KeyValuePair.Create("Horde:BundleCacheSize", "Horde:Plugins:Storage:BundleCacheSize"),
KeyValuePair.Create("Horde:Backends:", "Horde:Plugins:Storage:Backends:"),
// Tools plugin
KeyValuePair.Create("Horde:BundledTools:", "Horde:Plugins:Tools:BundledTools:")
};
/// <summary>
/// Constructs a configuration object for the current environment
/// </summary>
/// <returns></returns>
static IConfiguration CreateConfig(bool readInstalledConfig, FileReference? serverConfigFile)
{
IConfigurationBuilder builder = new ConfigurationBuilder();
string? regDataDir = null;
if (OperatingSystem.IsWindows())
{
if (readInstalledConfig)
{
builder = builder.Add(new RegistryConfigurationSource(Registry.LocalMachine, "SOFTWARE\\Epic Games\\Horde\\Server", ServerSettings.SectionName));
}
else
{
// Check the registry for an installed server, if this is running we need to set the data directory if available
string? installedServerExecutable = Registry.GetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Epic Games\\Horde\\Server", "InstalledServerExecutable", null) as string;
if (!String.IsNullOrEmpty(installedServerExecutable))
{
FileReference installedServer = new FileReference(installedServerExecutable);
FileReference executingServer = new FileReference(Assembly.GetExecutingAssembly().Location);
if (installedServer == executingServer)
{
regDataDir = Registry.GetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Epic Games\\Horde\\Server", "DataDir", null) as string;
}
}
}
}
builder.SetBasePath(AppDir.FullName)
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Build.json", optional: true) // specific settings for builds (installer/dockerfile)
.AddJsonFile($"appsettings.{DeploymentEnvironment}.json", optional: true) // environment variable overrides, also used in k8s setups with Helm
.AddJsonFile("appsettings.User.json", optional: true);
if (serverConfigFile != null)
{
builder = builder.AddJsonFile(serverConfigFile.FullName, optional: true, reloadOnChange: true);
}
builder = builder.AddEnvironmentVariables();
// Create a temporary configuration object and apply any upgrades and key renames
IConfiguration configuration = builder.Build();
List<KeyValuePair<string, string?>> remappedValues = new List<KeyValuePair<string, string?>>();
foreach ((string source, string target) in s_renamedConfigValues)
{
if (source.EndsWith(':'))
{
foreach (KeyValuePair<string, string?> pair in configuration.AsEnumerable().Where(x => x.Key.StartsWith(source, StringComparison.OrdinalIgnoreCase)))
{
if (pair.Value != null)
{
remappedValues.Add(new KeyValuePair<string, string?>(target + pair.Key.Substring(source.Length), pair.Value));
}
}
}
else
{
string? value = configuration[source];
if (value != null && configuration[target] == null)
{
remappedValues.Add(new KeyValuePair<string, string?>(target, value));
}
}
}
// set data directory if we have one from registry
if (!readInstalledConfig && !String.IsNullOrEmpty(regDataDir))
{
remappedValues.Add(new KeyValuePair<string, string?>("Horde:DataDir", regDataDir));
}
builder.Add(new MemoryConfigurationSource { InitialData = remappedValues });
return builder.Build();
}
static async Task CopyDefaultConfigFilesAsync(DirectoryReference sourceDir, DirectoryReference targetDir, CancellationToken cancellationToken)
{
DirectoryReference.CreateDirectory(targetDir);
foreach (FileReference sourceFile in DirectoryReference.EnumerateFiles(sourceDir))
{
if ((sourceFile.HasExtension(".json") || sourceFile.HasExtension(".png")) && !sourceFile.GetFileName().StartsWith("default", StringComparison.OrdinalIgnoreCase))
{
FileReference targetFile = FileReference.Combine(targetDir, sourceFile.GetFileName());
if (!FileReference.Exists(targetFile))
{
// Copy the data to the output file. Create a new file to reset permissions.
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, cancellationToken);
}
}
}
}
}
}
}