// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Horde.Users; using EpicGames.Redis; using HordeServer.Acls; using HordeServer.Plugins; using HordeServer.Server; using HordeServer.Users; using HordeServer.Utilities; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using ProtoBuf; using StackExchange.Redis; namespace HordeServer.Configuration { using JsonObject = System.Text.Json.Nodes.JsonObject; /// /// Service which processes runtime configuration data. /// public sealed class ConfigService : IConfigService, IOptionsFactory, IOptionsChangeTokenSource, IHostedService, IAsyncDisposable { // Index to all current config files [ProtoContract] class ConfigSnapshot { [ProtoMember(1)] public byte[] Data { get; set; } = null!; [ProtoMember(4)] public Dictionary Dependencies { get; set; } = new Dictionary(); [ProtoMember(5)] public string ServerVersion { get; set; } = String.Empty; } // Implements a token for config change notifications class ChangeToken : IChangeToken { class Registration : IDisposable { readonly List _registrations; readonly Action _callback; readonly object? _state; public Registration(List registrations, Action callback, object? state) { _registrations = registrations; _callback = callback; _state = state; lock (registrations) { registrations.Add(this); } } public void Dispose() { lock (_registrations) { _registrations.Remove(this); } } public void Trigger() => _callback(_state); } readonly List _registrations = new List(); public bool ActiveChangeCallbacks => true; public bool HasChanged { get; private set; } public void TriggerChange() { HasChanged = true; Registration[] registrations; lock (_registrations) { registrations = _registrations.ToArray(); } foreach (Registration registration in registrations) { registration.Trigger(); } HasChanged = false; } public IDisposable RegisterChangeCallback(Action callback, object? state) { return new Registration(_registrations, callback, state); } } // The current config record class ConfigState(IoHash Hash, GlobalConfig GlobalConfig); readonly IRedisService _redisService; readonly ServerSettings _serverSettings; readonly Dictionary _sources; readonly IPluginCollection _pluginCollection; readonly JsonSerializerOptions _jsonOptions; readonly RedisKey _snapshotKey = "config-v2"; readonly IEnumerable _defaultAclModifiers; readonly IHealthMonitor _health; readonly ILogger _logger; readonly ITicker _ticker; readonly TimeSpan _tickInterval = TimeSpan.FromMinutes(1.0); readonly RedisChannel _updateChannel = RedisChannel.Literal("config-update-v2"); readonly BackgroundTask _updateTask; Task _stateTask; readonly ChangeToken _changeToken = new ChangeToken(); /// string IOptionsChangeTokenSource.Name => String.Empty; /// /// Event for notifications that the config has been updated /// public event Action? OnConfigUpdate; /// /// Constructor /// public ConfigService(IRedisService redisService, IOptions serverSettings, IEnumerable sources, IPluginCollection pluginCollection, IEnumerable aclModifiers, IClock clock, IHealthMonitor health, ILogger logger) { _redisService = redisService; _serverSettings = serverSettings.Value; _sources = sources.ToDictionary(x => x.Scheme, x => x, StringComparer.OrdinalIgnoreCase); _defaultAclModifiers = aclModifiers; _pluginCollection = pluginCollection; _health = health; _health.SetName("GlobalConfig"); _logger = logger; _jsonOptions = new JsonSerializerOptions(); JsonUtils.ConfigureJsonSerializer(_jsonOptions); PluginConfigCollectionConverter pluginConfigConverter = new PluginConfigCollectionConverter(); foreach (ILoadedPlugin plugin in pluginCollection.LoadedPlugins) { if (plugin.GlobalConfigType != null) { pluginConfigConverter.NameToType[plugin.Name] = plugin.GlobalConfigType; } } _jsonOptions.Converters.Add(pluginConfigConverter); _ticker = clock.AddSharedTicker(_tickInterval, TickSharedAsync, logger); _updateTask = new BackgroundTask(WaitForUpdatesAsync); _stateTask = Task.Run(() => GetStartupStateAsync()); } /// public async ValueTask DisposeAsync() { await _stateTask; await _updateTask.DisposeAsync(); await _ticker.DisposeAsync(); } /// /// Wait for the initial config state to be read. /// public async Task WaitForInitialConfigAsync(CancellationToken cancellationToken = default) { ConfigState state = await _stateTask.WaitAsync(cancellationToken); return state.GlobalConfig; } class OverrideConfigFile : IConfigFile { readonly byte[] _data; public Uri Uri { get; } public string Revision => "New"; public IUser? Author => null; public bool WasRead { get; private set; } public OverrideConfigFile(Uri uri, byte[] data) { Uri = uri; _data = data; } public ValueTask> ReadAsync(CancellationToken cancellationToken) { WasRead = true; return new ValueTask>(_data); } } class OverrideConfigSource : IConfigSource { readonly IConfigSource _inner; readonly Dictionary _overrides; public string Scheme => _inner.Scheme; public TimeSpan UpdateInterval => TimeSpan.FromSeconds(1.0); public OverrideConfigSource(IConfigSource inner, Dictionary overrides) { _inner = inner; _overrides = overrides; } public void Add(OverrideConfigFile file) => _overrides.Add(file.Uri, file); public async Task GetFilesAsync(Uri[] uris, CancellationToken cancellationToken) { IConfigFile[] files = new IConfigFile[uris.Length]; for (int idx = 0; idx < uris.Length; idx++) { IConfigFile? file; if (_overrides.TryGetValue(uris[idx], out OverrideConfigFile? overrideFile)) { file = overrideFile; } else { file = (await _inner.GetFilesAsync(new[] { uris[idx] }, cancellationToken))[0]; } files[idx] = file; } return files; } /// public Task GetUpdateInfoAsync(IReadOnlyDictionary files, IReadOnlyDictionary? prevFiles, ConfigUpdateInfo updateInfo, CancellationToken cancellationToken) => Task.CompletedTask; } ConfigContext CreateConfigContext(IReadOnlyDictionary sources, ILogger logger) { ConfigContext context = new ConfigContext(_jsonOptions, sources, logger); context.MacroScopes.Add(new Dictionary { ["HordeDir"] = ServerApp.AppDir.FullName }); return context; } /// /// Validate a new set of config files. Parses and runs PostLoad methods on them. /// public async Task ValidateAsync(Dictionary files, CancellationToken cancellationToken) { Dictionary overrideFiles = new Dictionary(); foreach ((Uri uri, byte[] data) in files) { overrideFiles[uri] = new OverrideConfigFile(uri, data); } Dictionary overrideSources = new Dictionary(_sources.Count, _sources.Comparer); foreach ((string schema, IConfigSource source) in _sources) { overrideSources.Add(schema, new OverrideConfigSource(source, overrideFiles)); } ConfigContext context = CreateConfigContext(overrideSources, NullLogger.Instance); try { GlobalConfig globalConfig = await ReadGlobalConfigAsync(context, cancellationToken); await globalConfig.PostLoadAsync(_serverSettings, _pluginCollection.LoadedPlugins, _defaultAclModifiers); foreach (OverrideConfigFile file in overrideFiles.Values) { if (!file.WasRead) { return $"File {file.Uri} was not read by server"; } } return null; } catch (Exception ex) { StringBuilder message = new StringBuilder(ex.Message); message.Append("\n\nLocation:\n"); foreach (IConfigFile include in context.IncludeStack) { string path = include.GetUserFormattedPath(); message.Append($" {path}\n"); } if (ex is not ConfigException && ex is not JsonException) { message.Append($"\n\nTrace:\n{ex.StackTrace}"); _logger.LogWarning(ex, "Unhandled exception while validating config files: {Message}", ex.Message); } return message.ToString(); } } /// public async Task StartAsync(CancellationToken cancellationToken) { // Wait for the initial config update to complete await _stateTask; if (_serverSettings.IsRunModeActive(RunMode.Worker)) { await _ticker.StartAsync(); } _updateTask.Start(); } /// public async Task StopAsync(CancellationToken cancellationToken) { await _updateTask.StopAsync(cancellationToken); if (_serverSettings.IsRunModeActive(RunMode.Worker)) { await _ticker.StopAsync(); } } /// /// Accessor for tests to update the global config /// /// New config value public void OverrideConfig(GlobalConfig globalConfig) { Set(IoHash.Zero, globalConfig); } /// /// Updates the current config /// /// Hash for the config data /// New config value void Set(IoHash hash, GlobalConfig globalConfig) { // Set the new state _stateTask = Task.FromResult(new ConfigState(hash, globalConfig)); // Notify any watchers of the new config object _changeToken.TriggerChange(); } /// public GlobalConfig Create(string name) { #pragma warning disable VSTHRD002 // Synchronous wait return _stateTask.Result.GlobalConfig; #pragma warning restore VSTHRD002 // Synchronous wait } /// public IChangeToken GetChangeToken() => _changeToken; async Task GetStartupStateAsync() { try { bool forceConfigUpdate = _serverSettings.ForceConfigUpdateOnStartup; if (forceConfigUpdate && _redisService.ReadOnlyMode) { _logger.LogInformation("Ignoring flag to force config update on startup due to Redis configured in read-only mode."); forceConfigUpdate = false; } ReadOnlyMemory data = ReadOnlyMemory.Empty; if (!forceConfigUpdate) { data = await ReadSnapshotDataAsync(); } if (data.Length == 0) { ConfigSnapshot snapshot = await CreateSnapshotAsync(CancellationToken.None); await WriteSnapshotAsync(snapshot); data = SerializeSnapshot(snapshot); } ConfigState state = new ConfigState(IoHash.Compute(data.Span), await CreateGlobalConfigAsync(data)); await _health.UpdateAsync(HealthStatus.Healthy); return state; } catch (Exception ex) { await _health.UpdateAsync(HealthStatus.Unhealthy, ex.Message); throw; } } async ValueTask TickSharedAsync(CancellationToken cancellationToken) { // Read a new copy of the current snapshot in the context of being elected to perform updates. This // avoids any latency with receiving notifications on the pub/sub channel about changes. ReadOnlyMemory initialData = await ReadSnapshotDataAsync(); // Print info about the initial snapshot ConfigSnapshot? snapshot = null; if (initialData.Length > 0) { snapshot = DeserializeSnapshot(initialData); _logger.LogDebug("Initial snapshot for update: {@Info}", GetSnapshotInfo(initialData.Span, snapshot)); } // Update the snapshot until we're asked to stop while (!cancellationToken.IsCancellationRequested) { if (snapshot == null || await IsOutOfDateAsync(snapshot, cancellationToken)) { snapshot = await CreateSnapshotGuardedAsync(snapshot?.Dependencies, cancellationToken); if (snapshot != null) { try { await WriteSnapshotAsync(snapshot); } catch (Exception ex) { _logger.LogError(ex, "Error while updating config: {Message}", ex.Message); } } } TimeSpan tickInterval = GetConfigUpdateInterval(snapshot); await Task.Delay(tickInterval, cancellationToken); } } async Task WaitForUpdatesAsync(CancellationToken cancellationToken) { AsyncEvent updateEvent = new AsyncEvent(); await using (RedisSubscription subscription = await _redisService.SubscribeAsync(_updateChannel, _ => updateEvent.Pulse())) { Task cancellationTask = Task.Delay(-1, cancellationToken); while (!cancellationToken.IsCancellationRequested) { Task updateTask = updateEvent.Task; try { RedisValue value = await _redisService.GetDatabase().StringGetAsync(_snapshotKey); if (!value.IsNullOrEmpty) { await UpdateConfigObjectAsync(value); } } catch (Exception ex) { _logger.LogError(ex, "Exception while updating config from Redis: {Message}", ex.Message); await Task.Delay(TimeSpan.FromMinutes(1.0), cancellationToken); continue; } await await Task.WhenAny(updateTask, cancellationTask); } } } /// /// Create a new snapshot object, catching any exceptions and sending notifications /// /// Previous snapshot data /// Cancellation token for the operation /// New config snapshot async Task CreateSnapshotGuardedAsync(IReadOnlyDictionary? prevFiles, CancellationToken cancellationToken) { try { ConfigSnapshot snapshot = await CreateSnapshotAsync(cancellationToken); await _health.UpdateAsync(HealthStatus.Healthy); await NotifyConfigUpdateAsync(snapshot.Dependencies, prevFiles, null, cancellationToken); return snapshot; } catch (ConfigException ex) { _logger.LogError(ex, "Exception while updating config: {Message}", ex.Message); await _health.UpdateAsync(HealthStatus.Unhealthy, ex.Message); await NotifyConfigUpdateAsync(ex.GetContext().Files.ToDictionary(x => x.Key, x => x.Value.Revision), prevFiles, ex, cancellationToken); return null; } } async Task NotifyConfigUpdateAsync(IReadOnlyDictionary files, IReadOnlyDictionary? prevFiles, Exception? exception, CancellationToken cancellationToken) { ConfigUpdateInfo info = new ConfigUpdateInfo(new List(), new HashSet(), exception); foreach (IConfigSource source in _sources.Values) { await source.GetUpdateInfoAsync(files, prevFiles, info, cancellationToken); } OnConfigUpdate?.Invoke(info); } internal Uri GetGlobalConfigUri() { return GetGlobalConfigUri(_serverSettings.ConfigPath, ServerApp.ConfigDir.FullName); } /// /// Gets the path to the root config file /// internal static Uri GetGlobalConfigUri(string configPath, string defaultConfigDir) { bool isPerforcePath = configPath.StartsWith("//", StringComparison.Ordinal); if (isPerforcePath) { return ConfigNode.CombinePaths(new Uri(FileReference.Combine(new DirectoryReference(defaultConfigDir), "_").FullName), configPath); } Uri? uri; if (Uri.TryCreate(configPath, UriKind.Absolute, out uri)) { return uri; } // Path is relative FileReference fileReference = FileReference.Combine(new DirectoryReference(defaultConfigDir), configPath); return new UriBuilder("file", String.Empty) { Path = fileReference.FullName }.Uri; } /// /// Get the appropriate update interval for checking of new config updates /// TimeSpan GetConfigUpdateInterval(ConfigSnapshot? snapshot) { TimeSpan interval = TimeSpan.FromSeconds(5.0); foreach (Uri sourceUri in GetConfigSourceUris(snapshot)) { if (_sources.TryGetValue(sourceUri.Scheme, out IConfigSource? configSource)) { TimeSpan sourceInterval = configSource.UpdateInterval; if (sourceInterval > interval) { interval = sourceInterval; } } } return interval; } /// /// Enumerates all the known URIs for reading the given config snapshot. /// IEnumerable GetConfigSourceUris(ConfigSnapshot? snapshot) { if (snapshot == null) { return new[] { GetGlobalConfigUri() }; } else { return snapshot.Dependencies.Keys; } } /// /// Create a new snapshot object /// /// Cancellation token for the operation /// New config snapshot async Task CreateSnapshotAsync(CancellationToken cancellationToken) { // Read the config files ConfigContext context = CreateConfigContext(_sources, _logger); try { // Bind it to the target object GlobalConfig globalConfig = await ReadGlobalConfigAsync(context, cancellationToken); if (globalConfig.VersionEnum < ConfigVersion.Latest) { List message = new List(); message.Add($"Support for the following features will be removed in an upcoming release:"); message.Add(""); if (globalConfig.VersionEnum < ConfigVersion.PoolsInConfigFiles) { message.Add($"- v{(int)ConfigVersion.PoolsInConfigFiles}: Pools should now be configured through the globals.json file rather than REST API or database. The /api/v1/server/migrate/pool-config endpoint will transcribe your configured pools into JSON."); } message.Add(""); message.Add($"Please migrate your installation and update the 'Version' property in globals.json to {(int)ConfigVersion.Latest}"); _logger.LogWarning("Global config file is using old version number ({Version}<{LatestVersion})\n\n{DeprecatedFeaturesMessage}\n", globalConfig.Version, (int)ConfigVersion.Latest, String.Join("\n", message)); } // Create the snapshot data, with the config serialized back out to a byte array ConfigSnapshot snapshot = new ConfigSnapshot(); snapshot.ServerVersion = ServerApp.Version.ToString(); snapshot.Data = JsonSerializer.SerializeToUtf8Bytes(globalConfig, context.JsonOptions); // Save all the dependencies foreach ((Uri depUri, IConfigFile depFile) in context.Files) { snapshot.Dependencies.Add(depUri, depFile.Revision); } // Execute a PostLoad before returning so we can validate that everything is valid await globalConfig.PostLoadAsync(_serverSettings, _pluginCollection.LoadedPlugins, _defaultAclModifiers); return snapshot; } catch (Exception ex) when (ex is not ConfigException) { throw new ConfigException(context, ex.Message, ex); } } async Task ReadGlobalConfigAsync(ConfigContext context, CancellationToken cancellationToken) { // Read the new config in Uri globalConfigUri = GetGlobalConfigUri(); // Generate the schema for the preprocessor ObjectConfigNode configNode = new ObjectConfigNode(typeof(GlobalConfig)); ObjectConfigNode pluginsObj = new ObjectConfigNode(false, false); configNode.Properties["Plugins"] = pluginsObj; foreach (ILoadedPlugin plugin in _pluginCollection.LoadedPlugins) { if (plugin.GlobalConfigType != null) { pluginsObj.Properties.Add(plugin.Name.ToString(), new ObjectConfigNode(plugin.GlobalConfigType)); } } // Copy the schema from any remapped nodes to the source node foreach (KeyValuePair remapConfigPair in s_remapConfigValues) { if (configNode.TryGetChildNode(remapConfigPair.Value, out ConfigNode? node)) { configNode.AddChildNode(remapConfigPair.Key, node); } } // Preprocess the file JsonObject configObj = await context.PreprocessFileAsync(globalConfigUri, configNode, cancellationToken); // Copy the preprocessed data into the right location foreach ((string source, string target) in s_remapConfigValues) { CopyJsonNode(configObj, source, target); } // Bind it to the target object return JsonSerializer.Deserialize(configObj, _jsonOptions)!; } static readonly KeyValuePair[] s_remapConfigValues = new[] { // Analytics KeyValuePair.Create("TelemetryStores", "Plugins.Analytics.Stores"), // Build KeyValuePair.Create("PerforceClusters", "Plugins.Build.PerforceClusters"), KeyValuePair.Create("Devices", "Plugins.Build.Devices"), KeyValuePair.Create("MaxConformCount", "Plugins.Build.MaxConformCount"), KeyValuePair.Create("AgentShutdownIfDisabledGracePeriod", "Plugins.Build.AgentShutdownIfDisabledGracePeriod"), KeyValuePair.Create("ArtifactTypes", "Plugins.Build.ArtifactTypes"), KeyValuePair.Create("Projects", "Plugins.Build.Projects"), KeyValuePair.Create("IssueFixedTag", "Plugins.Build.IssueFixedTag"), // Compute KeyValuePair.Create("Rates", "Plugins.Compute.Rates"), KeyValuePair.Create("Compute", "Plugins.Compute.Clusters"), KeyValuePair.Create("Pools", "Plugins.Compute.Pools"), KeyValuePair.Create("Software", "Plugins.Compute.Software"), KeyValuePair.Create("Networks", "Plugins.Compute.Network"), // Secrets KeyValuePair.Create("Secrets", "Plugins.Secrets.Secrets"), // Storage KeyValuePair.Create("Storage", "Plugins.Storage"), // Tools KeyValuePair.Create("Tools", "Plugins.Tools.Tools") }; static void CopyJsonNode(JsonObject rootObj, string sourcePath, string targetPath) { JsonNode? sourceNode = rootObj; string[] sourceFragments = sourcePath.Split('.'); foreach (string sourceFragment in sourceFragments) { sourceNode = sourceNode[sourceFragment]; if (sourceNode == null) { return; } } JsonObject? targetObj = rootObj; string[] targetFragments = targetPath.Split('.'); for (int idx = 0; idx < targetFragments.Length - 1; idx++) { string targetFragment = targetFragments[idx]; JsonObject? nextObj = targetObj[targetFragment] as JsonObject; if (nextObj == null) { nextObj = new JsonObject(); targetObj[targetFragment] = nextObj; } targetObj = nextObj; } targetObj[targetFragments[^1]] = sourceNode.DeepClone(); } internal byte[] Serialize(GlobalConfig config) { return JsonSerializer.SerializeToUtf8Bytes(config, _jsonOptions); } internal GlobalConfig? Deserialize(byte[] data, bool withData) { JsonSerializerOptions options = new JsonSerializerOptions(_jsonOptions); options.Converters.Add(new ConfigResourceSerializer(withData)); return JsonSerializer.Deserialize(data, options); } /// /// Writes information about the current snapshot to the logger /// /// Data for the snapshot /// Snapshot to log information on static object GetSnapshotInfo(ReadOnlySpan span, ConfigSnapshot snapshot) { return new { Revision = IoHash.Compute(span).ToString(), ServerVersion = snapshot.ServerVersion.ToString(), Dependencies = snapshot.Dependencies.ToArray() }; } /// /// Checks if the given snapshot is out of date /// /// The current config snapshot /// Cancellation token for the operation /// True if the snapshot is out of date async Task IsOutOfDateAsync(ConfigSnapshot snapshot, CancellationToken cancellationToken) { try { // Always re-read the config file when switching server versions string newServerVersion = ServerApp.Version.ToString(); if (!snapshot.ServerVersion.Equals(newServerVersion, StringComparison.Ordinal)) { _logger.LogInformation("Config is out of date (server version {OldVersion} -> {NewVersion})", snapshot.ServerVersion, newServerVersion); return true; } // Group the dependencies by scheme in order to allow the source to batch-query them foreach (IGrouping> group in snapshot.Dependencies.GroupBy(x => x.Key.Scheme)) { KeyValuePair[] pairs = group.ToArray(); IConfigSource? source; if (!_sources.TryGetValue(group.Key, out source)) { _logger.LogInformation("Config is out of date (missing source '{Source}')", group.Key); return true; } IConfigFile[] files = await source.GetFilesAsync(pairs.ConvertAll(x => x.Key), cancellationToken); for (int idx = 0; idx < pairs.Length; idx++) { if (!files[idx].Revision.Equals(pairs[idx].Value, StringComparison.Ordinal)) { _logger.LogInformation("Config is out of date (file {Path}: {OldVersion} -> {NewVersion})", files[idx].Uri, pairs[idx].Value, files[idx].Revision); return true; } } } return false; } catch (Exception ex) { _logger.LogInformation(ex, "Exception while checking for config files; assuming out of date"); return true; } } async Task WriteSnapshotAsync(ConfigSnapshot snapshot) { ReadOnlyMemory data = SerializeSnapshot(snapshot); IoHash hash = IoHash.Compute(data.Span); await _redisService.GetDatabase().StringSetAsync(_snapshotKey, data); await _redisService.PublishAsync(_updateChannel, RedisValue.EmptyString); _logger.LogInformation("Published new config snapshot (hash: {Hash}, server: {Server}, size: {Size})", hash.ToString(), ServerApp.Version.ToString(), data.Length); } async Task> ReadSnapshotDataAsync() { RedisValue value = await _redisService.GetDatabase().StringGetAsync(_snapshotKey); return value.IsNullOrEmpty ? ReadOnlyMemory.Empty : (ReadOnlyMemory)value; } static ReadOnlyMemory SerializeSnapshot(ConfigSnapshot snapshot) { using (MemoryStream outputStream = new MemoryStream()) { using GZipStream zipStream = new GZipStream(outputStream, CompressionMode.Compress, false); ProtoBuf.Serializer.Serialize(zipStream, snapshot); zipStream.Flush(); return outputStream.ToArray(); } } static ConfigSnapshot DeserializeSnapshot(ReadOnlyMemory data) { using (ReadOnlyMemoryStream outputStream = new ReadOnlyMemoryStream(data)) { using GZipStream zipStream = new GZipStream(outputStream, CompressionMode.Decompress, false); return ProtoBuf.Serializer.Deserialize(zipStream); } } async Task UpdateConfigObjectAsync(ReadOnlyMemory data) { ConfigState state = await _stateTask; // Hash the data and only update if it changes; this prevents any double-updates due to time between initialization and the hosted service starting. IoHash hash = IoHash.Compute(data.Span); if (hash != state.Hash && state.Hash != IoHash.Zero) // Don't replace any explicit config override { GlobalConfig globalConfig = await CreateGlobalConfigAsync(data); _logger.LogInformation("Updating config state from Redis (hash: {OldHash} -> {NewHash}, new size: {Size})", state.Hash, hash, data.Length); Set(hash, globalConfig); } } async Task CreateGlobalConfigAsync(ReadOnlyMemory data) { // Decompress the snapshot object ConfigSnapshot snapshot = DeserializeSnapshot(data); // Build the new config and store the project and stream configs inside it GlobalConfig globalConfig = JsonSerializer.Deserialize(snapshot.Data, _jsonOptions)!; globalConfig.Revision = IoHash.Compute(data.Span).ToString(); // Run the postload callbacks on all the config objects await globalConfig.PostLoadAsync(_serverSettings, _pluginCollection.LoadedPlugins, _defaultAclModifiers); return globalConfig; } } }