From b3d5fed35153892f3929638ef3820812affeb3fe Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 25 Sep 2026 16:20:06 +0800 Subject: [PATCH 1/2] Replicate more robust manifest loading from error instance --- .../PersistenceManifestLibraryTests.cs | 81 +++++++++++++++++++ .../PersistenceManifest.cs | 44 ++++++---- .../ServiceControl.Audit.Persistence.csproj | 4 + 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/ServiceControl.Audit.Persistence.Tests/PersistenceManifestLibraryTests.cs b/src/ServiceControl.Audit.Persistence.Tests/PersistenceManifestLibraryTests.cs index 4d64e08f66..f68bc70c67 100644 --- a/src/ServiceControl.Audit.Persistence.Tests/PersistenceManifestLibraryTests.cs +++ b/src/ServiceControl.Audit.Persistence.Tests/PersistenceManifestLibraryTests.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Audit.Persistence.Tests { + using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -70,4 +71,84 @@ public void All_types_defined_in_manifest_files_exist_in_specified_assembly() Assert.That(count, Is.Not.Zero, "No persistence manifests found."); } } + + [TestFixture] + public class PersistenceManifestLoadingTests + { + const string RavenDB = """ + { + "Name": "RavenDB", + "DisplayName": "RavenDB", + "Description": "RavenDB ServiceControl Audit persister", + "AssemblyName": "ServiceControl.Audit.Persistence.RavenDB", + "TypeName": "ServiceControl.Audit.Persistence.RavenDB.RavenPersistenceConfiguration, ServiceControl.Audit.Persistence.RavenDB" + } + """; + + // Ships so that ServiceControl Management can describe pre-v5 instances, and has no assembly left to name + const string RavenDB35 = """ + { + "Name": "RavenDB35", + "IsSupported": false, + "DisplayName": "RavenDB 3.5 (Legacy)", + "Description": "RavenDB 3.5 (Legacy) ServiceControl Audit persister" + } + """; + + const string InMemory = """ + { + "Name": "InMemory", + "DisplayName": "In-memory", + "Description": "InMemory ServiceControl Audit persister", + "AssemblyName": "ServiceControl.Audit.Persistence.InMemory", + "TypeName": "ServiceControl.Audit.Persistence.InMemory.InMemoryPersistenceConfiguration, ServiceControl.Audit.Persistence.InMemory" + } + """; + + [Test] + public void Legacy_manifest_without_an_assembly_does_not_hide_the_persisters_after_it() + { + var manifests = LoadFrom(new() + { + ["RavenDB"] = RavenDB, + ["RavenDB35"] = RavenDB35, + ["InMemory"] = InMemory + }); + + Assert.That(manifests.Select(m => m.Name), Is.EquivalentTo(["RavenDB", "RavenDB35", "InMemory"])); + } + + [Test] + public void Unreadable_manifest_does_not_hide_the_persisters_after_it() + { + var manifests = LoadFrom(new() + { + ["Corrupt"] = "{ this is not json", + ["InMemory"] = InMemory + }); + + Assert.That(manifests.Select(m => m.Name), Is.EqualTo(["InMemory"])); + } + + static List LoadFrom(Dictionary persisters) + { + var installDirectory = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID); + + foreach (var (persister, manifest) in persisters) + { + var persisterDirectory = Path.Combine(installDirectory, "Persisters", persister); + Directory.CreateDirectory(persisterDirectory); + File.WriteAllText(Path.Combine(persisterDirectory, "persistence.manifest"), manifest); + } + + try + { + return PersistenceManifestLibrary.LoadManifests(Directory.EnumerateFiles(installDirectory, "persistence.manifest", SearchOption.AllDirectories)); + } + finally + { + Directory.Delete(installDirectory, true); + } + } + } } \ No newline at end of file diff --git a/src/ServiceControl.Audit.Persistence/PersistenceManifest.cs b/src/ServiceControl.Audit.Persistence/PersistenceManifest.cs index 8ff8bcfb16..45b5d89572 100644 --- a/src/ServiceControl.Audit.Persistence/PersistenceManifest.cs +++ b/src/ServiceControl.Audit.Persistence/PersistenceManifest.cs @@ -42,13 +42,7 @@ static PersistenceManifestLibrary() try { - foreach (var manifestFile in Directory.EnumerateFiles(assemblyDirectory, "persistence.manifest", SearchOption.AllDirectories)) - { - var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestFile)); - manifest.Location = Path.GetDirectoryName(manifestFile); - - PersistenceManifests.Add(manifest); - } + PersistenceManifests.AddRange(LoadManifests(Directory.EnumerateFiles(assemblyDirectory, "persistence.manifest", SearchOption.AllDirectories))); } catch (Exception ex) { @@ -57,13 +51,7 @@ static PersistenceManifestLibrary() try { - foreach (var manifestFile in DevelopmentPersistenceLocations.ManifestFiles) - { - var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestFile)); - manifest.Location = Path.GetDirectoryName(manifestFile); - - PersistenceManifests.Add(manifest); - } + PersistenceManifests.AddRange(LoadManifests(DevelopmentPersistenceLocations.ManifestFiles)); } catch (Exception ex) { @@ -73,6 +61,34 @@ static PersistenceManifestLibrary() PersistenceManifests.ForEach(m => logger.LogInformation("Found persistence manifest for {ManifestDisplayName}", m.DisplayName)); } + // One unreadable manifest must not hide the persisters enumerated after it + internal static List LoadManifests(IEnumerable manifestFiles) + { + var manifests = new List(); + + foreach (var manifestFile in manifestFiles) + { + try + { + manifests.Add(DeserializeManifest(manifestFile)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load persistence manifest {ManifestFile}", manifestFile); + } + } + + return manifests; + } + + static PersistenceManifest DeserializeManifest(string manifestFile) + { + var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestFile)) + ?? throw new InvalidDataException($"The persistence manifest '{manifestFile}' is empty or invalid."); + manifest.Location = Path.GetDirectoryName(manifestFile); + return manifest; + } + static string GetAssemblyDirectory() { var assemblyLocation = typeof(PersistenceManifestLibrary).Assembly.Location; diff --git a/src/ServiceControl.Audit.Persistence/ServiceControl.Audit.Persistence.csproj b/src/ServiceControl.Audit.Persistence/ServiceControl.Audit.Persistence.csproj index ad7832ae08..9c01837062 100644 --- a/src/ServiceControl.Audit.Persistence/ServiceControl.Audit.Persistence.csproj +++ b/src/ServiceControl.Audit.Persistence/ServiceControl.Audit.Persistence.csproj @@ -14,4 +14,8 @@ + + + + \ No newline at end of file From 2293858a5b5d96f0e1225db276308d620877dcb7 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 25 Sep 2026 16:34:06 +0800 Subject: [PATCH 2/2] Add guard for starting maintenence mode on non-raven audit instance --- .../InMemoryPersistenceConfiguration.cs | 2 ++ .../RavenPersistenceConfiguration.cs | 2 ++ .../IPersistenceConfiguration.cs | 2 ++ .../Infrastructure/MaintenanceModeTests.cs | 27 +++++++++++++++++++ .../Commands/MaintenanceModeCommand.cs | 6 +++++ 5 files changed, 39 insertions(+) create mode 100644 src/ServiceControl.Audit.UnitTests/Infrastructure/MaintenanceModeTests.cs diff --git a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryPersistenceConfiguration.cs b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryPersistenceConfiguration.cs index ee4b78acff..016adef5ec 100644 --- a/src/ServiceControl.Audit.Persistence.InMemory/InMemoryPersistenceConfiguration.cs +++ b/src/ServiceControl.Audit.Persistence.InMemory/InMemoryPersistenceConfiguration.cs @@ -4,6 +4,8 @@ public class InMemoryPersistenceConfiguration : IPersistenceConfiguration { + public bool SupportsMaintenanceMode => false; + public string Name => "InMemory"; public IEnumerable ConfigurationKeys => new string[0]; diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs index 09a0850ad8..787a06d9c1 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs @@ -43,6 +43,8 @@ public class RavenPersistenceConfiguration : IPersistenceConfiguration QueryTimeoutInSecondsKey }; + public bool SupportsMaintenanceMode => true; + public string Name => "RavenDB"; public IPersistence Create(PersistenceSettings settings) diff --git a/src/ServiceControl.Audit.Persistence/IPersistenceConfiguration.cs b/src/ServiceControl.Audit.Persistence/IPersistenceConfiguration.cs index 360b26c94f..03dbab4443 100644 --- a/src/ServiceControl.Audit.Persistence/IPersistenceConfiguration.cs +++ b/src/ServiceControl.Audit.Persistence/IPersistenceConfiguration.cs @@ -4,6 +4,8 @@ public interface IPersistenceConfiguration { + bool SupportsMaintenanceMode { get; } + string Name { get; } IEnumerable ConfigurationKeys { get; } diff --git a/src/ServiceControl.Audit.UnitTests/Infrastructure/MaintenanceModeTests.cs b/src/ServiceControl.Audit.UnitTests/Infrastructure/MaintenanceModeTests.cs new file mode 100644 index 0000000000..e98b6e1543 --- /dev/null +++ b/src/ServiceControl.Audit.UnitTests/Infrastructure/MaintenanceModeTests.cs @@ -0,0 +1,27 @@ +namespace ServiceControl.Audit.UnitTests.Infrastructure +{ + using System; + using System.Runtime.Loader; + using System.Threading.Tasks; + using Audit.Infrastructure.Hosting; + using Audit.Infrastructure.Hosting.Commands; + using Audit.Infrastructure.Settings; + using NUnit.Framework; + + class MaintenanceModeTests + { + [Test] + public void Should_refuse_unsupported_persister_before_starting_host() + { + var settings = new Settings(persisterType: "InMemory") + { + AssemblyLoadContextResolver = static _ => AssemblyLoadContext.Default + }; + + var exception = Assert.ThrowsAsync(async () => + await new MaintenanceModeCommand().Execute(new HostArguments([]), settings)); + + Assert.That(exception.Message, Does.Contain("Maintenance mode is not supported").And.Contain("InMemory")); + } + } +} diff --git a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/MaintenanceModeCommand.cs b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/MaintenanceModeCommand.cs index 7dadf6a98e..a868a3bfbb 100644 --- a/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/MaintenanceModeCommand.cs +++ b/src/ServiceControl.Audit/Infrastructure/Hosting/Commands/MaintenanceModeCommand.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Audit.Infrastructure.Hosting.Commands { + using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; @@ -13,6 +14,11 @@ class MaintenanceModeCommand : AbstractCommand public override async Task Execute(HostArguments args, Settings settings, CancellationToken cancellationToken = default) { var persistenceConfiguration = PersistenceConfigurationFactory.LoadPersistenceConfiguration(settings); + if (!persistenceConfiguration.SupportsMaintenanceMode) + { + throw new InvalidOperationException($"Maintenance mode is not supported by the {settings.PersistenceType} persister."); + } + var persistenceSettings = persistenceConfiguration.BuildPersistenceSettings(settings); persistenceSettings.MaintenanceMode = true;