// Copyright Epic Games, Inc. All Rights Reserved. using EpicGames.Core; using EpicGames.Horde.Storage; using EpicGames.Horde.Storage.Bundles; using EpicGames.Horde.Storage.Clients; using EpicGames.Horde.Storage.Nodes; using EpicGames.Serialization; using Horde.Server.Server; using Horde.Server.Storage; using Horde.Server.Utilities; using HordeCommon; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using MongoDB.Bson; using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using StackExchange.Redis; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace Horde.Server.Tools { /// /// Collection of tool documents /// public class ToolCollection : IToolCollection { class Tool : VersionedDocument, ITool { [BsonIgnore] public ToolConfig Config { get; set; } = null!; [BsonElement("dep")] public List Deployments { get; set; } = new List(); // ITool interface IReadOnlyList ITool.Deployments => Deployments; [BsonConstructor] public Tool(ToolId id) : base(id) { Config = null!; } public Tool(ToolConfig config) : base(config.Id) { Config = config; } /// public override Tool UpgradeToLatest() => this; public void UpdateTemporalState(DateTime utcNow) { foreach (ToolDeployment deployment in Deployments) { deployment.UpdateTemporalState(utcNow); } } } class ToolDeployment : IToolDeployment { public ToolDeploymentId Id { get; set; } [BsonElement("ver")] public string Version { get; set; } [BsonIgnore] public ToolDeploymentState State { get; set; } [BsonIgnore] public double Progress { get; set; } [BsonElement("bpr")] public double BaseProgress { get; set; } [BsonElement("stm")] public DateTime? StartedAt { get; set; } [BsonElement("dur")] public TimeSpan Duration { get; set; } [BsonElement("ref")] public RefName RefName { get; set; } public ToolDeployment(ToolDeploymentId id) { Id = id; Version = String.Empty; } public ToolDeployment(ToolDeploymentId id, ToolDeploymentConfig options, RefName refName) { Id = id; Version = options.Version; Duration = options.Duration; RefName = refName; } public void UpdateTemporalState(DateTime utcNow) { if (BaseProgress >= 1.0) { State = ToolDeploymentState.Complete; Progress = 1.0; } else if (StartedAt == null) { State = ToolDeploymentState.Paused; Progress = BaseProgress; } else if (Duration > TimeSpan.Zero) { State = ToolDeploymentState.Active; Progress = Math.Clamp((utcNow - StartedAt.Value) / Duration, 0.0, 1.0); } else { State = ToolDeploymentState.Complete; Progress = 1.0; } } } private class ToolDeploymentData { [CbField] public ToolDeploymentId Id { get; set; } [CbField("version")] public string Version { get; set; } = String.Empty; [CbField("data")] public CbBinaryAttachment Data { get; set; } } private class CachedIndex { [CbField("rev")] public string Rev { get; set; } = String.Empty; [CbField("empty")] public bool Empty { get; set; } [CbField("ids")] public List Ids { get; set; } = new List(); } private readonly VersionedCollection _tools; private readonly StorageService _storageService; private readonly IClock _clock; private readonly BundleReaderCache _cache; private readonly ILogger _logger; private static readonly RedisKey s_baseKey = "tools/v1/"; private static readonly IReadOnlyDictionary s_types = RegisterTypes(); /// /// Constructor /// public ToolCollection(MongoService mongoService, RedisService redisService, StorageService storageService, BundleReaderCache cache, IClock clock, ILogger logger) { _tools = new VersionedCollection(mongoService, "Tools", redisService, s_baseKey, s_types); _storageService = storageService; _clock = clock; _cache = cache; _logger = logger; } /// /// Registers types required for this collection /// /// static IReadOnlyDictionary RegisterTypes() { Dictionary versionToType = new Dictionary(); versionToType[1] = typeof(Tool); BsonClassMap.RegisterClassMap(cm => { cm.AutoMap(); cm.MapCreator(t => new Tool(t.Id)); }); return versionToType; } /// /// Gets a tool with the given identifier /// /// The tool identifier /// The current global configuration /// public async Task GetAsync(ToolId id, GlobalConfig globalConfig) => await GetInternalAsync(id, globalConfig); /// /// Gets a tool with the given identifier /// /// The tool identifier /// The current global configuration /// async Task GetInternalAsync(ToolId toolId, GlobalConfig globalConfig) { ToolConfig? toolConfig; if (globalConfig.TryGetTool(toolId, out toolConfig)) { Tool tool = await _tools.FindOrAddAsync(toolId, () => new Tool(toolId)); tool.Config = toolConfig; tool.UpdateTemporalState(_clock.UtcNow); return tool; } BundledToolConfig? bundledToolConfig; if (globalConfig.ServerSettings.TryGetBundledTool(toolId, out bundledToolConfig)) { Tool tool = new Tool(bundledToolConfig); ToolDeployment deployment = new ToolDeployment(default); deployment.Version = bundledToolConfig.Version; deployment.State = ToolDeploymentState.Complete; deployment.RefName = bundledToolConfig.RefName; tool.Deployments.Add(deployment); return tool; } return null; } /// /// Adds a new deployment to the given tool. The new deployment will replace the current active deployment. /// /// The tool to update /// Options for the new deployment /// Stream containing the tool data /// The current configuration /// Cancellation token for the operation /// Updated tool document, or null if it does not exist public async Task CreateDeploymentAsync(ITool tool, ToolDeploymentConfig options, Stream stream, GlobalConfig globalConfig, CancellationToken cancellationToken) { ToolDeploymentId deploymentId = ToolDeploymentId.GenerateNewId(); RefName refName = new RefName($"{tool.Id}/{deploymentId}"); using IServerStorageClient client = _storageService.CreateClient(Namespace.Tools); NodeRef nodeRef; await using (IStorageWriter writer = client.CreateWriter(refName)) { DirectoryNode directoryNode = new DirectoryNode(); await directoryNode.CopyFromZipStreamAsync(stream, writer, new ChunkingOptions(), cancellationToken); nodeRef = await writer.WriteNodeAsync(directoryNode, cancellationToken); } BundleNodeHandle handle = (BundleNodeHandle)nodeRef.Handle; await client.WriteRefTargetAsync(refName, handle, cancellationToken: cancellationToken); return await CreateDeploymentAsync(tool, options, handle.GetLocator(), globalConfig, cancellationToken); } /// /// Adds a new deployment to the given tool. The new deployment will replace the current active deployment. /// /// The tool to update /// Options for the new deployment /// Locator for the tool data /// The current configuration /// Cancellation token for the operation /// Updated tool document, or null if it does not exist public async Task CreateDeploymentAsync(ITool tool, ToolDeploymentConfig options, BundleNodeLocator locator, GlobalConfig globalConfig, CancellationToken cancellationToken) { ToolDeploymentId deploymentId = ToolDeploymentId.GenerateNewId(); RefName refName = new RefName($"{tool.Id}/{deploymentId}"); using IServerStorageClient client = _storageService.CreateClient(Namespace.Tools); await client.WriteRefTargetAsync(refName, locator, cancellationToken: cancellationToken); return await CreateDeploymentInternalAsync(tool, deploymentId, options, refName, globalConfig, cancellationToken); } async Task CreateDeploymentInternalAsync(ITool tool, ToolDeploymentId deploymentId, ToolDeploymentConfig options, RefName refName, GlobalConfig globalConfig, CancellationToken cancellationToken) { if (tool.Config is BundledToolConfig) { throw new InvalidOperationException("Cannot update the state of bundled tools."); } // Create the new deployment object ToolDeployment deployment = new ToolDeployment(deploymentId, options, refName); // Start the deployment DateTime utcNow = _clock.UtcNow; if (!options.CreatePaused) { deployment.StartedAt = utcNow; } // Create the deployment Tool? newTool = (Tool)tool; for (; ; ) { newTool = await TryAddDeploymentAsync(newTool, deployment, cancellationToken); if (newTool != null) { break; } newTool = await GetInternalAsync(tool.Id, globalConfig); if (newTool == null) { return null; } } // Return the new tool with updated deployment states newTool.UpdateTemporalState(utcNow); return newTool; } async ValueTask TryAddDeploymentAsync(Tool tool, ToolDeployment deployment, CancellationToken cancellationToken) { Tool? newTool = tool; // If there are already a maximum number of deployments, remove the oldest one const int MaxDeploymentCount = 5; while (newTool.Deployments.Count >= MaxDeploymentCount) { newTool = await _tools.UpdateAsync(newTool, Builders.Update.PopFirst(x => x.Deployments)); if (newTool == null) { return null; } using IStorageClient client = _storageService.CreateClient(Namespace.Tools); await client.DeleteRefAsync(tool.Deployments[0].RefName, cancellationToken); } // Add the new deployment return await _tools.UpdateAsync(newTool, Builders.Update.Push(x => x.Deployments, deployment)); } /// /// Updates the state of the current deployment /// /// Tool to be updated /// Identifier for the deployment to modify /// New state of the deployment /// public async Task UpdateDeploymentAsync(ITool tool, ToolDeploymentId deploymentId, ToolDeploymentState action) { if (tool.Config is BundledToolConfig) { throw new InvalidOperationException("Cannot update the state of bundled tools."); } return await UpdateDeploymentInternalAsync((Tool)tool, deploymentId, action); } async Task UpdateDeploymentInternalAsync(Tool tool, ToolDeploymentId deploymentId, ToolDeploymentState action) { int idx = tool.Deployments.FindIndex(x => x.Id == deploymentId); if (idx == -1) { return null; } ToolDeployment deployment = tool.Deployments[idx]; switch (action) { case ToolDeploymentState.Complete: return await _tools.UpdateAsync(tool, Builders.Update.Set(x => x.Deployments[idx].BaseProgress, 1.0).Unset(x => x.Deployments[idx].StartedAt)); case ToolDeploymentState.Cancelled: List newDeployments = tool.Deployments.Where(x => x != deployment).ToList(); return await _tools.UpdateAsync(tool, Builders.Update.Set(x => x.Deployments, newDeployments)); case ToolDeploymentState.Paused: if (deployment.StartedAt == null) { return tool; } else { return await _tools.UpdateAsync(tool, Builders.Update.Set(x => x.Deployments[idx].BaseProgress, deployment.GetProgressValue(_clock.UtcNow)).Set(x => x.Deployments[idx].StartedAt, null)); } case ToolDeploymentState.Active: if (deployment.StartedAt != null) { return tool; } else { return await _tools.UpdateAsync(tool, Builders.Update.Set(x => x.Deployments[idx].StartedAt, _clock.UtcNow)); } default: throw new ArgumentException("Invalid action for deployment", nameof(action)); } } /// /// Gets the storage client containing data for a particular tool /// /// Identifier for the tool /// Storage client for the data public IBundleStorageClient CreateStorageClient(ITool tool) { if (tool.Config is BundledToolConfig bundledConfig) { return new FileStorageClient(DirectoryReference.Combine(ServerApp.AppDir, bundledConfig.DataDir ?? $"tools/{tool.Id}"), _cache, _logger); } else { return _storageService.CreateClient(Namespace.Tools); } } /// /// Opens a stream to the data for a particular deployment /// /// Identifier for the tool /// The deployment /// Cancellation token for the operation /// Stream for the data public async Task GetDeploymentZipAsync(ITool tool, IToolDeployment deployment, CancellationToken cancellationToken) { using IStorageClient client = CreateStorageClient(tool); DirectoryNode node = await client.ReadRefAsync(deployment.RefName, DateTime.UtcNow - TimeSpan.FromDays(2.0), cancellationToken); return node.AsZipStream(); } } }