// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using HordeServer.Utilities;
using Microsoft.Extensions.Logging;
namespace HordeServer.Server;
///
/// Indicates the version relationship between the application's defined schema and the database's applied schema.
/// Used to determine if migrations or code updates are required.
///
public enum SchemaVersionState
{
///
/// The application and database schemas are at the same version.
///
/// - No action required
/// - Application can operate normally
/// - Represents the ideal state
///
///
VersionMatch = 0,
///
/// The application schema version is newer than the database version.
///
/// - Database requires migration
/// - Application should not run or process requests
/// - Requires manual (or automated) migration intervention
///
///
DatabaseOutdated,
///
/// The database schema version is newer than the application version.
///
/// - Application requires deployment of a newer version of itself
/// - Can be a valid temporary state during rolling updates
/// - Safe to continue operation
///
///
ApplicationOutdated,
}
///
/// Results of comparing application and database schema versions
///
/// Current version state
/// Application schema version
/// Database schema version
/// List of migrations needed to reconcile versions
public record SchemaVersionResult(SchemaVersionState State, int AppVersion, int DatabaseVersion, List MissingMigrations);
///
/// A database migration with its metadata
///
public class MigrationDefinition(IMongoMigration migration, string plugin, int version, string name)
{
///
public IMongoMigration Migration { get; } = migration;
///
public string Plugin { get; } = plugin;
///
public int Version { get; } = version;
///
public string Name { get; } = name;
///
public override string ToString()
{
return $"Plugin: {Plugin}, Version: {Version}, Name: {Name}";
}
///
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
{
return false;
}
if (ReferenceEquals(this, obj))
{
return true;
}
return obj.GetType() == GetType() && Equals((MigrationDefinition)obj);
}
///
public override int GetHashCode()
{
return HashCode.Combine(Plugin, Version);
}
private bool Equals(MigrationDefinition other)
{
return Plugin == other.Plugin && Version == other.Version;
}
}
///
/// Exception thrown during migration operations
///
public class MigrationException : Exception
{
///
public MigrationException(string message) : base(message) {}
///
public MigrationException(string message, Exception innerException) : base(message, innerException) { }
}
///
/// Handles database schema migrations for MongoDB database
/// This class is not thread-safe
///
public class MongoMigrator
{
private readonly Dictionary> _migrations = [];
private readonly IMongoService _mongoService;
private readonly ILogger _logger;
[SingletonDocument("mongo-migrations")]
private class MigrationState : SingletonBase
{
public Dictionary Versions { get; set; } = new ();
}
///
/// Constructor
///
public MongoMigrator(IMongoService mongoService, ILogger logger)
{
_mongoService = mongoService;
_logger = logger;
}
///
/// Load all migrations
/// Will scan all loaded assemblies via reflection and add any class decorated with MongoMigrationAttribute
///
/// If migration is invalid or conflicts
public void AutoAddMigrations()
{
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
// Skip most assemblies as iterating types is slow
bool isHordePlugin = assembly.GetName().Name?.Contains("Horde", StringComparison.InvariantCultureIgnoreCase) ?? false;
if (isHordePlugin)
{
foreach (Type type in assembly.GetTypes())
{
MongoMigrationAttribute? mma = type.GetCustomAttribute();
if (mma is {AutoLoad: true} && Activator.CreateInstance(type) is IMongoMigration migration)
{
AddMigration(migration);
}
}
}
}
}
///
/// Adds a new migration to be managed by this migrator
///
/// Migration implementation to add
/// If migration is invalid or conflicts
public void AddMigration(IMongoMigration migration)
{
MongoMigrationAttribute? attribute = migration.GetType().GetCustomAttribute();
if (attribute == null)
{
throw new MigrationException($"Migration class {migration.GetType()} is missing [MongoMigration] attribute");
}
MigrationDefinition definition = new (migration, attribute.Plugin, attribute.Version, attribute.Name);
if (!_migrations.TryGetValue(definition.Plugin, out HashSet? pluginMigrations))
{
pluginMigrations = new HashSet();
_migrations[definition.Plugin] = pluginMigrations;
}
if (definition.Version is < 1 or > 50000)
{
throw new MigrationException($"Migration {definition.Plugin}/{definition.Name} has an invalid version number {definition.Version}");
}
if (definition.Name.Length is <= 3 or > 200)
{
throw new MigrationException($"Migration {definition.Plugin}/{definition.Name} has a too short/long name");
}
if (!pluginMigrations.Add(definition))
{
throw new MigrationException($"Migrations for plugin {definition.Plugin} already contains a migration with version {definition.Version}");
}
}
///
/// Validates database schema versions for all plugins against application versions
/// Logs error for outdated schemas with migration instructions.
///
/// Whether automatic upgrade of the schemas should be performed
/// Token to cancel the validation operation
/// True if all schemas are valid or newer than app, false if any schema is outdated
public async Task ValidateAllSchemasAsync(bool autoUpgrade, CancellationToken cancellationToken)
{
Dictionary schemaResults = await GetAllSchemaStateAsync(cancellationToken);
bool databaseOutdated = false;
foreach ((string plugin, SchemaVersionResult svr) in schemaResults)
{
string dbVersion = svr.DatabaseVersion > 0 ? Convert.ToString(svr.DatabaseVersion) : "(not applied)";
switch (svr.State)
{
case SchemaVersionState.VersionMatch: continue;
case SchemaVersionState.DatabaseOutdated:
_logger.Log(autoUpgrade ? LogLevel.Warning : LogLevel.Error, "Database schema for plugin {Plugin} is outdated. Schema versions: app = {AppVersion} database = {DatabaseVersion}", plugin, svr.AppVersion, dbVersion);
databaseOutdated = true;
break;
case SchemaVersionState.ApplicationOutdated:
_logger.LogWarning("Database schema for plugin {Plugin} newer than app. Schema versions: app = {AppVersion} database = {DatabaseVersion}", plugin, svr.AppVersion, dbVersion);
break;
default: throw new ArgumentOutOfRangeException($"Unknown schema state {svr}");
}
}
if (autoUpgrade)
{
_logger.LogInformation("Automatically upgrading database schemas...");
await UpgradeAsync(cancellationToken);
}
else if (databaseOutdated)
{
_logger.LogInformation("To apply migration scripts, run: dotnet HordeServer.dll mongo upgrade (or equivalent)");
_logger.LogInformation("For dev or test deployments, auto-apply of migrations can be enabled in server settings. This setting is *not* recommended for production use.");
}
return !databaseOutdated;
}
///
/// Retrieves the schema version states for all registered plugins
///
/// Cancellation token
/// Schema version comparison results per plugin
public async Task> GetAllSchemaStateAsync(CancellationToken cancellationToken)
{
Dictionary result = new ();
foreach (string plugin in _migrations.Keys)
{
SchemaVersionResult svr = await GetSchemaStateAsync(plugin, cancellationToken);
result[plugin] = svr;
}
return result;
}
///
/// Checks the current schema version state for a plugin
///
/// Plugin identifier
/// Cancellation token
/// Schema version comparison results
public async Task GetSchemaStateAsync(string plugin, CancellationToken cancellationToken)
{
IReadOnlyList migrations = GetMigrations(plugin);
int appVersion = migrations.Count == 0 ? -1 : migrations.Max(x => x.Version);
int dbVersion = await GetVersionAsync(plugin, cancellationToken);
if (appVersion > dbVersion)
{
List missingMigrations = migrations
.Where(x => x.Version > dbVersion && x.Version <= appVersion)
.OrderBy(x => x.Version)
.ToList();
return new SchemaVersionResult(SchemaVersionState.DatabaseOutdated, appVersion, dbVersion, missingMigrations);
}
if (appVersion < dbVersion)
{
List missingMigrations = [];
for (int v = appVersion + 1; v <= dbVersion; v++)
{
missingMigrations.Add(new MigrationDefinition(null!, plugin, v, $"Unknown migration {v}"));
}
return new SchemaVersionResult(SchemaVersionState.ApplicationOutdated, appVersion, dbVersion, missingMigrations);
}
return new SchemaVersionResult(SchemaVersionState.VersionMatch, appVersion, dbVersion, []);
}
///
/// Gets all migrations registered for a plugin
///
/// Plugin identifier
/// List of migrations ordered by version
/// If plugin is unknown
public IReadOnlyList GetMigrations(string plugin)
{
if (_migrations.TryGetValue(plugin, out HashSet? pluginMigrations))
{
return new List(pluginMigrations).OrderBy(x => x.Version).ToList();
}
throw new MigrationException($"Unknown plugin {plugin}");
}
///
/// Upgrades all database schemas to latest version
///
/// Cancellation token
public async Task UpgradeAsync(CancellationToken cancellationToken)
{
foreach (string plugin in _migrations.Keys)
{
await UpgradeAsync(plugin, toVersion: null, cancellationToken);
}
}
///
/// Upgrades database schema for a plugin to specified version or latest if not specified
///
/// Plugin identifier
/// Target version, or null for latest
/// Cancellation token
public async Task UpgradeAsync(string plugin, int? toVersion, CancellationToken cancellationToken)
{
IReadOnlyList migrations = GetMigrations(plugin);
ValidateMigrations(plugin, migrations);
int appliedVersion = await GetVersionAsync(plugin, cancellationToken);
MigrationContext context = new (_mongoService, _logger);
foreach (MigrationDefinition mi in migrations.Where(x => x.Version > appliedVersion && (!toVersion.HasValue || x.Version <= toVersion.Value)))
{
try
{
_logger.LogInformation("Upgrading {Plugin} to version {Version} using migration: {Name} ({ClassName})", mi.Plugin, mi.Version, mi.Name, mi.Migration.GetType().FullName);
await mi.Migration.UpAsync(context, cancellationToken);
await SetVersionAsync(plugin, mi.Version, cancellationToken);
}
catch (Exception e)
{
throw new MigrationException($"Failed to upgrade {mi.Plugin} to version {mi.Version} using migration: {mi.Name}", e);
}
}
}
///
/// Downgrades database schema for a plugin to specified version
///
/// Plugin identifier
/// Target version
/// Cancellation token
public async Task DowngradeAsync(string plugin, int toVersion, CancellationToken cancellationToken)
{
IReadOnlyList migrations = GetMigrations(plugin);
ValidateMigrations(plugin, migrations);
MigrationContext context = new (_mongoService, _logger);
IEnumerable migrationsToApply = migrations
.OrderByDescending(x => x.Version).Where(x => x.Version > toVersion);
foreach (MigrationDefinition mi in migrationsToApply)
{
try
{
_logger.LogInformation("Downgrading {Plugin} to version {Version} using migration: {Name}", mi.Plugin, mi.Version, mi.Name);
await mi.Migration.DownAsync(context, cancellationToken);
await SetVersionAsync(plugin, mi.Version, cancellationToken);
}
catch (Exception e)
{
throw new MigrationException($"Failed to downgrade {mi.Plugin} to version {mi.Version} using migration: {mi.Name}", e);
}
}
}
private static void ValidateMigrations(string plugin, IReadOnlyList migrations)
{
if (migrations.Count == 0)
{
throw new MigrationException($"No migrations found for {plugin}");
}
if (migrations[0].Version != 1)
{
throw new MigrationException($"Missing migration version 1 for {plugin}");
}
// Check for gaps in version numbers
for (int i = 0; i < migrations.Count - 1; i++)
{
int currentVersion = migrations[i].Version;
int nextVersion = migrations[i + 1].Version;
if (nextVersion != currentVersion + 1)
{
throw new MigrationException($"Gap detected in migration versions for {plugin}. Expected version {currentVersion + 1} but found version {nextVersion}");
}
}
}
private async Task SetVersionAsync(string plugin, int version, CancellationToken cancellationToken)
{
await _mongoService.UpdateSingletonAsync(state => state.Versions[plugin] = version, cancellationToken);
}
///
/// Get currently applied version of migrations for a plugin as stored in database
///
private async Task GetVersionAsync(string plugin, CancellationToken cancellationToken)
{
MigrationState state = await _mongoService.GetSingletonAsync(cancellationToken);
return state.Versions.TryGetValue(plugin, out int version) ? version : -1;
}
}