From 0248ddf6b7c07019548b8231a65caf010ded3087 Mon Sep 17 00:00:00 2001
From: Andreas Bednarz <110360248+abparticular@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:31:28 +1100
Subject: [PATCH 1/5] Create SQL Server and PostgreSQL instances in the
installer engine.
It now writes the EF Core persisters' settings:
- The database connection string.
- The Error instance's persister settings, taken from its persistence manifest as the Audit instance already did.
- The maintenance port and full text search settings, only when the persistence manifest declares them. The RavenDB manifests now declare EnableFullTextSearchOnBodies.
- The message body storage settings, validated per storage type. A file system body path is checked with the other instance paths and created with modify access for the service account.
The Error EF Core manifests default MessageBody/StorageType to FileSystem since installation is assumed to be on-prem.
---
.../Persisters/RavenDB35/persistence.manifest | 3 +
.../Persisters/RavenDB35/persistence.manifest | 3 +
.../persistence.manifest | 3 +
.../persistence.manifest | 1 +
.../persistence.manifest | 1 +
.../persistence.manifest | 3 +
...pected_config_file.PostgreSQL.approved.txt | 18 +++
...xpected_config_file.SQLServer.approved.txt | 18 +++
...pected_config_file.PostgreSQL.approved.txt | 19 +++
..._expected_config_file.RavenDB.approved.txt | 18 +++
...xpected_config_file.SQLServer.approved.txt | 19 +++
.../InstallationTests.cs | 106 ++++++++++++++++-
.../MessageBodyStorageValidationTests.cs | 109 ++++++++++++++++++
.../Validation/QueueValidationTests.cs | 8 ++
.../AuditInstanceSettingsList.cs | 4 +
.../ServiceControl/ServiceControlAppConfig.cs | 54 ++++++++-
.../ServiceControlAuditAppConfig.cs | 15 ++-
.../ServiceControl/SettingsList.cs | 4 +
.../Instances/MessageBodyStorage.cs | 74 ++++++++++++
.../Instances/ServiceControlAuditInstance.cs | 9 +-
.../ServiceControlAuditNewInstance.cs | 11 +-
.../Instances/ServiceControlBaseService.cs | 3 +
.../ServiceControlInstallableBase.cs | 44 +++++--
.../Instances/ServiceControlInstance.cs | 9 +-
.../Instances/ServiceControlNewInstance.cs | 10 +-
.../Interfaces.cs | 7 ++
.../Validation/MessageBodyStorageValidator.cs | 78 +++++++++++++
.../Validation/PathsValidator.cs | 12 +-
28 files changed, 640 insertions(+), 23 deletions(-)
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.PostgreSQL.approved.txt
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.SQLServer.approved.txt
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.PostgreSQL.approved.txt
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.RavenDB.approved.txt
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.SQLServer.approved.txt
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/Validation/MessageBodyStorageValidationTests.cs
create mode 100644 src/ServiceControlInstaller.Engine/Instances/MessageBodyStorage.cs
create mode 100644 src/ServiceControlInstaller.Engine/Validation/MessageBodyStorageValidator.cs
diff --git a/src/LegacyArtifacts/Particular.ServiceControl.Audit/Persisters/RavenDB35/persistence.manifest b/src/LegacyArtifacts/Particular.ServiceControl.Audit/Persisters/RavenDB35/persistence.manifest
index 9e4acddc85..af39167cb9 100644
--- a/src/LegacyArtifacts/Particular.ServiceControl.Audit/Persisters/RavenDB35/persistence.manifest
+++ b/src/LegacyArtifacts/Particular.ServiceControl.Audit/Persisters/RavenDB35/persistence.manifest
@@ -24,6 +24,9 @@
{
"Name": "ServiceControl.Audit/DatabaseMaintenancePort",
"Mandatory": true
+ },
+ {
+ "Name": "ServiceControl.Audit/EnableFullTextSearchOnBodies"
}
],
"SettingsWithPathsToCleanup": [
diff --git a/src/LegacyArtifacts/Particular.ServiceControl/Persisters/RavenDB35/persistence.manifest b/src/LegacyArtifacts/Particular.ServiceControl/Persisters/RavenDB35/persistence.manifest
index 916073dabb..027f73a019 100644
--- a/src/LegacyArtifacts/Particular.ServiceControl/Persisters/RavenDB35/persistence.manifest
+++ b/src/LegacyArtifacts/Particular.ServiceControl/Persisters/RavenDB35/persistence.manifest
@@ -24,6 +24,9 @@
{
"Name": "ServiceControl/DatabaseMaintenancePort",
"Mandatory": true
+ },
+ {
+ "Name": "ServiceControl/EnableFullTextSearchOnBodies"
}
],
"SettingsWithPathsToCleanup": [
diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/persistence.manifest b/src/ServiceControl.Audit.Persistence.RavenDB/persistence.manifest
index 75c8e5c96e..2b7383a4ad 100644
--- a/src/ServiceControl.Audit.Persistence.RavenDB/persistence.manifest
+++ b/src/ServiceControl.Audit.Persistence.RavenDB/persistence.manifest
@@ -16,6 +16,9 @@
{
"Name": "ServiceControl.Audit/DatabaseMaintenancePort",
"Mandatory": true
+ },
+ {
+ "Name": "ServiceControl.Audit/EnableFullTextSearchOnBodies"
}
],
"SettingsWithPathsToCleanup": [
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest
index f021224da5..7a1a5deda1 100644
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest
@@ -19,6 +19,7 @@
},
{
"Name": "ServiceControl/MessageBody/StorageType",
+ "DefaultValue": "FileSystem",
"Mandatory": true
},
{
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest b/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest
index faa1f24c3e..0730992832 100644
--- a/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest
@@ -19,6 +19,7 @@
},
{
"Name": "ServiceControl/MessageBody/StorageType",
+ "DefaultValue": "FileSystem",
"Mandatory": true
},
{
diff --git a/src/ServiceControl.Persistence.RavenDB/persistence.manifest b/src/ServiceControl.Persistence.RavenDB/persistence.manifest
index cc351d645c..9057d349cb 100644
--- a/src/ServiceControl.Persistence.RavenDB/persistence.manifest
+++ b/src/ServiceControl.Persistence.RavenDB/persistence.manifest
@@ -16,6 +16,9 @@
{
"Name": "ServiceControl/DatabaseMaintenancePort",
"Mandatory": true
+ },
+ {
+ "Name": "ServiceControl/EnableFullTextSearchOnBodies"
}
],
"SettingsWithPathsToCleanup": [
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.PostgreSQL.approved.txt b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.PostgreSQL.approved.txt
new file mode 100644
index 0000000000..279819d735
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.PostgreSQL.approved.txt
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.SQLServer.approved.txt b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.SQLServer.approved.txt
new file mode 100644
index 0000000000..067fd524e4
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.Audit_install_should_write_expected_config_file.SQLServer.approved.txt
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.PostgreSQL.approved.txt b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.PostgreSQL.approved.txt
new file mode 100644
index 0000000000..a949e28ed1
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.PostgreSQL.approved.txt
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.RavenDB.approved.txt b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.RavenDB.approved.txt
new file mode 100644
index 0000000000..0534cc2169
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.RavenDB.approved.txt
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.SQLServer.approved.txt b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.SQLServer.approved.txt
new file mode 100644
index 0000000000..f13dc8a779
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/ApprovalFiles/InstallationTests.ServiceControl_install_should_write_expected_config_file.SQLServer.approved.txt
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/InstallationTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/InstallationTests.cs
index b6516ac3a2..85ca9ac07b 100644
--- a/src/ServiceControlInstaller.Engine.UnitTests/InstallationTests.cs
+++ b/src/ServiceControlInstaller.Engine.UnitTests/InstallationTests.cs
@@ -19,6 +19,7 @@ public void Audit_install_should_write_expected_config_file(string manifestPath)
var installPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "install");
var dbPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "db");
var logPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "log");
+ var messageBodyPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "bodies");
try
{
@@ -55,13 +56,23 @@ public void Audit_install_should_write_expected_config_file(string manifestPath)
newInstance.LogPath = logPath;
newInstance.HostName = "localhost";
newInstance.DatabaseMaintenancePort = 33333;
+ newInstance.PersistenceConnectionString = "placeholder-connection-string";
+
+ if (persistenceManifest.Settings.Any(setting => setting.Name == AuditInstanceSettingsList.MessageBodyStorageType.Name))
+ {
+ newInstance.MessageBodyStorage = new MessageBodyStorage
+ {
+ StorageType = MessageBodyStorageType.FileSystem,
+ FileSystemStoragePath = messageBodyPath
+ };
+ }
newInstance.WriteConfigurationFile();
var configFile = File.ReadAllText(Path.Combine(installPath, "ServiceControl.Audit.exe.config"));
var scenario = Path.GetFileName(Path.GetDirectoryName(manifestPath));
- Approver.Verify(configFile, input => input.Replace(dbPath, "value-not-asserted").Replace(logPath, "value-not-asserted"), scenario);
+ Approver.Verify(configFile, input => input.Replace(dbPath, "value-not-asserted").Replace(logPath, "value-not-asserted").Replace(messageBodyPath, "value-not-asserted"), scenario);
}
finally
{
@@ -75,7 +86,9 @@ public void Audit_install_should_write_expected_config_file(string manifestPath)
[
AuditInstanceSettingsList.DBPath.Name,
AuditInstanceSettingsList.DatabaseMaintenancePort.Name,
- AuditInstanceSettingsList.LogPath.Name
+ AuditInstanceSettingsList.LogPath.Name,
+ AuditInstanceSettingsList.DatabaseConnectionString.Name,
+ AuditInstanceSettingsList.MessageBodyStorageType.Name
];
public static IEnumerable GetAuditPersistenceManifestPaths()
@@ -85,4 +98,93 @@ public static IEnumerable GetAuditPersistenceManifestPaths()
return Directory.GetFiles(persistersPath, "persistence.manifest", SearchOption.AllDirectories);
}
}
+
+ class ServiceControlInstallationTests
+ {
+ const string ConnectionStringPlaceholder = "placeholder-connection-string";
+
+ [Test]
+ [TestCaseSource(nameof(GetPrimaryPersistenceManifestPaths))]
+ public void ServiceControl_install_should_write_expected_config_file(string manifestPath)
+ {
+ var installPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "install");
+ var dbPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "db");
+ var logPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "log");
+ var messageBodyPath = Path.Combine(Path.GetTempPath(), TestContext.CurrentContext.Test.ID, "bodies");
+
+ try
+ {
+ PersistenceManifest persistenceManifest;
+
+ using (var reader = new StreamReader(manifestPath))
+ {
+ var manifestContent = reader.ReadToEnd();
+ persistenceManifest = JsonSerializer.Deserialize(manifestContent);
+ }
+
+ if (!persistenceManifest.IsSupported)
+ {
+ Assert.Ignore("Don't care about config for unsupported persistence types.");
+ }
+
+ if (persistenceManifest.Settings.Any(setting => setting.Mandatory && setting.DefaultValue == null && !InstallerSuppliedSettings.Contains(setting.Name)))
+ {
+ Assert.Ignore("The installer cannot create an instance on this persister yet: it has no value for one of its mandatory settings.");
+ }
+
+ var newInstance = new ServiceControlNewInstance(persistenceManifest);
+
+ if (Directory.Exists(installPath))
+ {
+ Directory.Delete(installPath, true);
+ }
+
+ newInstance.InstallPath = installPath;
+ newInstance.TransportPackage = ServiceControlCoreTransports.Find("LearningTransport");
+
+ newInstance.DBPath = dbPath;
+ newInstance.LogPath = logPath;
+ newInstance.HostName = "localhost";
+ newInstance.DatabaseMaintenancePort = 33333;
+ newInstance.PersistenceConnectionString = ConnectionStringPlaceholder;
+
+ if (persistenceManifest.Settings.Any(setting => setting.Name == ServiceControlSettings.MessageBodyStorageType.Name))
+ {
+ newInstance.MessageBodyStorage = new MessageBodyStorage
+ {
+ StorageType = MessageBodyStorageType.FileSystem,
+ FileSystemStoragePath = messageBodyPath
+ };
+ }
+
+ newInstance.WriteConfigurationFile();
+
+ var configFile = File.ReadAllText(Path.Combine(installPath, "ServiceControl.exe.config"));
+ var scenario = Path.GetFileName(Path.GetDirectoryName(manifestPath));
+
+ Approver.Verify(configFile, input => input.Replace(dbPath, "value-not-asserted").Replace(logPath, "value-not-asserted").Replace(messageBodyPath, "value-not-asserted"), scenario);
+ }
+ finally
+ {
+ FileUtils.DeleteDirectory(installPath, true, false);
+ FileUtils.DeleteDirectory(dbPath, true, false);
+ FileUtils.DeleteDirectory(logPath, true, false);
+ }
+ }
+
+ static readonly string[] InstallerSuppliedSettings =
+ [
+ ServiceControlSettings.DBPath.Name,
+ ServiceControlSettings.DatabaseMaintenancePort.Name,
+ ServiceControlSettings.HostName.Name,
+ ServiceControlSettings.DatabaseConnectionString.Name
+ ];
+
+ public static IEnumerable GetPrimaryPersistenceManifestPaths()
+ {
+ var deployPath = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "..", "deploy"));
+ var persistersPath = Path.Combine(deployPath, "Particular.ServiceControl", "Persisters");
+ return Directory.GetFiles(persistersPath, "persistence.manifest", SearchOption.AllDirectories);
+ }
+ }
}
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Validation/MessageBodyStorageValidationTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Validation/MessageBodyStorageValidationTests.cs
new file mode 100644
index 0000000000..d16bbd82f0
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/Validation/MessageBodyStorageValidationTests.cs
@@ -0,0 +1,109 @@
+namespace ServiceControlInstaller.Engine.UnitTests.Validation
+{
+ using Engine.Validation;
+ using Instances;
+ using NUnit.Framework;
+
+ [TestFixture]
+ public class MessageBodyStorageValidationTests
+ {
+ static PersistenceManifest SqlServer => ServiceControlPersisters.GetPrimaryPersistence("SQLServer");
+ static PersistenceManifest RavenDB => ServiceControlPersisters.GetPrimaryPersistence("RavenDB");
+
+ [Test]
+ public void Should_require_body_storage_for_SQL_Server_audit() =>
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(ServiceControlPersisters.GetAuditPersistence("SQLServer"), null));
+
+ [Test]
+ public void Should_accept_body_storage_for_SQL_Server_audit()
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.S3, S3BucketName = "audit-bodies" };
+
+ Assert.DoesNotThrow(() => MessageBodyStorageValidator.Validate(ServiceControlPersisters.GetAuditPersistence("SQLServer"), storage));
+ }
+
+ [Test]
+ public void Should_reject_body_storage_for_RavenDB_audit()
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.FileSystem, FileSystemStoragePath = @"C:\bodies" };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(ServiceControlPersisters.GetAuditPersistence("RavenDB"), storage));
+ }
+
+ [Test]
+ public void Should_reject_body_storage_for_RavenDB()
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.FileSystem, FileSystemStoragePath = @"C:\bodies" };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(RavenDB, storage));
+ }
+
+ [Test]
+ public void Should_allow_no_body_storage_for_RavenDB() =>
+ Assert.DoesNotThrow(() => MessageBodyStorageValidator.Validate(RavenDB, null));
+
+ [Test]
+ public void Should_require_body_storage_for_SQL_Server() =>
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(SqlServer, null));
+
+ [Test]
+ public void Should_require_path_for_file_system()
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.FileSystem };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+
+ [TestCase(null, null)]
+ [TestCase("UseDevelopmentStorage=true", "https://account.blob.core.windows.net")]
+ public void Should_require_exactly_one_Azure_authentication_method(string connectionString, string serviceUri)
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.AzureBlob, AzureConnectionString = connectionString, AzureServiceUri = serviceUri };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+
+ [Test]
+ public void Should_reject_relative_Azure_service_uri()
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.AzureBlob, AzureServiceUri = "account.blob.core.windows.net" };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+
+ [TestCase("UseDevelopmentStorage=true", null)]
+ [TestCase(null, "https://account.blob.core.windows.net")]
+ public void Should_accept_one_Azure_authentication_method(string connectionString, string serviceUri)
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.AzureBlob, AzureConnectionString = connectionString, AzureServiceUri = serviceUri };
+
+ Assert.DoesNotThrow(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+
+ [Test]
+ public void Should_require_S3_bucket()
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.S3 };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+
+ [TestCase("key", null)]
+ [TestCase(null, "secret")]
+ public void Should_require_both_S3_keys_or_neither(string accessKeyId, string secretAccessKey)
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.S3, S3BucketName = "bodies", S3AccessKeyId = accessKeyId, S3SecretAccessKey = secretAccessKey };
+
+ Assert.Throws(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+
+ [TestCase(null, null)]
+ [TestCase("key", "secret")]
+ public void Should_accept_S3_with_both_keys_or_neither(string accessKeyId, string secretAccessKey)
+ {
+ var storage = new MessageBodyStorage { StorageType = MessageBodyStorageType.S3, S3BucketName = "bodies", S3AccessKeyId = accessKeyId, S3SecretAccessKey = secretAccessKey };
+
+ Assert.DoesNotThrow(() => MessageBodyStorageValidator.Validate(SqlServer, storage));
+ }
+ }
+}
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Validation/QueueValidationTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Validation/QueueValidationTests.cs
index aaf6fdf138..08bed62664 100644
--- a/src/ServiceControlInstaller.Engine.UnitTests/Validation/QueueValidationTests.cs
+++ b/src/ServiceControlInstaller.Engine.UnitTests/Validation/QueueValidationTests.cs
@@ -29,6 +29,8 @@ class FakeServiceControlInstance : IServiceControlInstance
public List RemoteInstances { get; set; }
+ public MessageBodyStorage MessageBodyStorage { get; set; }
+
public bool EnableFullTextSearchOnBodies { get; set; }
public int Port { get; set; }
@@ -61,6 +63,8 @@ class FakeServiceControlInstance : IServiceControlInstance
public PersistenceManifest PersistenceManifest { get; set; }
+ public string PersistenceConnectionString { get; set; }
+
public string ConnectionString { get; set; }
public string Url { get; set; }
@@ -82,8 +86,12 @@ class FakeServiceControlAuditInstance : IServiceControlAuditInstance
public string ServiceControlQueueAddress { get; set; }
+ public MessageBodyStorage MessageBodyStorage { get; set; }
+
public PersistenceManifest PersistenceManifest { get; set; }
+ public string PersistenceConnectionString { get; set; }
+
public bool EnableFullTextSearchOnBodies { get; set; }
public int Port { get; set; }
diff --git a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/AuditInstanceSettingsList.cs b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/AuditInstanceSettingsList.cs
index 64239d65c3..16c441d8e7 100644
--- a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/AuditInstanceSettingsList.cs
+++ b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/AuditInstanceSettingsList.cs
@@ -21,6 +21,10 @@ public static class AuditInstanceSettingsList
public static readonly SettingInfo HostName = new() { Name = "ServiceControl.Audit/HostName" };
public static readonly SettingInfo LogPath = new() { Name = "ServiceControl.Audit/LogPath" };
public static readonly SettingInfo DBPath = new() { Name = "ServiceControl.Audit/DBPath" };
+ public static readonly SettingInfo DatabaseConnectionString = new() { Name = "ServiceControl.Audit/Database/ConnectionString" };
+ public static readonly SettingInfo RavenDBConnectionString = new() { Name = "ServiceControl.Audit/RavenDB/ConnectionString" };
+ public static readonly SettingInfo MessageBodyStorageType = new() { Name = "ServiceControl.Audit/MessageBody/StorageType" };
+ public static readonly SettingInfo MessageBodyFileSystemStoragePath = new() { Name = "ServiceControl.Audit/MessageBody/FileSystem/StoragePath" };
public static readonly SettingInfo ForwardAuditMessages = new() { Name = "ServiceControl.Audit/ForwardAuditMessages" };
public static readonly SettingInfo TransportType = new() { Name = "ServiceControl.Audit/TransportType" };
diff --git a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAppConfig.cs b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAppConfig.cs
index 757a006b9e..c20976adc3 100644
--- a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAppConfig.cs
+++ b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAppConfig.cs
@@ -4,6 +4,7 @@
using System.Configuration;
using System.Data.Common;
using System.IO;
+ using System.Linq;
using Instances;
using NuGet.Versioning;
@@ -25,10 +26,8 @@ protected override void UpdateSettings()
settings.Set(ServiceControlSettings.InstanceName, details.InstanceName, version);
settings.Set(ServiceControlSettings.VirtualDirectory, details.VirtualDirectory);
settings.Set(ServiceControlSettings.Port, details.Port.ToString());
- settings.Set(ServiceControlSettings.DatabaseMaintenancePort, details.DatabaseMaintenancePort.ToString(), version);
settings.Set(ServiceControlSettings.HostName, details.HostName);
settings.Set(ServiceControlSettings.LogPath, details.LogPath);
- settings.Set(ServiceControlSettings.DBPath, details.DBPath);
settings.Set(ServiceControlSettings.ForwardErrorMessages, details.ForwardErrorMessages.ToString(), version);
settings.Set(ServiceControlSettings.TransportType, details.TransportPackage.Name, version);
settings.Set(ServiceControlSettings.StorageType, details.PersistenceManifest.Name); // TODO: Why is it set here AND at ServiceControlInstance.ApplySettingsChanges 🤬
@@ -36,10 +35,59 @@ protected override void UpdateSettings()
settings.Set(ServiceControlSettings.ErrorLogQueue, details.ForwardErrorMessages ? details.ErrorLogQueue : null);
settings.Set(ServiceControlSettings.AuditRetentionPeriod, details.AuditRetentionPeriod.ToString(), version);
settings.Set(ServiceControlSettings.ErrorRetentionPeriod, details.ErrorRetentionPeriod.ToString(), version);
- settings.Set(ServiceControlSettings.EnableFullTextSearchOnBodies, details.EnableFullTextSearchOnBodies.ToString(), version);
+ settings.Set(ServiceControlSettings.EnableFullTextSearchOnBodies, details.PersistenceManifest.Settings.Any(s => s.Name == ServiceControlSettings.EnableFullTextSearchOnBodies.Name) ? details.EnableFullTextSearchOnBodies.ToString() : null, version);
settings.Set(ServiceControlSettings.RemoteInstances, RemoteInstanceConverter.ToJson(details.RemoteInstances), version);
settings.Set(ServiceControlSettings.EnableIntegratedServicePulse, details.EnableIntegratedServicePulse.ToString(), version);
+ if (details.MessageBodyStorage != null)
+ {
+ foreach (var (setting, value) in details.MessageBodyStorage.GetSettings("ServiceControl"))
+ {
+ settings.Set(setting, value);
+ }
+ }
+
+ foreach (var manifestSetting in details.PersistenceManifest.Settings)
+ {
+ if (!settings.AllKeys.Contains(manifestSetting.Name))
+ {
+ var value = manifestSetting.DefaultValue;
+
+ if (manifestSetting.Name == ServiceControlSettings.DBPath.Name)
+ {
+ value = details.DBPath;
+ }
+
+ if (manifestSetting.Name == ServiceControlSettings.DatabaseMaintenancePort.Name)
+ {
+ value = details.DatabaseMaintenancePort.ToString();
+ }
+
+ if (manifestSetting.Name == ServiceControlSettings.DatabaseConnectionString.Name)
+ {
+ value = details.PersistenceConnectionString;
+ }
+
+ if (!string.IsNullOrEmpty(value))
+ {
+ settings.Add(manifestSetting.Name, value);
+ }
+ }
+
+ if (manifestSetting.Mandatory)
+ {
+ if (!settings.AllKeys.Contains(manifestSetting.Name))
+ {
+ throw new Exception($"Mandatory setting {manifestSetting.Name} was not configured");
+ }
+
+ if (string.IsNullOrEmpty(settings[manifestSetting.Name].Value))
+ {
+ throw new Exception($"Mandatory setting {manifestSetting.Name} can not be null or empty");
+ }
+ }
+ }
+
// Windows services allow a maximum of 125 seconds when stopping a service.
// When shutting down or restarting the OS we have no control over the
// shutdown timeout. This is by the installer engine that is run _only_ on
diff --git a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAuditAppConfig.cs b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAuditAppConfig.cs
index 926bb03313..bbbe533e32 100644
--- a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAuditAppConfig.cs
+++ b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/ServiceControlAuditAppConfig.cs
@@ -28,7 +28,7 @@ protected override void UpdateSettings()
settings.Set(AuditInstanceSettingsList.AuditLogQueue, instance.ForwardAuditMessages ? instance.AuditLogQueue : null);
settings.Set(AuditInstanceSettingsList.AuditRetentionPeriod, instance.AuditRetentionPeriod.ToString(), version);
settings.Set(AuditInstanceSettingsList.ServiceControlQueueAddress, instance.ServiceControlQueueAddress);
- settings.Set(AuditInstanceSettingsList.EnableFullTextSearchOnBodies, instance.EnableFullTextSearchOnBodies.ToString().ToLowerInvariant(), version);
+ settings.Set(AuditInstanceSettingsList.EnableFullTextSearchOnBodies, instance.PersistenceManifest.Settings.Any(s => s.Name == AuditInstanceSettingsList.EnableFullTextSearchOnBodies.Name) ? instance.EnableFullTextSearchOnBodies.ToString().ToLowerInvariant() : null, version);
// Windows services allow a maximum of 125 seconds when stopping a service.
// When shutting down or restarting the OS we have no control over the
@@ -36,6 +36,14 @@ protected override void UpdateSettings()
// Windows via SCMU or PowerShell
settings.Set(AuditInstanceSettingsList.ShutdownTimeout, "00:02:00", version);
+ if (instance.MessageBodyStorage != null)
+ {
+ foreach (var (setting, value) in instance.MessageBodyStorage.GetSettings("ServiceControl.Audit"))
+ {
+ settings.Set(setting, value);
+ }
+ }
+
foreach (var manifestSetting in instance.PersistenceManifest.Settings)
{
if (!settings.AllKeys.Contains(manifestSetting.Name))
@@ -52,6 +60,11 @@ protected override void UpdateSettings()
value = instance.DatabaseMaintenancePort.ToString();
}
+ if (manifestSetting.Name == AuditInstanceSettingsList.DatabaseConnectionString.Name)
+ {
+ value = instance.PersistenceConnectionString;
+ }
+
if (!string.IsNullOrEmpty(value))
{
settings.Add(manifestSetting.Name, value);
diff --git a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/SettingsList.cs b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/SettingsList.cs
index d3394a281f..c487c43a8c 100644
--- a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/SettingsList.cs
+++ b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/SettingsList.cs
@@ -27,6 +27,10 @@ public static class ServiceControlSettings
public static readonly SettingInfo HostName = new() { Name = "ServiceControl/HostName" };
public static readonly SettingInfo LogPath = new() { Name = "ServiceControl/LogPath" };
public static readonly SettingInfo DBPath = new() { Name = "ServiceControl/DBPath" };
+ public static readonly SettingInfo DatabaseConnectionString = new() { Name = "ServiceControl/Database/ConnectionString" };
+ public static readonly SettingInfo RavenDBConnectionString = new() { Name = "ServiceControl/RavenDB/ConnectionString" };
+ public static readonly SettingInfo MessageBodyStorageType = new() { Name = "ServiceControl/MessageBody/StorageType" };
+ public static readonly SettingInfo MessageBodyFileSystemStoragePath = new() { Name = "ServiceControl/MessageBody/FileSystem/StoragePath" };
public static readonly SettingInfo ForwardAuditMessages = new()
{
Name = "ServiceControl/ForwardAuditMessages",
diff --git a/src/ServiceControlInstaller.Engine/Instances/MessageBodyStorage.cs b/src/ServiceControlInstaller.Engine/Instances/MessageBodyStorage.cs
new file mode 100644
index 0000000000..5e867e54fc
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine/Instances/MessageBodyStorage.cs
@@ -0,0 +1,74 @@
+namespace ServiceControlInstaller.Engine.Instances
+{
+ using System.Collections.Generic;
+ using Configuration;
+
+ // Names match ServiceControl's BodyStorageType so they are written to the config file as-is.
+ public enum MessageBodyStorageType
+ {
+ FileSystem,
+ AzureBlob,
+ S3
+ }
+
+ public class MessageBodyStorage
+ {
+ public MessageBodyStorageType StorageType { get; set; }
+
+ public string FileSystemStoragePath { get; set; }
+
+ public string AzureConnectionString { get; set; }
+
+ public string AzureServiceUri { get; set; }
+
+ public string AzureManagedIdentityClientId { get; set; }
+
+ public string AzureAuthorityHost { get; set; }
+
+ public string AzureContainerName { get; set; }
+
+ public string S3BucketName { get; set; }
+
+ public string S3KeyPrefix { get; set; }
+
+ public string S3Region { get; set; }
+
+ public string S3ServiceUrl { get; set; }
+
+ public string S3AccessKeyId { get; set; }
+
+ public string S3SecretAccessKey { get; set; }
+
+ // settingsRoot is the instance's setting prefix, such as ServiceControl or ServiceControl.Audit.
+ internal IEnumerable<(SettingInfo Setting, string Value)> GetSettings(string settingsRoot)
+ {
+ (SettingInfo, string) Setting(string name, string value) => (new SettingInfo { Name = $"{settingsRoot}/MessageBody/{name}" }, value);
+
+ yield return Setting("StorageType", StorageType.ToString());
+
+ switch (StorageType)
+ {
+ case MessageBodyStorageType.FileSystem:
+ yield return Setting("FileSystem/StoragePath", FileSystemStoragePath);
+ break;
+ case MessageBodyStorageType.AzureBlob:
+ yield return Setting("Azure/ConnectionString", AzureConnectionString);
+ yield return Setting("Azure/ServiceUri", AzureServiceUri);
+ yield return Setting("Azure/ManagedIdentityClientId", AzureManagedIdentityClientId);
+ yield return Setting("Azure/AuthorityHost", AzureAuthorityHost);
+ yield return Setting("Azure/ContainerName", AzureContainerName);
+ break;
+ case MessageBodyStorageType.S3:
+ yield return Setting("S3/BucketName", S3BucketName);
+ yield return Setting("S3/KeyPrefix", S3KeyPrefix);
+ yield return Setting("S3/Region", S3Region);
+ yield return Setting("S3/ServiceUrl", S3ServiceUrl);
+ yield return Setting("S3/AccessKeyId", S3AccessKeyId);
+ yield return Setting("S3/SecretAccessKey", S3SecretAccessKey);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+}
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs
index f1eda8ea34..c760c98d6d 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs
@@ -46,7 +46,7 @@ public ServiceControlAuditInstance(IWindowsServiceController service) : base(ser
public string ServiceControlQueueAddress { get; set; }
- public PersistenceManifest PersistenceManifest { get; set; }
+ public string PersistenceConnectionString { get; set; }
protected override void ApplySettingsChanges(KeyValueConfigurationCollection settings)
{
@@ -68,7 +68,12 @@ protected override void ApplySettingsChanges(KeyValueConfigurationCollection set
settings.Set(AuditInstanceSettingsList.AuditRetentionPeriod, AuditRetentionPeriod.ToString(), Version);
settings.Set(AuditInstanceSettingsList.AuditQueue, AuditQueue, Version);
settings.Set(AuditInstanceSettingsList.AuditLogQueue, AuditLogQueue, Version);
- settings.Set(AuditInstanceSettingsList.EnableFullTextSearchOnBodies, EnableFullTextSearchOnBodies.ToString().ToLowerInvariant(), Version);
+
+ var enableFullTextSearchOnBodiesValue = PersistenceManifest.Settings.Any(s => s.Name == AuditInstanceSettingsList.EnableFullTextSearchOnBodies.Name)
+ ? EnableFullTextSearchOnBodies.ToString().ToLowerInvariant()
+ : null;
+
+ settings.Set(AuditInstanceSettingsList.EnableFullTextSearchOnBodies, enableFullTextSearchOnBodiesValue, Version);
settings.Set(AuditInstanceSettingsList.StorageType, PersistenceManifest.Name);
// Retired settings
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditNewInstance.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditNewInstance.cs
index ff192d4cca..bfea58eb4d 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditNewInstance.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditNewInstance.cs
@@ -2,6 +2,7 @@
{
using System;
using System.IO;
+ using System.Linq;
using System.Xml.Serialization;
using Configuration.ServiceControl;
using Setup;
@@ -64,7 +65,10 @@ protected override void RunSetup()
protected override void ValidateMaintenancePort()
{
- DatabaseMaintenancePortValidator.Validate(this);
+ if (PersistenceManifest.Settings.Any(s => s.Name == AuditInstanceSettingsList.DatabaseMaintenancePort.Name))
+ {
+ DatabaseMaintenancePortValidator.Validate(this);
+ }
}
protected override void ValidateQueueNames()
@@ -81,5 +85,10 @@ protected override void ValidateConnectionString()
{
ConnectionStringValidator.Validate(this);
}
+
+ protected override void ValidatePersistence()
+ {
+ MessageBodyStorageValidator.Validate(PersistenceManifest, MessageBodyStorage);
+ }
}
}
\ No newline at end of file
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs
index e2983cb02e..aabcf2bcd0 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs
@@ -40,6 +40,9 @@ protected ServiceControlBaseService(IWindowsServiceController service)
public bool InMaintenanceMode { get; set; }
public ReportCard ReportCard { get; set; }
+ public PersistenceManifest PersistenceManifest { get; set; }
+
+ public MessageBodyStorage MessageBodyStorage => null;
///
/// Raven management URL
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstallableBase.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstallableBase.cs
index d66df319ee..2a64351689 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstallableBase.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstallableBase.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+ using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading;
@@ -52,20 +53,26 @@ public string AclMaintenanceUrl
}
}
- string[] FlagFiles
+ IEnumerable FlagFiles
{
get
{
const string flagFileName = ".notconfigured";
- return
- [
- Path.Combine(InstallPath, flagFileName),
- Path.Combine(DBPath, flagFileName),
- Path.Combine(LogPath, flagFileName)
- ];
+ return new[] { InstallPath, LogPath }
+ .Concat(DataPaths)
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Select(path => Path.Combine(path, flagFileName));
}
}
+ // Directories the service writes data to. Each is created with modify access for the service account.
+ protected virtual IEnumerable DataPaths => [DBPath, MessageBodyStoragePath];
+
+ // Only populated for storage types that keep message bodies outside the database.
+ public MessageBodyStorage MessageBodyStorage { get; set; }
+
+ string MessageBodyStoragePath => MessageBodyStorage?.StorageType == MessageBodyStorageType.FileSystem ? MessageBodyStorage.FileSystemStoragePath : null;
+
public string LogPath { get; set; }
public string DBPath { get; set; }
@@ -98,6 +105,8 @@ string[] FlagFiles
public string ConnectionString { get; set; }
+ public string PersistenceConnectionString { get; set; }
+
public string Name { get; set; }
public string InstanceName { get; set; }
@@ -148,9 +157,9 @@ public virtual void CopyFiles(string zipResourceName)
FileUtils.CreateDirectoryAndSetAcl(LogPath, modifyAccessRule);
}
- if (!string.IsNullOrWhiteSpace(DBPath))
+ foreach (var dataPath in DataPaths.Where(path => !string.IsNullOrWhiteSpace(path)))
{
- FileUtils.CreateDirectoryAndSetAcl(DBPath, modifyAccessRule);
+ FileUtils.CreateDirectoryAndSetAcl(dataPath, modifyAccessRule);
}
// Mark these directories with a flag
@@ -238,6 +247,7 @@ public async Task Validate(Func> promptT
RunValidation(ValidateQueueNames);
RunValidation(ValidateServiceAccount);
RunValidation(ValidateConnectionString);
+ RunValidation(ValidatePersistence);
}
public void RunValidation(Action action)
@@ -260,6 +270,10 @@ protected virtual void ValidateConnectionString()
{
}
+ protected virtual void ValidatePersistence()
+ {
+ }
+
protected virtual void ValidateServiceAccount()
{
}
@@ -270,9 +284,19 @@ protected virtual void ValidateQueueNames()
protected virtual Task ValidatePaths(Func> promptToProceed, CancellationToken cancellationToken = default)
{
- return new PathsValidator(this).RunValidation(true, promptToProceed, cancellationToken);
+ return new PathsValidator(this, AdditionalPaths).RunValidation(true, promptToProceed, cancellationToken);
}
+ protected virtual IEnumerable AdditionalPaths =>
+ [
+ new PathInfo
+ {
+ Name = "message body path",
+ Path = MessageBodyStoragePath,
+ CheckIfEmpty = true
+ }
+ ];
+
protected virtual void ValidateMaintenancePort()
{
}
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs
index 79834da36b..a917e027a5 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs
@@ -52,7 +52,7 @@ public ServiceControlInstance(IWindowsServiceController service) : base(service)
public List RemoteInstances { get; set; } = [];
- public PersistenceManifest PersistenceManifest { get; set; }
+ public string PersistenceConnectionString { get; set; }
public void AddRemoteInstance(string apiUri)
{
@@ -218,7 +218,12 @@ protected override void ApplySettingsChanges(KeyValueConfigurationCollection set
settings.Set(ServiceControlSettings.ErrorQueue, ErrorQueue);
settings.Set(ServiceControlSettings.AuditLogQueue, AuditLogQueue, Version);
settings.Set(ServiceControlSettings.ErrorLogQueue, ErrorLogQueue, Version);
- settings.Set(ServiceControlSettings.EnableFullTextSearchOnBodies, EnableFullTextSearchOnBodies.ToString(), Version);
+
+ var enableFullTextSearchOnBodiesValue = PersistenceManifest.Settings.Any(s => s.Name == ServiceControlSettings.EnableFullTextSearchOnBodies.Name)
+ ? EnableFullTextSearchOnBodies.ToString().ToLowerInvariant()
+ : null;
+
+ settings.Set(ServiceControlSettings.EnableFullTextSearchOnBodies, enableFullTextSearchOnBodiesValue, Version);
settings.Set(ServiceControlSettings.StorageType, PersistenceManifest.Name);
settings.Set(ServiceControlSettings.EnableIntegratedServicePulse, EnableIntegratedServicePulse.ToString(), Version);
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlNewInstance.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlNewInstance.cs
index 62fded61dd..a9270ee621 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlNewInstance.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlNewInstance.cs
@@ -92,7 +92,10 @@ protected override void RunSetup()
protected override void ValidateMaintenancePort()
{
- DatabaseMaintenancePortValidator.Validate(this);
+ if (PersistenceManifest.Settings.Any(s => s.Name == ServiceControlSettings.DatabaseMaintenancePort.Name))
+ {
+ DatabaseMaintenancePortValidator.Validate(this);
+ }
}
protected override void ValidateQueueNames()
@@ -110,6 +113,11 @@ protected override void ValidateConnectionString()
ConnectionStringValidator.Validate(this);
}
+ protected override void ValidatePersistence()
+ {
+ MessageBodyStorageValidator.Validate(PersistenceManifest, MessageBodyStorage);
+ }
+
public static ServiceControlNewInstance Load(string path)
{
ServiceControlNewInstance instanceData;
diff --git a/src/ServiceControlInstaller.Engine/Interfaces.cs b/src/ServiceControlInstaller.Engine/Interfaces.cs
index 5d5968b7a5..05f3e5a2a5 100644
--- a/src/ServiceControlInstaller.Engine/Interfaces.cs
+++ b/src/ServiceControlInstaller.Engine/Interfaces.cs
@@ -22,6 +22,10 @@ public interface ITransportConfig
public interface IPersistenceConfig
{
PersistenceManifest PersistenceManifest { get; }
+
+ // Only populated when creating a new instance; existing instances read their
+ // connection string back from the already-written config file instead.
+ string PersistenceConnectionString { get; set; }
}
public interface IHttpInstance
@@ -91,6 +95,9 @@ public interface IServiceControlBaseInstance : IHttpInstance,
IPersistenceConfig
{
bool EnableFullTextSearchOnBodies { get; }
+
+ // Only populated when creating a new instance, like PersistenceConnectionString.
+ MessageBodyStorage MessageBodyStorage { get; }
}
public interface IServiceControlAuditInstance : IServiceControlBaseInstance
diff --git a/src/ServiceControlInstaller.Engine/Validation/MessageBodyStorageValidator.cs b/src/ServiceControlInstaller.Engine/Validation/MessageBodyStorageValidator.cs
new file mode 100644
index 0000000000..79352b9246
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine/Validation/MessageBodyStorageValidator.cs
@@ -0,0 +1,78 @@
+namespace ServiceControlInstaller.Engine.Validation
+{
+ using System;
+ using System.Linq;
+ using Instances;
+
+ // Mirrors the checks ServiceControl runs at startup, so a bad combination fails at install time instead.
+ static class MessageBodyStorageValidator
+ {
+ public static void Validate(PersistenceManifest manifest, MessageBodyStorage storage)
+ {
+ var supportsBodyStorage = manifest.Settings.Any(s => s.Name.EndsWith("/MessageBody/StorageType"));
+
+ if (!supportsBodyStorage)
+ {
+ if (storage != null)
+ {
+ throw new EngineValidationException($"The {manifest.DisplayName} storage type does not support configuring message body storage.");
+ }
+
+ return;
+ }
+
+ if (storage == null)
+ {
+ throw new EngineValidationException($"Message body storage must be configured for the {manifest.DisplayName} storage type.");
+ }
+
+ switch (storage.StorageType)
+ {
+ case MessageBodyStorageType.FileSystem:
+ if (IsEmpty(storage.FileSystemStoragePath))
+ {
+ throw new EngineValidationException("A message body path is required when message bodies are stored on the file system.");
+ }
+
+ break;
+ case MessageBodyStorageType.AzureBlob:
+ var hasConnectionString = !IsEmpty(storage.AzureConnectionString);
+ var hasServiceUri = !IsEmpty(storage.AzureServiceUri);
+
+ if (hasConnectionString == hasServiceUri)
+ {
+ throw new EngineValidationException("Azure Blob Storage requires either a connection string or a service URI for managed identity, but not both.");
+ }
+
+ CheckAbsoluteUri(storage.AzureServiceUri, "Azure Blob Storage service URI");
+ CheckAbsoluteUri(storage.AzureAuthorityHost, "Azure authority host");
+ break;
+ case MessageBodyStorageType.S3:
+ if (IsEmpty(storage.S3BucketName))
+ {
+ throw new EngineValidationException("An S3 bucket name is required when message bodies are stored in Amazon S3.");
+ }
+
+ if (IsEmpty(storage.S3AccessKeyId) != IsEmpty(storage.S3SecretAccessKey))
+ {
+ throw new EngineValidationException("Specify both the S3 access key ID and secret access key, or neither to use the default AWS credentials.");
+ }
+
+ CheckAbsoluteUri(storage.S3ServiceUrl, "S3 service URL");
+ break;
+ default:
+ throw new EngineValidationException($"Message body storage type '{storage.StorageType}' is not supported.");
+ }
+ }
+
+ static void CheckAbsoluteUri(string value, string name)
+ {
+ if (!IsEmpty(value) && !Uri.TryCreate(value, UriKind.Absolute, out _))
+ {
+ throw new EngineValidationException($"The {name} '{value}' is not a valid absolute URI.");
+ }
+ }
+
+ static bool IsEmpty(string value) => string.IsNullOrWhiteSpace(value);
+ }
+}
diff --git a/src/ServiceControlInstaller.Engine/Validation/PathsValidator.cs b/src/ServiceControlInstaller.Engine/Validation/PathsValidator.cs
index edfa7a5c5f..e041dfcea9 100644
--- a/src/ServiceControlInstaller.Engine/Validation/PathsValidator.cs
+++ b/src/ServiceControlInstaller.Engine/Validation/PathsValidator.cs
@@ -28,7 +28,11 @@ public PathsValidator(IServicePaths instance)
paths = pathList.Where(p => !string.IsNullOrWhiteSpace(p.Path)).ToList();
}
- public PathsValidator(IServiceControlPaths instance)
+ public PathsValidator(IServiceControlPaths instance) : this(instance, [])
+ {
+ }
+
+ public PathsValidator(IServiceControlPaths instance, IEnumerable additionalPaths)
{
var pathList = new List
{
@@ -50,6 +54,12 @@ public PathsValidator(IServiceControlPaths instance)
CheckIfEmpty = true
}
};
+ pathList.AddRange(additionalPaths.Select(p => new PathInfo
+ {
+ Name = p.Name,
+ Path = Environment.ExpandEnvironmentVariables(p.Path ?? string.Empty),
+ CheckIfEmpty = p.CheckIfEmpty
+ }));
paths = pathList.Where(p => !string.IsNullOrWhiteSpace(p.Path)).ToList();
}
From c94c5e52acb862e690bf07c15e3e89eea65bb77c Mon Sep 17 00:00:00 2001
From: Andreas Bednarz <110360248+abparticular@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:45:50 +1100
Subject: [PATCH 2/5] Remove only the local data of SQL Server and PostgreSQL
instances
Removing an instance with the "remove database" option deleted the paths each instance type hard-coded for RavenDB. The removal now uses the paths listed in the persistence manifest's SettingsWithPathsToCleanup:
- The EF Core manifests list the file system message body path. It is removed only when message bodies are stored on the file system.
- Nothing is removed for an external RavenDB database.
The SCMU remove dialog hides the data option when there is nothing local to remove, labels it for database files or message body files, and names the external database or body storage that the user must delete manually. The Remove cmdlets' -RemoveDB help explains the same.
---
.../persistence.manifest | 3 +
.../persistence.manifest | 3 +
.../DeleteServiceControlInstanceCommand.cs | 33 ++++
.../DeleteServiceControlConfirmationView.xaml | 41 ++---
...leteServiceControlConfirmationViewModel.cs | 8 +
.../RemoveServiceControlAuditInstance.cs | 2 +-
.../RemoveServiceControlInstance.cs | 2 +-
...Control.Management.PowerShell.dll-help.xml | 8 +-
.../persistence.manifest | 3 +
.../persistence.manifest | 3 +
.../Configuration/AuditInstanceTests.cs | 64 +++++++
.../Configuration/InstanceTests.cs | 171 ++++++++++++++++++
.../Instances/ServiceControlAuditInstance.cs | 24 +--
.../Instances/ServiceControlBaseService.cs | 57 +++++-
.../Instances/ServiceControlInstance.cs | 32 +---
15 files changed, 385 insertions(+), 69 deletions(-)
create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/Configuration/InstanceTests.cs
diff --git a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/persistence.manifest b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/persistence.manifest
index 93363f97c6..db5298fb6e 100644
--- a/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/persistence.manifest
+++ b/src/ServiceControl.Audit.Persistence.EFCore.PostgreSql/persistence.manifest
@@ -78,5 +78,8 @@
"Name": "ServiceControl.Audit/MessageBody/MinCompressionSize",
"Mandatory": false
}
+ ],
+ "SettingsWithPathsToCleanup": [
+ "ServiceControl.Audit/MessageBody/FileSystem/StoragePath"
]
}
diff --git a/src/ServiceControl.Audit.Persistence.EFCore.SqlServer/persistence.manifest b/src/ServiceControl.Audit.Persistence.EFCore.SqlServer/persistence.manifest
index 475fec6814..4f85894da0 100644
--- a/src/ServiceControl.Audit.Persistence.EFCore.SqlServer/persistence.manifest
+++ b/src/ServiceControl.Audit.Persistence.EFCore.SqlServer/persistence.manifest
@@ -78,5 +78,8 @@
"Name": "ServiceControl.Audit/MessageBody/MinCompressionSize",
"Mandatory": false
}
+ ],
+ "SettingsWithPathsToCleanup": [
+ "ServiceControl.Audit/MessageBody/FileSystem/StoragePath"
]
}
diff --git a/src/ServiceControl.Config/Commands/DeleteServiceControlInstanceCommand.cs b/src/ServiceControl.Config/Commands/DeleteServiceControlInstanceCommand.cs
index 9ed943fd4e..2497f63478 100644
--- a/src/ServiceControl.Config/Commands/DeleteServiceControlInstanceCommand.cs
+++ b/src/ServiceControl.Config/Commands/DeleteServiceControlInstanceCommand.cs
@@ -7,6 +7,7 @@
using Framework;
using Framework.Commands;
using Framework.Modules;
+ using ServiceControlInstaller.Engine.Instances;
using UI.AdvancedOptions;
using UI.DeleteInstanceConfirmation;
@@ -31,6 +32,9 @@ public override async Task ExecuteAsync(ServiceControlAdvancedViewModel model)
var confirmation = deleteInstanceConfirmation();
confirmation.InstanceName = model.Name;
+ confirmation.HasCleanableStoragePaths = model.ServiceControlInstance.HasCleanableStoragePaths;
+ confirmation.RemoveDataLabel = model.ServiceControlInstance.UsesEmbeddedDatabase ? "Remove database files and data" : "Remove message body files";
+ confirmation.UnmanagedStorageMessage = DescribeUnmanagedStorage(model.ServiceControlInstance);
var isConfirmed = await windowManager.ShowDialogAsync(confirmation);
if (isConfirmed == true)
{
@@ -52,6 +56,35 @@ public override async Task ExecuteAsync(ServiceControlAdvancedViewModel model)
}
}
+ static string DescribeUnmanagedStorage(ServiceControlBaseService instance)
+ {
+ var database = instance.UsesEmbeddedDatabase ? null : $"The {instance.PersistenceManifest.DisplayName} database";
+ var bodyLocation = instance.ConfiguredMessageBodyStorageType switch
+ {
+ MessageBodyStorageType.AzureBlob => "Azure Blob Storage",
+ MessageBodyStorageType.S3 => "Amazon S3",
+ MessageBodyStorageType.FileSystem => null,
+ null => null,
+ _ => null
+ };
+
+ var subject = (database, bodyLocation) switch
+ {
+ (null, null) => null,
+ (_, null) => $"{database} is",
+ (null, _) => $"The message bodies in {bodyLocation} are",
+ _ => $"{database} and the message bodies in {bodyLocation} are"
+ };
+
+ if (subject == null)
+ {
+ return null;
+ }
+
+ var pronoun = bodyLocation == null ? "it" : "them";
+ return $"{subject} not managed by ServiceControl and will not be removed. Delete {pronoun} manually if you no longer need {pronoun}.";
+ }
+
readonly Func deleteInstanceConfirmation;
readonly IEventAggregator eventAggregator;
readonly ServiceControlInstanceInstaller installer;
diff --git a/src/ServiceControl.Config/UI/DeleteInstanceConfirmation/DeleteServiceControlConfirmationView.xaml b/src/ServiceControl.Config/UI/DeleteInstanceConfirmation/DeleteServiceControlConfirmationView.xaml
index cd1641f452..907051a862 100644
--- a/src/ServiceControl.Config/UI/DeleteInstanceConfirmation/DeleteServiceControlConfirmationView.xaml
+++ b/src/ServiceControl.Config/UI/DeleteInstanceConfirmation/DeleteServiceControlConfirmationView.xaml
@@ -6,7 +6,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="DeleteInstanceConfirmationView"
Width="500"
- Height="370"
+ SizeToContent="Height"
Background="{StaticResource Gray10Brush}"
Foreground="{StaticResource WhiteBrush}"
Padding="0"
@@ -29,7 +29,7 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
!string.IsNullOrEmpty(UnmanagedStorageMessage);
+
public bool RemoveDatabase { get; set; }
public bool RemoveLogs { get; set; }
diff --git a/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/RemoveServiceControlAuditInstance.cs b/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/RemoveServiceControlAuditInstance.cs
index 9c2b8fcb05..64b0590a51 100644
--- a/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/RemoveServiceControlAuditInstance.cs
+++ b/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/RemoveServiceControlAuditInstance.cs
@@ -12,7 +12,7 @@ public class RemoveServiceControlAuditInstance : PSCmdlet
[Parameter(Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 0, HelpMessage = "Specify the ServiceControl Audit instance name to remove")]
public string[] Name { get; set; }
- [Parameter(HelpMessage = "Remove the RavenDB database")]
+ [Parameter(HelpMessage = "Remove the database and message body files that the instance stores locally. A SQL Server or PostgreSQL database, Azure Blob Storage and Amazon S3 are not removed")]
public SwitchParameter RemoveDB { get; set; }
[Parameter(HelpMessage = "Remove the Logs directory")]
diff --git a/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/RemoveServiceControlInstance.cs b/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/RemoveServiceControlInstance.cs
index 017578f73d..4badc9d5f7 100644
--- a/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/RemoveServiceControlInstance.cs
+++ b/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/RemoveServiceControlInstance.cs
@@ -13,7 +13,7 @@ public class RemoveServiceControlInstance : PSCmdlet
[Parameter(Mandatory = true, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, Position = 0, HelpMessage = "Specify the ServiceControl instance name to remove")]
public string[] Name { get; set; }
- [Parameter(HelpMessage = "Remove the RavenDB database")]
+ [Parameter(HelpMessage = "Remove the database and message body files that the instance stores locally. A SQL Server or PostgreSQL database, Azure Blob Storage and Amazon S3 are not removed")]
public SwitchParameter RemoveDB { get; set; }
[Parameter(HelpMessage = "Remove the Logs directory")]
diff --git a/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml b/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml
index 42ad697714..f14ff3d98f 100644
--- a/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml
+++ b/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml
@@ -3087,7 +3087,7 @@
RemoveDB
- Remove the contents of the Database folder.
+ Remove the database and message body files that the instance stores locally. A SQL Server or PostgreSQL database, Azure Blob Storage and Amazon S3 are not removed.
SwitchParameter
@@ -3116,7 +3116,7 @@
RemoveDB
- Remove the contents of the Database folder.
+ Remove the database and message body files that the instance stores locally. A SQL Server or PostgreSQL database, Azure Blob Storage and Amazon S3 are not removed.
SwitchParameter
@@ -3308,7 +3308,7 @@
RemoveDB
- Remove the contents of the Database folder.
+ Remove the database and message body files that the instance stores locally. A SQL Server or PostgreSQL database, Azure Blob Storage and Amazon S3 are not removed.
SwitchParameter
@@ -3337,7 +3337,7 @@
RemoveDB
- Remove the contents of the Database folder.
+ Remove the database and message body files that the instance stores locally. A SQL Server or PostgreSQL database, Azure Blob Storage and Amazon S3 are not removed.
SwitchParameter
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest
index 7a1a5deda1..77a1c26cfa 100644
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest
@@ -78,5 +78,8 @@
"Name": "ServiceControl/MessageBody/MinCompressionSize",
"Mandatory": false
}
+ ],
+ "SettingsWithPathsToCleanup": [
+ "ServiceControl/MessageBody/FileSystem/StoragePath"
]
}
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest b/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest
index 0730992832..7853239632 100644
--- a/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest
@@ -78,5 +78,8 @@
"Name": "ServiceControl/MessageBody/MinCompressionSize",
"Mandatory": false
}
+ ],
+ "SettingsWithPathsToCleanup": [
+ "ServiceControl/MessageBody/FileSystem/StoragePath"
]
}
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Configuration/AuditInstanceTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/AuditInstanceTests.cs
index c143ceb24c..376bcccb86 100644
--- a/src/ServiceControlInstaller.Engine.UnitTests/Configuration/AuditInstanceTests.cs
+++ b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/AuditInstanceTests.cs
@@ -67,6 +67,70 @@ public void Should_remove_log_and_db_folders_on_uninstall()
Assert.That(Directory.Exists(DbPath), Is.False);
}
+ [Test]
+ public void Should_remove_only_message_body_folder_on_uninstall_for_SQL_Server()
+ {
+ var messageBodyPath = Path.Combine(Path.GetDirectoryName(DbPath), "bodies");
+ var newInstance = CreateSqlServerInstance(new MessageBodyStorage
+ {
+ StorageType = MessageBodyStorageType.FileSystem,
+ FileSystemStoragePath = messageBodyPath
+ });
+
+ newInstance.CopyFiles(zipResourceName);
+ newInstance.WriteConfigurationFile();
+
+ var instance = new ServiceControlAuditInstance(new FakeWindowsServiceController(Path.Combine(InstallPath, "ServiceControl.Audit.exe")));
+
+ instance.Reload();
+
+ Assert.That(Directory.Exists(DbPath), Is.False, "SQL Server instances have no local database folder");
+ Assert.That(Directory.Exists(messageBodyPath), Is.True);
+ Assert.That(instance.HasCleanableStoragePaths, Is.True);
+ Assert.That(instance.UsesEmbeddedDatabase, Is.False);
+ Assert.That(instance.ConfiguredMessageBodyStorageType, Is.EqualTo(MessageBodyStorageType.FileSystem));
+ Assert.That(instance.MessageBodyStoragePath, Is.EqualTo(messageBodyPath));
+
+ instance.RemoveDataBaseFolder();
+
+ Assert.That(Directory.Exists(messageBodyPath), Is.False);
+ }
+
+ [Test]
+ public void Should_not_attempt_local_cleanup_on_uninstall_for_external_body_storage()
+ {
+ var newInstance = CreateSqlServerInstance(new MessageBodyStorage
+ {
+ StorageType = MessageBodyStorageType.AzureBlob,
+ AzureConnectionString = "UseDevelopmentStorage=true"
+ });
+
+ newInstance.CopyFiles(zipResourceName);
+ newInstance.WriteConfigurationFile();
+
+ var instance = new ServiceControlAuditInstance(new FakeWindowsServiceController(Path.Combine(InstallPath, "ServiceControl.Audit.exe")));
+
+ instance.Reload();
+
+ Assert.That(instance.HasCleanableStoragePaths, Is.False);
+ Assert.That(instance.ConfiguredMessageBodyStorageType, Is.EqualTo(MessageBodyStorageType.AzureBlob));
+ Assert.That(instance.AppConfig.Read("ServiceControl.Audit/MessageBody/Azure/ConnectionString", null), Is.EqualTo("UseDevelopmentStorage=true"));
+ }
+
+ ServiceControlAuditNewInstance CreateSqlServerInstance(MessageBodyStorage messageBodyStorage)
+ {
+ var newInstance = ServiceControlAuditNewInstance.CreateWithPersistence("SQLServer");
+
+ newInstance.InstallPath = InstallPath;
+ newInstance.TransportPackage = ServiceControlCoreTransports.Find("LearningTransport");
+ newInstance.LogPath = LogPath;
+ newInstance.HostName = "localhost";
+ newInstance.PersistenceConnectionString = "placeholder-connection-string";
+ newInstance.MessageBodyStorage = messageBodyStorage;
+
+ return newInstance;
+ }
+
class FakeWindowsServiceController : IWindowsServiceController
{
public FakeWindowsServiceController(string exePath)
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Configuration/InstanceTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/InstanceTests.cs
new file mode 100644
index 0000000000..4ebac06112
--- /dev/null
+++ b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/InstanceTests.cs
@@ -0,0 +1,171 @@
+namespace ServiceControlInstaller.Engine.UnitTests.Configuration
+{
+ using System;
+ using System.IO;
+ using System.ServiceProcess;
+ using NUnit.Framework;
+ using ServiceControlInstaller.Engine.Configuration.ServiceControl;
+ using ServiceControlInstaller.Engine.Instances;
+ using ServiceControlInstaller.Engine.Services;
+
+ [TestFixture]
+ class InstanceTests : InstallationFixture
+ {
+ const string zipResourceName = "Particular.ServiceControl.zip";
+
+ [Test]
+ public void Should_remove_db_folder_on_uninstall_for_RavenDB()
+ {
+ var newInstance = ServiceControlNewInstance.CreateWithPersistence("RavenDB");
+
+ newInstance.InstallPath = InstallPath;
+ newInstance.TransportPackage = ServiceControlCoreTransports.Find("LearningTransport");
+ newInstance.DBPath = DbPath;
+ newInstance.LogPath = LogPath;
+ newInstance.HostName = "localhost";
+ newInstance.DatabaseMaintenancePort = 33333;
+
+ newInstance.CopyFiles(zipResourceName);
+ newInstance.WriteConfigurationFile();
+
+ var instance = new ServiceControlInstance(new FakeWindowsServiceController(Path.Combine(InstallPath, "ServiceControl.exe")));
+
+ instance.Reload();
+
+ Assert.That(instance.HasCleanableStoragePaths, Is.True);
+ Assert.That(instance.UsesEmbeddedDatabase, Is.True);
+
+ instance.RemoveLogsFolder();
+ instance.RemoveDataBaseFolder();
+
+ Assert.That(Directory.Exists(LogPath), Is.False);
+ Assert.That(Directory.Exists(DbPath), Is.False);
+ }
+
+ [Test]
+ public void Should_remove_only_message_body_folder_on_uninstall_for_SQL_Server()
+ {
+ var messageBodyPath = Path.Combine(Path.GetDirectoryName(DbPath), "bodies");
+ var newInstance = CreateSqlServerInstance(new MessageBodyStorage
+ {
+ StorageType = MessageBodyStorageType.FileSystem,
+ FileSystemStoragePath = messageBodyPath
+ });
+
+ newInstance.CopyFiles(zipResourceName);
+ newInstance.WriteConfigurationFile();
+
+ var instance = new ServiceControlInstance(new FakeWindowsServiceController(Path.Combine(InstallPath, "ServiceControl.exe")));
+
+ instance.Reload();
+
+ Assert.That(Directory.Exists(DbPath), Is.False, "SQL Server instances have no local database folder");
+ Assert.That(Directory.Exists(messageBodyPath), Is.True);
+ Assert.That(instance.HasCleanableStoragePaths, Is.True);
+ Assert.That(instance.UsesEmbeddedDatabase, Is.False);
+ Assert.That(instance.ConfiguredMessageBodyStorageType, Is.EqualTo(MessageBodyStorageType.FileSystem));
+ Assert.That(instance.MessageBodyStoragePath, Is.EqualTo(messageBodyPath));
+
+ instance.RemoveLogsFolder();
+ instance.RemoveDataBaseFolder();
+
+ Assert.That(Directory.Exists(LogPath), Is.False);
+ Assert.That(Directory.Exists(messageBodyPath), Is.False);
+ }
+
+ [Test]
+ public void Should_not_attempt_local_cleanup_on_uninstall_for_external_storage()
+ {
+ var newInstance = CreateSqlServerInstance(new MessageBodyStorage
+ {
+ StorageType = MessageBodyStorageType.S3,
+ S3BucketName = "bodies"
+ });
+
+ newInstance.CopyFiles(zipResourceName);
+ newInstance.WriteConfigurationFile();
+
+ var instance = new ServiceControlInstance(new FakeWindowsServiceController(Path.Combine(InstallPath, "ServiceControl.exe")));
+
+ instance.Reload();
+
+ Assert.That(instance.HasCleanableStoragePaths, Is.False);
+ Assert.That(instance.ConfiguredMessageBodyStorageType, Is.EqualTo(MessageBodyStorageType.S3));
+ Assert.That(instance.MessageBodyStoragePath, Is.Null);
+ Assert.That(instance.AppConfig.Read(ServiceControlSettings.MessageBodyFileSystemStoragePath, null), Is.Null, "Only the selected body storage type's settings are written");
+ }
+
+ ServiceControlNewInstance CreateSqlServerInstance(MessageBodyStorage messageBodyStorage)
+ {
+ var newInstance = ServiceControlNewInstance.CreateWithPersistence("SQLServer");
+
+ newInstance.InstallPath = InstallPath;
+ newInstance.TransportPackage = ServiceControlCoreTransports.Find("LearningTransport");
+ newInstance.LogPath = LogPath;
+ newInstance.HostName = "localhost";
+ newInstance.PersistenceConnectionString = "placeholder-connection-string";
+ newInstance.MessageBodyStorage = messageBodyStorage;
+
+ return newInstance;
+ }
+
+ [Test]
+ public void Should_treat_external_RavenDB_as_unmanaged_storage()
+ {
+ var newInstance = ServiceControlNewInstance.CreateWithPersistence("RavenDB");
+
+ newInstance.InstallPath = InstallPath;
+ newInstance.TransportPackage = ServiceControlCoreTransports.Find("LearningTransport");
+ newInstance.DBPath = DbPath;
+ newInstance.LogPath = LogPath;
+ newInstance.HostName = "localhost";
+ newInstance.DatabaseMaintenancePort = 33333;
+
+ newInstance.CopyFiles(zipResourceName);
+ newInstance.WriteConfigurationFile();
+
+ var instance = new ServiceControlInstance(new FakeWindowsServiceController(Path.Combine(InstallPath, "ServiceControl.exe")));
+ instance.AppConfig.Config.AppSettings.Settings.Add(ServiceControlSettings.RavenDBConnectionString.Name, "http://ravendb.example:8080");
+ instance.AppConfig.Config.Save();
+
+ instance.Reload();
+
+ Assert.That(instance.UsesEmbeddedDatabase, Is.False);
+ Assert.That(instance.HasCleanableStoragePaths, Is.False);
+
+ instance.RemoveDataBaseFolder();
+
+ Assert.That(Directory.Exists(DbPath), Is.True);
+ }
+
+ class FakeWindowsServiceController : IWindowsServiceController
+ {
+ public FakeWindowsServiceController(string exePath)
+ {
+ ExePath = exePath;
+ }
+ public string ServiceName => "ServiceControl";
+
+ public bool Exists() => true;
+
+ public string ExePath { get; }
+
+ public string Description { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
+
+ public ServiceControllerStatus Status => throw new NotImplementedException();
+
+ public string Account => "system";
+
+ public string DisplayName => throw new NotImplementedException();
+
+ public void ChangeAccountDetails(string accountName, string serviceAccountPwd) => throw new NotImplementedException();
+ public void Delete() => throw new NotImplementedException();
+ public void Refresh()
+ { }
+ public void SetStartupMode(string v) => throw new NotImplementedException();
+ public void Start() => throw new NotImplementedException();
+ public void Stop() => throw new NotImplementedException();
+ public void WaitForStatus(ServiceControllerStatus stopped, TimeSpan timeSpan) => throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs
index c760c98d6d..9b89a67257 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlAuditInstance.cs
@@ -97,6 +97,14 @@ protected override TransportInfo DetermineTransportPackage()
protected override string BaseServiceName => "ServiceControl.Audit";
+ protected override SettingInfo DatabaseMaintenancePortSetting => AuditInstanceSettingsList.DatabaseMaintenancePort;
+
+ protected override SettingInfo RavenDBConnectionStringSetting => AuditInstanceSettingsList.RavenDBConnectionString;
+
+ protected override SettingInfo MessageBodyStorageTypeSetting => AuditInstanceSettingsList.MessageBodyStorageType;
+
+ protected override SettingInfo MessageBodyStoragePathSetting => AuditInstanceSettingsList.MessageBodyFileSystemStoragePath;
+
public override void Reload()
{
Service.Refresh();
@@ -158,22 +166,6 @@ protected override void Prepare(string zipResourceName, string destDir)
FileUtils.UnzipToSubdirectory("RavenDBServer.zip", Path.Combine(destDir, "Persisters", "RavenDB", "RavenDBServer"));
}
- protected override IEnumerable GetPersistencePathsToCleanUp()
- {
- var settings = AppConfig.Config.AppSettings.Settings;
- foreach (var key in PersistenceManifest.SettingsWithPathsToCleanup)
- {
- if (!settings.AllKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
- {
- continue;
- }
-
- var folderPath = settings[key].Value;
- yield return folderPath.StartsWith("~") //Raven uses ~ to indicate path is relative to bin folder e.g. ~/Data/Logs
- ? Path.Combine(InstallPath, folderPath.Remove(0, 1))
- : folderPath;
- }
- }
protected override void ValidateConnectionString()
{
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs
index aabcf2bcd0..5a86735663 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs
@@ -42,8 +42,38 @@ protected ServiceControlBaseService(IWindowsServiceController service)
public ReportCard ReportCard { get; set; }
public PersistenceManifest PersistenceManifest { get; set; }
+ // False when all data lives somewhere ServiceControl doesn't manage, such as a SQL Server database or an S3 bucket.
+ public bool HasCleanableStoragePaths => AppConfig?.Config != null && !UsesExternalRavenDB && GetPersistencePathsToCleanUp().Any();
+
+ // Only persisters that run an embedded database server declare a maintenance port.
+ public bool UsesEmbeddedDatabase =>
+ PersistenceManifest?.Settings.Any(setting => setting.Name == DatabaseMaintenancePortSetting.Name) == true
+ && !UsesExternalRavenDB;
+
+ bool UsesExternalRavenDB => !string.IsNullOrWhiteSpace(AppConfig?.Read(RavenDBConnectionStringSetting, null));
+
+ protected abstract SettingInfo DatabaseMaintenancePortSetting { get; }
+
+ protected abstract SettingInfo RavenDBConnectionStringSetting { get; }
+
+ protected abstract SettingInfo MessageBodyStorageTypeSetting { get; }
+
+ protected abstract SettingInfo MessageBodyStoragePathSetting { get; }
+
public MessageBodyStorage MessageBodyStorage => null;
+ // Null for storage types that keep message bodies in the database itself.
+ public MessageBodyStorageType? ConfiguredMessageBodyStorageType =>
+ Enum.TryParse(AppConfig?.Read(MessageBodyStorageTypeSetting, null), ignoreCase: true, out var storageType)
+ ? storageType
+ : null;
+
+ // Null unless message bodies are stored on the file system.
+ public string MessageBodyStoragePath =>
+ ConfiguredMessageBodyStorageType == MessageBodyStorageType.FileSystem
+ ? AppConfig.Read(MessageBodyStoragePathSetting, null)
+ : null;
+
///
/// Raven management URL
///
@@ -214,6 +244,11 @@ public void RemoveLogsFolder()
public void RemoveDataBaseFolder()
{
+ if (!HasCleanableStoragePaths)
+ {
+ return;
+ }
+
//Order by length descending in case they are nested paths
var folders = GetPersistencePathsToCleanUp().ToList();
@@ -295,9 +330,27 @@ public virtual void DisableMaintenanceMode()
InMaintenanceMode = false;
}
- protected virtual IEnumerable GetPersistencePathsToCleanUp()
+ IEnumerable GetPersistencePathsToCleanUp()
{
- return [];
+ var settings = AppConfig.Config.AppSettings.Settings;
+ foreach (var key in PersistenceManifest.SettingsWithPathsToCleanup)
+ {
+ if (!settings.AllKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (string.Equals(key, MessageBodyStoragePathSetting.Name, StringComparison.OrdinalIgnoreCase)
+ && ConfiguredMessageBodyStorageType != MessageBodyStorageType.FileSystem)
+ {
+ continue;
+ }
+
+ var folderPath = settings[key].Value;
+ yield return folderPath.StartsWith("~") //Raven uses ~ to indicate path is relative to bin folder e.g. ~/Data/Logs
+ ? Path.Combine(InstallPath, folderPath.Remove(0, 1))
+ : folderPath;
+ }
}
protected virtual void ValidateConnectionString()
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs
index a917e027a5..240999b9bb 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs
@@ -48,6 +48,14 @@ public ServiceControlInstance(IWindowsServiceController service) : base(service)
protected override string BaseServiceName => "ServiceControl";
+ protected override SettingInfo DatabaseMaintenancePortSetting => ServiceControlSettings.DatabaseMaintenancePort;
+
+ protected override SettingInfo RavenDBConnectionStringSetting => ServiceControlSettings.RavenDBConnectionString;
+
+ protected override SettingInfo MessageBodyStorageTypeSetting => ServiceControlSettings.MessageBodyStorageType;
+
+ protected override SettingInfo MessageBodyStoragePathSetting => ServiceControlSettings.MessageBodyFileSystemStoragePath;
+
public TimeSpan? AuditRetentionPeriod { get; set; }
public List RemoteInstances { get; set; } = [];
@@ -252,29 +260,5 @@ protected override void Prepare(string zipFilePath, string destDir)
FileUtils.UnzipToSubdirectory("RavenDBServer.zip", Path.Combine(destDir, "Persisters", "RavenDB", "RavenDBServer"));
}
- protected override IEnumerable GetPersistencePathsToCleanUp()
- {
- string[] keys =
- [
- "Raven/IndexStoragePath",
- "Raven/CompiledIndexCacheDirectory",
- "Raven/Esent/LogsPath",
- ServiceControlSettings.DBPath.Name
- ];
-
- var settings = AppConfig.Config.AppSettings.Settings;
- foreach (var key in keys)
- {
- if (!settings.AllKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
- {
- continue;
- }
-
- var folderpath = settings[key].Value;
- yield return folderpath.StartsWith("~") //Raven uses ~ to indicate path is relative to bin folder e.g. ~/Data/Logs
- ? Path.Combine(InstallPath, folderpath.Remove(0, 1))
- : folderpath;
- }
- }
}
}
\ No newline at end of file
From 0ae94db9932fdd8d64210e68545c798f5408d907 Mon Sep 17 00:00:00 2001
From: Andreas Bednarz <110360248+abparticular@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:52:16 +1100
Subject: [PATCH 3/5] Adapt the SCMU edit and details screens to the instance's
persister
The edit, details and advanced options screens now follow the instance's persistence manifest instead of assuming an embedded RavenDB database.
- The edit screens show and validate the database maintenance port and the full text search option only when the manifest declares them.
- The details screen links to the database path only for the embedded database, and links to the message body path when message bodies are stored on the file system.
- Maintenance mode is offered only for the embedded database, or when an instance is already in maintenance mode so the user can stop it.
---
.../EditAuditInstanceValidationTests.cs | 36 ++++++++++++
.../EditErrorInstanceValidationTests.cs | 56 +++++++++++++++++++
.../ServiceControlAdvancedViewModel.cs | 5 +-
.../InstanceDetails/InstanceDetailsView.xaml | 23 ++++++++
.../InstanceDetailsViewModel.cs | 20 +++----
.../ServiceControlAuditEditView.xaml | 31 +++++-----
.../ServiceControlAuditEditViewModel.cs | 11 +++-
...rviceControlAuditEditViewModelValidator.cs | 2 +-
.../InstanceEdit/ServiceControlEditView.xaml | 3 +-
.../ServiceControlEditViewModel.cs | 11 +++-
.../ServiceControlEditViewModelValidator.cs | 2 +-
11 files changed, 170 insertions(+), 30 deletions(-)
diff --git a/src/ServiceControl.Config.Tests/Validation/EditAuditInstanceValidationTests.cs b/src/ServiceControl.Config.Tests/Validation/EditAuditInstanceValidationTests.cs
index 8a23c110b3..34f07b5705 100644
--- a/src/ServiceControl.Config.Tests/Validation/EditAuditInstanceValidationTests.cs
+++ b/src/ServiceControl.Config.Tests/Validation/EditAuditInstanceValidationTests.cs
@@ -92,6 +92,42 @@ public void Transport_connection_string_can_be_null_if_sample_connection_string_
#region hostname
+ [TestCase("SQLServer", false)]
+ [TestCase("PostgreSQL", false)]
+ [TestCase("RavenDB", true)]
+ public void Database_maintenance_port_is_required_only_for_RavenDB_when_editing_audit_instance(string storageTypeName, bool required)
+ {
+ var viewModel = new ServiceControlAuditEditViewModel
+ {
+ PersistenceManifest = ServiceControlPersisters.GetAuditPersistence(storageTypeName),
+ SubmitAttempted = true,
+ DatabaseMaintenancePortNumber = null
+ };
+
+ viewModel.NotifyOfPropertyChange(nameof(viewModel.DatabaseMaintenancePortNumber));
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.DatabaseMaintenancePortNumber));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.DatabaseMaintenancePortNumberRequired, Is.EqualTo(required));
+ Assert.That(errors, required ? Is.Not.Empty : Is.Empty);
+ }
+ }
+
+ [TestCase("SQLServer", false)]
+ [TestCase("PostgreSQL", false)]
+ [TestCase("RavenDB", true)]
+ public void Full_text_search_option_is_shown_only_for_RavenDB_when_editing_audit_instance(string storageTypeName, bool expected)
+ {
+ var viewModel = new ServiceControlAuditEditViewModel
+ {
+ PersistenceManifest = ServiceControlPersisters.GetAuditPersistence(storageTypeName)
+ };
+
+ Assert.That(viewModel.ShowFullTextSearchOnBodies, Is.EqualTo(expected));
+ }
+
[Test]
public void Audit_hostname_can_not_be_empty_when_editing_audit_instance()
{
diff --git a/src/ServiceControl.Config.Tests/Validation/EditErrorInstanceValidationTests.cs b/src/ServiceControl.Config.Tests/Validation/EditErrorInstanceValidationTests.cs
index 52b8d82417..122d975f82 100644
--- a/src/ServiceControl.Config.Tests/Validation/EditErrorInstanceValidationTests.cs
+++ b/src/ServiceControl.Config.Tests/Validation/EditErrorInstanceValidationTests.cs
@@ -244,6 +244,62 @@ public void Error_port_is_not_equal_to_database_port_number_when_editing_error_i
#endregion
#region DatabaseManintenancePortnumber
+ [TestCase("SQLServer")]
+ [TestCase("PostgreSQL")]
+ public void Database_maintenance_port_is_not_required_when_editing_error_instance_without_one(string storageTypeName)
+ {
+ var viewModel = new ServiceControlEditViewModel
+ {
+ PersistenceManifest = ServiceControlPersisters.GetPrimaryPersistence(storageTypeName),
+ SubmitAttempted = true,
+ DatabaseMaintenancePortNumber = null
+ };
+
+ viewModel.NotifyOfPropertyChange(nameof(viewModel.DatabaseMaintenancePortNumber));
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.DatabaseMaintenancePortNumber));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.DatabaseMaintenancePortNumberRequired, Is.False);
+ Assert.That(errors, Is.Empty);
+ }
+ }
+
+ [Test]
+ public void Database_maintenance_port_is_required_when_editing_RavenDB_error_instance()
+ {
+ var viewModel = new ServiceControlEditViewModel
+ {
+ PersistenceManifest = ServiceControlPersisters.GetPrimaryPersistence("RavenDB"),
+ SubmitAttempted = true,
+ DatabaseMaintenancePortNumber = null
+ };
+
+ viewModel.NotifyOfPropertyChange(nameof(viewModel.DatabaseMaintenancePortNumber));
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.DatabaseMaintenancePortNumber));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.DatabaseMaintenancePortNumberRequired, Is.True);
+ Assert.That(errors, Is.Not.Empty);
+ }
+ }
+
+ [TestCase("SQLServer", false)]
+ [TestCase("PostgreSQL", false)]
+ [TestCase("RavenDB", true)]
+ public void Full_text_search_option_is_shown_only_for_RavenDB_when_editing_error_instance(string storageTypeName, bool expected)
+ {
+ var viewModel = new ServiceControlEditViewModel
+ {
+ PersistenceManifest = ServiceControlPersisters.GetPrimaryPersistence(storageTypeName)
+ };
+
+ Assert.That(viewModel.ShowFullTextSearchOnBodies, Is.EqualTo(expected));
+ }
+
[Test]
public void Database_maintenance_port_cannot_be_empty_when_editing_error_instance()
{
diff --git a/src/ServiceControl.Config/UI/AdvancedOptions/ServiceControlAdvancedViewModel.cs b/src/ServiceControl.Config/UI/AdvancedOptions/ServiceControlAdvancedViewModel.cs
index 638fad9e38..b0a921c104 100644
--- a/src/ServiceControl.Config/UI/AdvancedOptions/ServiceControlAdvancedViewModel.cs
+++ b/src/ServiceControl.Config/UI/AdvancedOptions/ServiceControlAdvancedViewModel.cs
@@ -65,7 +65,10 @@ ForceUpgradeAuditInstanceCommand forceUpgradeAuditCommand
public ICommand StartServiceInMaintenanceModeCommand { get; set; }
public ICommand StopMaintenanceModeCommand { get; set; }
- public bool MaintenanceModeSupported => ServiceControlInstance.Version >= ServiceControlSettings.MaintenanceMode.SupportedFrom;
+ // Stays visible while an instance is already in maintenance mode so the user can still stop it.
+ public bool MaintenanceModeSupported =>
+ ServiceControlInstance.Version >= ServiceControlSettings.MaintenanceMode.SupportedFrom
+ && (ServiceControlInstance.UsesEmbeddedDatabase || ServiceControlInstance.InMaintenanceMode);
public ICommand DeleteCommand { get; set; }
diff --git a/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml b/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml
index 477d4de761..c04b113eed 100644
--- a/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml
+++ b/src/ServiceControl.Config/UI/InstanceDetails/InstanceDetailsView.xaml
@@ -273,6 +273,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
!string.IsNullOrEmpty(DBPath);
+ public string MessageBodyPath => IsServiceControlInstance ? ServiceControlInstance?.MessageBodyStoragePath : IsServiceControlAudit ? ServiceControlAuditInstance?.MessageBodyStoragePath : null;
+
+ public bool HasBrowsableMessageBodyPath => !string.IsNullOrEmpty(MessageBodyPath);
+
public string LogPath => ((IServicePaths)ServiceInstance).LogPath;
public SemanticVersion Version => ServiceInstance.Version;
@@ -370,6 +367,9 @@ public Task HandleAsync(PostRefreshInstances message, CancellationToken cancella
NotifyOfPropertyChange("Host");
NotifyOfPropertyChange("InstallPath");
NotifyOfPropertyChange("DBPath");
+ NotifyOfPropertyChange(nameof(HasBrowsableDBPath));
+ NotifyOfPropertyChange(nameof(MessageBodyPath));
+ NotifyOfPropertyChange(nameof(HasBrowsableMessageBodyPath));
NotifyOfPropertyChange("LogPath");
NotifyOfPropertyChange("Version");
NotifyOfPropertyChange("NewVersion");
diff --git a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditView.xaml b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditView.xaml
index bff0eec344..6854cb2588 100644
--- a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditView.xaml
+++ b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditView.xaml
@@ -87,7 +87,8 @@
Warning="{Binding HostNameWarning}" />
+ Text="{Binding DatabaseMaintenancePortNumber}"
+ Visibility="{Binding DatabaseMaintenancePortNumberRequired, Converter={StaticResource boolToVis}}" />
-
-
-
-
+
+
+
+
+
+
diff --git a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModel.cs b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModel.cs
index 20069e1e87..638cb0b95f 100644
--- a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModel.cs
+++ b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModel.cs
@@ -2,10 +2,12 @@ namespace ServiceControl.Config.UI.InstanceEdit
{
using System;
using System.Collections.Generic;
+ using System.Linq;
using System.Windows.Input;
using Extensions;
using InstanceAdd;
using PropertyChanged;
+ using ServiceControlInstaller.Engine.Configuration.ServiceControl;
using ServiceControlInstaller.Engine.Instances;
using Validar;
using Xaml.Controls;
@@ -17,6 +19,7 @@ public ServiceControlAuditEditViewModel(ServiceControlAuditInstance instance)
{
DisplayName = "EDIT SERVICECONTROL AUDIT INSTANCE";
ServiceControlInstance = instance;
+ PersistenceManifest = instance.PersistenceManifest;
ServiceControlAudit.UpdateFromInstance(instance);
SelectedTransport = instance.TransportPackage;
ConnectionString = instance.ConnectionString;
@@ -29,6 +32,12 @@ internal ServiceControlAuditEditViewModel()
public ServiceControlAuditInstance ServiceControlInstance { get; set; }
+ public PersistenceManifest PersistenceManifest { get; set; }
+
+ public bool DatabaseMaintenancePortNumberRequired => PersistenceManifest?.Settings.Any(s => s.Name == AuditInstanceSettingsList.DatabaseMaintenancePort.Name) ?? true;
+
+ public bool ShowFullTextSearchOnBodies => PersistenceManifest?.Settings.Any(s => s.Name == AuditInstanceSettingsList.EnableFullTextSearchOnBodies.Name) ?? true;
+
public void UpdateInstanceFromViewModel(ServiceControlAuditInstance instance)
{
instance.HostName = ServiceControlAudit.HostName;
@@ -39,7 +48,7 @@ public void UpdateInstanceFromViewModel(ServiceControlAuditInstance instance)
instance.AuditRetentionPeriod = ServiceControlAudit.AuditRetentionPeriod;
instance.ForwardAuditMessages = ServiceControlAudit.AuditForwarding.Value;
instance.ConnectionString = ConnectionString;
- instance.DatabaseMaintenancePort = Convert.ToInt32(ServiceControlAudit.DatabaseMaintenancePortNumber);
+ instance.DatabaseMaintenancePort = string.IsNullOrWhiteSpace(ServiceControlAudit.DatabaseMaintenancePortNumber) ? null : Convert.ToInt32(ServiceControlAudit.DatabaseMaintenancePortNumber);
instance.EnableFullTextSearchOnBodies = ServiceControlAudit.EnableFullTextSearchOnBodies.Value;
}
diff --git a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModelValidator.cs b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModelValidator.cs
index 9741d57238..af5ed8f819 100644
--- a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModelValidator.cs
+++ b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlAuditEditViewModelValidator.cs
@@ -40,7 +40,7 @@ public ServiceControlAuditEditViewModelValidator()
.WithMessage(string.Format(Validation.Validations.MSG_MUST_BE_UNIQUE, "Audit Database Maintenance Port"))
.NotEqual(x => x.PortNumber)
.WithMessage(string.Format(Validation.Validations.MSG_PORTS_NOT_EQUAL, "Database Maintenance", "Audit"))
- .When(x => x.SubmitAttempted);
+ .When(x => x.SubmitAttempted && x.DatabaseMaintenancePortNumberRequired);
RuleFor(x => x.LogPath)
.NotEmpty()
diff --git a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditView.xaml b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditView.xaml
index 7565b9c214..122160effd 100644
--- a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditView.xaml
+++ b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditView.xaml
@@ -230,7 +230,8 @@
DisplayMemberPath="Name"
Header="FULL TEXT SEARCH ON MESSAGE BODIES"
ItemsSource="{Binding EnableFullTextSearchOnBodiesOptions}"
- SelectedValue="{Binding EnableFullTextSearchOnBodies}" />
+ SelectedValue="{Binding EnableFullTextSearchOnBodies}"
+ Visibility="{Binding ShowFullTextSearchOnBodies, Converter={StaticResource boolToVis}}" />
PersistenceManifest?.Settings.Any(s => s.Name == ServiceControlSettings.DatabaseMaintenancePort.Name) ?? true;
+
+ public bool ShowFullTextSearchOnBodies => PersistenceManifest?.Settings.Any(s => s.Name == ServiceControlSettings.EnableFullTextSearchOnBodies.Name) ?? true;
+
public void UpdateInstanceFromViewModel(ServiceControlInstance instance)
{
instance.HostName = ServiceControl.HostName;
@@ -40,7 +49,7 @@ public void UpdateInstanceFromViewModel(ServiceControlInstance instance)
instance.ErrorRetentionPeriod = ServiceControl.ErrorRetentionPeriod;
instance.ForwardErrorMessages = ServiceControl.ErrorForwarding.Value;
instance.ConnectionString = ConnectionString;
- instance.DatabaseMaintenancePort = Convert.ToInt32(ServiceControl.DatabaseMaintenancePortNumber);
+ instance.DatabaseMaintenancePort = string.IsNullOrWhiteSpace(ServiceControl.DatabaseMaintenancePortNumber) ? null : Convert.ToInt32(ServiceControl.DatabaseMaintenancePortNumber);
instance.EnableFullTextSearchOnBodies = ServiceControl.EnableFullTextSearchOnBodies.Value;
instance.EnableIntegratedServicePulse = ServiceControl.EnableIntegratedServicePulse.Value;
}
diff --git a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditViewModelValidator.cs b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditViewModelValidator.cs
index 0e9f6bfe8f..e3e6354065 100644
--- a/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditViewModelValidator.cs
+++ b/src/ServiceControl.Config/UI/InstanceEdit/ServiceControlEditViewModelValidator.cs
@@ -44,7 +44,7 @@ public ServiceControlEditViewModelValidator()
.NotEqual(x => x.PortNumber)
.WithMessage(string.Format(Validation.Validations.MSG_PORTS_NOT_EQUAL, "Database Maintenance",
"ServiceControl"))
- .When(x => x.SubmitAttempted);
+ .When(x => x.SubmitAttempted && x.DatabaseMaintenancePortNumberRequired);
RuleFor(x => x.LogPath)
.NotEmpty()
From 9176127f056759397879d8c59dc2d220323445c4 Mon Sep 17 00:00:00 2001
From: Andreas Bednarz <110360248+abparticular@users.noreply.github.com>
Date: Wed, 4 Nov 2026 16:45:09 +1100
Subject: [PATCH 4/5] Add SQL Server and PostgreSQL parameters to the New
cmdlets
New-ServiceControlInstance and New-ServiceControlAuditInstance now accept:
- -StorageType, limited to the storage types that can be installed, which excludes the legacy RavenDB35 type.
- -DatabaseConnectionString, required for SQL Server and PostgreSQL.
- -MessageBodyStorageType, which defaults to FileSystem, with
-MessageBodyPath or the Azure Blob and S3 parameters.
-DBPath and -DatabaseMaintenancePort are required only for RavenDB.
The cmdlets reject a parameter that the chosen storage type or message body storage type does not use.
---
.../NewServiceControlAuditInstance.cs | 95 +++++-
.../NewServiceControlInstance.cs | 95 +++++-
...Control.Management.PowerShell.dll-help.xml | 301 +++++++++++++++++-
.../Validation/PersistenceParameters.cs | 106 ++++++
.../Validation/StorageTypeValuesGenerator.cs | 20 ++
.../Configuration/PersistenceManifestTests.cs | 10 +
.../Instances/ServiceControlPersisters.cs | 3 +
7 files changed, 612 insertions(+), 18 deletions(-)
create mode 100644 src/ServiceControl.Management.PowerShell/Validation/PersistenceParameters.cs
create mode 100644 src/ServiceControl.Management.PowerShell/Validation/StorageTypeValuesGenerator.cs
diff --git a/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/NewServiceControlAuditInstance.cs b/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/NewServiceControlAuditInstance.cs
index 768041419f..381caed9ec 100644
--- a/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/NewServiceControlAuditInstance.cs
+++ b/src/ServiceControl.Management.PowerShell/Cmdlets/AuditInstances/NewServiceControlAuditInstance.cs
@@ -1,6 +1,7 @@
namespace ServiceControl.Management.PowerShell
{
using System;
+ using System.Collections.Generic;
using System.Management.Automation;
using System.Threading;
using System.Threading.Tasks;
@@ -23,11 +24,72 @@ public class NewServiceControlAuditInstance : PSCmdlet
[ValidatePath]
public string InstallPath { get; set; }
- [Parameter(Mandatory = true, HelpMessage = "Specify the directory that will contain the RavenDB database for this Audit Instance")]
+ [Parameter(Mandatory = false, HelpMessage = "Specify the storage type to use. Defaults to RavenDB")]
+ [ValidateSet(typeof(AuditStorageTypeValuesGenerator))]
+ public string StorageType { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the directory that will contain the RavenDB database for this Audit Instance. Required for RavenDB")]
[ValidateNotNullOrEmpty]
[ValidatePath]
public string DBPath { get; set; }
+ [Parameter(Mandatory = false, HelpMessage = "Specify the database connection string. Required for SQLServer and PostgreSQL")]
+ [ValidateNotNullOrEmpty]
+ public string DatabaseConnectionString { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify where message bodies are stored for SQLServer and PostgreSQL. Defaults to FileSystem")]
+ [ValidateSet("FileSystem", "AzureBlob", "S3")]
+ public string MessageBodyStorageType { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the directory that will contain message bodies. Required for FileSystem message body storage")]
+ [ValidateNotNullOrEmpty]
+ [ValidatePath]
+ public string MessageBodyPath { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Azure Blob Storage connection string. Use this or AzureBlobServiceUri")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobConnectionString { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Azure Blob Storage service URI to connect with a managed identity. Use this or AzureBlobConnectionString")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobServiceUri { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the client ID of a user-assigned managed identity")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobManagedIdentityClientId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Microsoft Entra authority host for sovereign clouds")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobAuthorityHost { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Azure Blob Storage container name. Defaults to audit-bodies")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobContainerName { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 bucket name. Required for S3 message body storage")]
+ [ValidateNotNullOrEmpty]
+ public string S3BucketName { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 key prefix. Defaults to audit-bodies/")]
+ [ValidateNotNullOrEmpty]
+ public string S3KeyPrefix { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 region")]
+ [ValidateNotNullOrEmpty]
+ public string S3Region { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the service URL of S3-compatible storage")]
+ [ValidateNotNullOrEmpty]
+ public string S3ServiceUrl { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 access key ID. Use with S3SecretAccessKey, or omit both to use the default AWS credentials")]
+ [ValidateNotNullOrEmpty]
+ public string S3AccessKeyId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 secret access key. Use with S3AccessKeyId")]
+ [ValidateNotNullOrEmpty]
+ public string S3SecretAccessKey { get; set; }
+
[Parameter(Mandatory = true, HelpMessage = "Specify the directory to use for this Audit Instance Logs")]
[ValidateNotNullOrEmpty]
[ValidatePath]
@@ -41,9 +103,9 @@ public class NewServiceControlAuditInstance : PSCmdlet
[ValidateRange(1, 49151)]
public int Port { get; set; }
- [Parameter(Mandatory = true, HelpMessage = "Specify the database maintenance port number to listen on")]
+ [Parameter(Mandatory = false, HelpMessage = "Specify the database maintenance port number to listen on. Required for RavenDB")]
[ValidateRange(1, 49151)]
- public int DatabaseMaintenancePort { get; set; }
+ public int? DatabaseMaintenancePort { get; set; }
[Parameter(Mandatory = false, HelpMessage = "Specify AuditQueue name to consume messages from. Default is audit")]
[ValidateNotNullOrEmpty]
@@ -88,7 +150,7 @@ public class NewServiceControlAuditInstance : PSCmdlet
[Parameter(Mandatory = false, HelpMessage = "Do not automatically create queues")]
public SwitchParameter SkipQueueCreation { get; set; }
- [Parameter(Mandatory = false, HelpMessage = "Specify whether to enable full text search on audit messages.")]
+ [Parameter(Mandatory = false, HelpMessage = "Specify whether to enable full text search on audit message bodies. Only used when StorageType is RavenDB.")]
public SwitchParameter EnableFullTextSearchOnBodies { get; set; } = true;
[Parameter(Mandatory = false, HelpMessage = "Reuse the specified log, db, and install paths even if they are not empty")]
@@ -99,6 +161,24 @@ public class NewServiceControlAuditInstance : PSCmdlet
protected override void BeginProcessing()
{
+ persistenceManifest = ServiceControlPersisters.GetAuditPersistence(string.IsNullOrWhiteSpace(StorageType) ? StorageEngineNames.RavenDB : StorageType);
+ PersistenceParameters.Validate(persistenceManifest, DBPath, DatabaseMaintenancePort, DatabaseConnectionString, MyInvocation.BoundParameters.ContainsKey(nameof(EnableFullTextSearchOnBodies)));
+ messageBodyStorage = PersistenceParameters.CreateMessageBodyStorage(persistenceManifest, MessageBodyStorageType, new Dictionary
+ {
+ [nameof(MessageBodyPath)] = MessageBodyPath,
+ [nameof(AzureBlobConnectionString)] = AzureBlobConnectionString,
+ [nameof(AzureBlobServiceUri)] = AzureBlobServiceUri,
+ [nameof(AzureBlobManagedIdentityClientId)] = AzureBlobManagedIdentityClientId,
+ [nameof(AzureBlobAuthorityHost)] = AzureBlobAuthorityHost,
+ [nameof(AzureBlobContainerName)] = AzureBlobContainerName,
+ [nameof(S3BucketName)] = S3BucketName,
+ [nameof(S3KeyPrefix)] = S3KeyPrefix,
+ [nameof(S3Region)] = S3Region,
+ [nameof(S3ServiceUrl)] = S3ServiceUrl,
+ [nameof(S3AccessKeyId)] = S3AccessKeyId,
+ [nameof(S3SecretAccessKey)] = S3SecretAccessKey
+ });
+
var transport = ServiceControlCoreTransports.Find(Transport);
var requiresConnectionString = !string.IsNullOrEmpty(transport.SampleConnectionString);
@@ -140,7 +220,7 @@ protected override void BeginProcessing()
protected override void ProcessRecord()
{
- var newAuditInstance = ServiceControlAuditNewInstance.CreateWithDefaultPersistence();
+ var newAuditInstance = ServiceControlAuditNewInstance.CreateWithPersistence(persistenceManifest.Name);
newAuditInstance.InstallPath = InstallPath;
newAuditInstance.LogPath = LogPath;
@@ -154,6 +234,8 @@ protected override void ProcessRecord()
newAuditInstance.HostName = HostName;
newAuditInstance.Port = Port;
newAuditInstance.DatabaseMaintenancePort = DatabaseMaintenancePort;
+ newAuditInstance.PersistenceConnectionString = DatabaseConnectionString;
+ newAuditInstance.MessageBodyStorage = messageBodyStorage;
newAuditInstance.AuditQueue = AuditQueue;
newAuditInstance.AuditLogQueue = string.IsNullOrWhiteSpace(AuditLogQueue) ? null : AuditLogQueue;
newAuditInstance.ForwardAuditMessages = ForwardAuditMessages.ToBool();
@@ -202,6 +284,9 @@ protected override void ProcessRecord()
}
}
+ PersistenceManifest persistenceManifest;
+ MessageBodyStorage messageBodyStorage;
+
Task PromptToProceed(PathInfo pathInfo, CancellationToken cancellationToken)
{
if (!pathInfo.CheckIfEmpty)
diff --git a/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/NewServiceControlInstance.cs b/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/NewServiceControlInstance.cs
index eb2e792fcf..6ee174fa12 100644
--- a/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/NewServiceControlInstance.cs
+++ b/src/ServiceControl.Management.PowerShell/Cmdlets/ServiceControlInstances/NewServiceControlInstance.cs
@@ -1,6 +1,7 @@
namespace ServiceControl.Management.PowerShell
{
using System;
+ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
@@ -25,11 +26,72 @@ public class NewServiceControlInstance : PSCmdlet
[ValidatePath]
public string InstallPath { get; set; }
- [Parameter(Mandatory = true, HelpMessage = "Specify the directory that will contain the RavenDB database for this ServiceControl Instance")]
+ [Parameter(Mandatory = false, HelpMessage = "Specify the storage type to use. Defaults to RavenDB")]
+ [ValidateSet(typeof(PrimaryStorageTypeValuesGenerator))]
+ public string StorageType { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the directory that will contain the RavenDB database for this ServiceControl Instance. Required for RavenDB")]
[ValidateNotNullOrEmpty]
[ValidatePath]
public string DBPath { get; set; }
+ [Parameter(Mandatory = false, HelpMessage = "Specify the database connection string. Required for SQLServer and PostgreSQL")]
+ [ValidateNotNullOrEmpty]
+ public string DatabaseConnectionString { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify where message bodies are stored for SQLServer and PostgreSQL. Defaults to FileSystem")]
+ [ValidateSet("FileSystem", "AzureBlob", "S3")]
+ public string MessageBodyStorageType { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the directory that will contain message bodies. Required for FileSystem message body storage")]
+ [ValidateNotNullOrEmpty]
+ [ValidatePath]
+ public string MessageBodyPath { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Azure Blob Storage connection string. Use this or AzureBlobServiceUri")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobConnectionString { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Azure Blob Storage service URI to connect with a managed identity. Use this or AzureBlobConnectionString")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobServiceUri { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the client ID of a user-assigned managed identity")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobManagedIdentityClientId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Microsoft Entra authority host for sovereign clouds")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobAuthorityHost { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the Azure Blob Storage container name. Defaults to error-bodies")]
+ [ValidateNotNullOrEmpty]
+ public string AzureBlobContainerName { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 bucket name. Required for S3 message body storage")]
+ [ValidateNotNullOrEmpty]
+ public string S3BucketName { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 key prefix. Defaults to error-bodies/")]
+ [ValidateNotNullOrEmpty]
+ public string S3KeyPrefix { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 region")]
+ [ValidateNotNullOrEmpty]
+ public string S3Region { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the service URL of S3-compatible storage")]
+ [ValidateNotNullOrEmpty]
+ public string S3ServiceUrl { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 access key ID. Use with S3SecretAccessKey, or omit both to use the default AWS credentials")]
+ [ValidateNotNullOrEmpty]
+ public string S3AccessKeyId { get; set; }
+
+ [Parameter(Mandatory = false, HelpMessage = "Specify the S3 secret access key. Use with S3AccessKeyId")]
+ [ValidateNotNullOrEmpty]
+ public string S3SecretAccessKey { get; set; }
+
[Parameter(Mandatory = true, HelpMessage = "Specify the directory to use for this ServiceControl Logs")]
[ValidateNotNullOrEmpty]
[ValidatePath]
@@ -43,9 +105,9 @@ public class NewServiceControlInstance : PSCmdlet
[ValidateRange(1, 49151)]
public int Port { get; set; }
- [Parameter(Mandatory = true, HelpMessage = "Specify the database maintenance port number to listen on. If this is the only ServiceControl instance then 33334 is recommended")]
+ [Parameter(Mandatory = false, HelpMessage = "Specify the database maintenance port number to listen on. If this is the only ServiceControl instance then 33334 is recommended. Required for RavenDB")]
[ValidateRange(1, 49151)]
- public int DatabaseMaintenancePort { get; set; }
+ public int? DatabaseMaintenancePort { get; set; }
[Parameter(Mandatory = false, HelpMessage = "Specify ErrorQueue name to consume messages from. Default is error")]
[ValidateNotNullOrEmpty]
@@ -92,7 +154,7 @@ public class NewServiceControlInstance : PSCmdlet
[Parameter(Mandatory = false, HelpMessage = "Do not automatically create queues")]
public SwitchParameter SkipQueueCreation { get; set; }
- [Parameter(Mandatory = false, HelpMessage = "Specify whether to enable full text search on error messages.")]
+ [Parameter(Mandatory = false, HelpMessage = "Specify whether to enable full text search on error message bodies. Only used when StorageType is RavenDB.")]
public SwitchParameter EnableFullTextSearchOnBodies { get; set; } = true;
[Parameter(Mandatory = false, HelpMessage = "Specify whether to enable integrated ServicePulse instance.")]
@@ -106,6 +168,24 @@ public class NewServiceControlInstance : PSCmdlet
protected override void BeginProcessing()
{
+ persistenceManifest = ServiceControlPersisters.GetPrimaryPersistence(string.IsNullOrWhiteSpace(StorageType) ? StorageEngineNames.RavenDB : StorageType);
+ PersistenceParameters.Validate(persistenceManifest, DBPath, DatabaseMaintenancePort, DatabaseConnectionString, MyInvocation.BoundParameters.ContainsKey(nameof(EnableFullTextSearchOnBodies)));
+ messageBodyStorage = PersistenceParameters.CreateMessageBodyStorage(persistenceManifest, MessageBodyStorageType, new Dictionary
+ {
+ [nameof(MessageBodyPath)] = MessageBodyPath,
+ [nameof(AzureBlobConnectionString)] = AzureBlobConnectionString,
+ [nameof(AzureBlobServiceUri)] = AzureBlobServiceUri,
+ [nameof(AzureBlobManagedIdentityClientId)] = AzureBlobManagedIdentityClientId,
+ [nameof(AzureBlobAuthorityHost)] = AzureBlobAuthorityHost,
+ [nameof(AzureBlobContainerName)] = AzureBlobContainerName,
+ [nameof(S3BucketName)] = S3BucketName,
+ [nameof(S3KeyPrefix)] = S3KeyPrefix,
+ [nameof(S3Region)] = S3Region,
+ [nameof(S3ServiceUrl)] = S3ServiceUrl,
+ [nameof(S3AccessKeyId)] = S3AccessKeyId,
+ [nameof(S3SecretAccessKey)] = S3SecretAccessKey
+ });
+
var transport = ServiceControlCoreTransports.Find(Transport);
var requiresConnectionString = !string.IsNullOrEmpty(transport.SampleConnectionString);
@@ -153,7 +233,7 @@ protected override void BeginProcessing()
protected override void ProcessRecord()
{
- var details = ServiceControlNewInstance.CreateWithDefaultPersistence();
+ var details = ServiceControlNewInstance.CreateWithPersistence(persistenceManifest.Name);
details.InstallPath = InstallPath;
details.LogPath = LogPath;
@@ -167,6 +247,8 @@ protected override void ProcessRecord()
details.HostName = HostName;
details.Port = Port;
details.DatabaseMaintenancePort = DatabaseMaintenancePort;
+ details.PersistenceConnectionString = DatabaseConnectionString;
+ details.MessageBodyStorage = messageBodyStorage;
details.VirtualDirectory = VirtualDirectory;
details.ErrorQueue = ErrorQueue;
details.ErrorLogQueue = string.IsNullOrWhiteSpace(ErrorLogQueue) ? null : ErrorLogQueue;
@@ -229,6 +311,9 @@ protected override void ProcessRecord()
}
}
+ PersistenceManifest persistenceManifest;
+ MessageBodyStorage messageBodyStorage;
+
Task PromptToProceed(PathInfo pathInfo, CancellationToken cancellationToken)
{
if (!pathInfo.CheckIfEmpty)
diff --git a/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml b/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml
index f14ff3d98f..88987ca3dc 100644
--- a/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml
+++ b/src/ServiceControl.Management.PowerShell/ServiceControl.Management.PowerShell.dll-help.xml
@@ -2514,10 +2514,10 @@
String
-
+
DBPath
- The directory that will contain the embedded database files for this ServiceControl Instance. This should be unique for each instance of ServiceControl and should not be a subfolder of the InstallPath or LogPath.
+ The directory that will contain the embedded database files for this ServiceControl Instance. This should be unique for each instance of ServiceControl and should not be a subfolder of the InstallPath or LogPath. Required when StorageType is RavenDB, and not used otherwise.
String
@@ -2548,13 +2548,118 @@
Int32
-
+
DatabaseMaintenancePort
-
+ The port the embedded RavenDB database listens on. If this is the only ServiceControl instance then 33334 is recommended. Required when StorageType is RavenDB, and not used otherwise.
Int32
+
+ StorageType
+
+ The storage type to use: RavenDB, SQLServer, or PostgreSQL.
+
+ String
+
+
+ DatabaseConnectionString
+
+ The connection string of the database that stores the instance data. Required when StorageType is SQLServer or PostgreSQL, and not used otherwise.
+
+ String
+
+
+ MessageBodyStorageType
+
+ Where message bodies are stored when StorageType is SQLServer or PostgreSQL: FileSystem, AzureBlob, or S3. Not used for RavenDB, which stores message bodies in its database.
+
+ String
+
+
+ MessageBodyPath
+
+ The directory that will contain message bodies. Required when MessageBodyStorageType is FileSystem. This should be unique for each instance of ServiceControl and should not be a subfolder of the InstallPath or LogPath.
+
+ String
+
+
+ AzureBlobConnectionString
+
+ The Azure Blob Storage connection string, using a shared key or SAS token. Specify this or AzureBlobServiceUri when MessageBodyStorageType is AzureBlob.
+
+ String
+
+
+ AzureBlobServiceUri
+
+ The Azure Blob Storage service URI, for example https://account.blob.core.windows.net, to connect with a managed identity. Specify this or AzureBlobConnectionString when MessageBodyStorageType is AzureBlob.
+
+ String
+
+
+ AzureBlobManagedIdentityClientId
+
+ The client ID of a user-assigned managed identity. Used with AzureBlobServiceUri. Leave empty to use the system-assigned identity.
+
+ String
+
+
+ AzureBlobAuthorityHost
+
+ The Microsoft Entra authority host for sovereign clouds. Used with AzureBlobServiceUri.
+
+ String
+
+
+ AzureBlobContainerName
+
+ The Azure Blob Storage container that stores message bodies.
+
+ String
+
+
+ S3BucketName
+
+ The S3 bucket that stores message bodies. Required when MessageBodyStorageType is S3.
+
+ String
+
+
+ S3KeyPrefix
+
+ The prefix for message body object keys in the S3 bucket.
+
+ String
+
+
+ S3Region
+
+ The AWS region of the S3 bucket.
+
+ String
+
+
+ S3ServiceUrl
+
+ The service URL of S3-compatible storage. Leave empty to use Amazon S3.
+
+ String
+
+
+ S3AccessKeyId
+
+ The S3 access key ID. Specify with S3SecretAccessKey, or omit both to use the default AWS credentials, such as an IAM role.
+
+ String
+
+
+ S3SecretAccessKey
+
+ The S3 secret access key. Specify with S3AccessKeyId.
+
+ String
+
ErrorQueue
@@ -2696,10 +2801,10 @@
-
+
DBPath
- The directory that will contain the embedded database files for this ServiceControl Instance. This should be unique for each instance of ServiceControl and should not be a subfolder of the InstallPath or LogPath.
+ The directory that will contain the embedded database files for this ServiceControl Instance. This should be unique for each instance of ServiceControl and should not be a subfolder of the InstallPath or LogPath. Required when StorageType is RavenDB, and not used otherwise.
String
@@ -2750,10 +2855,10 @@
-
+
DatabaseMaintenancePort
-
+ The port the embedded RavenDB database listens on. If this is the only ServiceControl instance then 33334 is recommended. Required when StorageType is RavenDB, and not used otherwise.
Int32
@@ -2762,6 +2867,186 @@
+
+ StorageType
+
+ The storage type to use: RavenDB, SQLServer, or PostgreSQL.
+
+ String
+
+ String
+
+
+ RavenDB
+
+
+ DatabaseConnectionString
+
+ The connection string of the database that stores the instance data. Required when StorageType is SQLServer or PostgreSQL, and not used otherwise.
+
+ String
+
+ String
+
+
+
+
+
+ MessageBodyStorageType
+
+ Where message bodies are stored when StorageType is SQLServer or PostgreSQL: FileSystem, AzureBlob, or S3. Not used for RavenDB, which stores message bodies in its database.
+
+ String
+
+ String
+
+
+ FileSystem
+
+
+ MessageBodyPath
+
+ The directory that will contain message bodies. Required when MessageBodyStorageType is FileSystem. This should be unique for each instance of ServiceControl and should not be a subfolder of the InstallPath or LogPath.
+
+ String
+
+ String
+
+
+
+
+
+ AzureBlobConnectionString
+
+ The Azure Blob Storage connection string, using a shared key or SAS token. Specify this or AzureBlobServiceUri when MessageBodyStorageType is AzureBlob.
+
+ String
+
+ String
+
+
+
+
+
+ AzureBlobServiceUri
+
+ The Azure Blob Storage service URI, for example https://account.blob.core.windows.net, to connect with a managed identity. Specify this or AzureBlobConnectionString when MessageBodyStorageType is AzureBlob.
+
+ String
+
+ String
+
+
+
+
+
+ AzureBlobManagedIdentityClientId
+
+ The client ID of a user-assigned managed identity. Used with AzureBlobServiceUri. Leave empty to use the system-assigned identity.
+
+ String
+
+ String
+
+
+
+
+
+ AzureBlobAuthorityHost
+
+ The Microsoft Entra authority host for sovereign clouds. Used with AzureBlobServiceUri.
+
+ String
+
+ String
+
+
+
+
+
+ AzureBlobContainerName
+
+ The Azure Blob Storage container that stores message bodies.
+
+ String
+
+ String
+
+
+ error-bodies
+
+
+ S3BucketName
+
+ The S3 bucket that stores message bodies. Required when MessageBodyStorageType is S3.
+
+ String
+
+ String
+
+
+
+
+
+ S3KeyPrefix
+
+ The prefix for message body object keys in the S3 bucket.
+
+ String
+
+ String
+
+
+ error-bodies/
+
+
+ S3Region
+
+ The AWS region of the S3 bucket.
+
+ String
+
+ String
+
+
+
+
+
+ S3ServiceUrl
+
+ The service URL of S3-compatible storage. Leave empty to use Amazon S3.
+
+ String
+
+ String
+
+
+
+
+
+ S3AccessKeyId
+
+ The S3 access key ID. Specify with S3SecretAccessKey, or omit both to use the default AWS credentials, such as an IAM role.
+
+ String
+
+ String
+
+
+
+
+
+ S3SecretAccessKey
+
+ The S3 secret access key. Specify with S3AccessKeyId.
+
+ String
+
+ String
+
+
+
+
ErrorQueue
diff --git a/src/ServiceControl.Management.PowerShell/Validation/PersistenceParameters.cs b/src/ServiceControl.Management.PowerShell/Validation/PersistenceParameters.cs
new file mode 100644
index 0000000000..a2ed0fd3df
--- /dev/null
+++ b/src/ServiceControl.Management.PowerShell/Validation/PersistenceParameters.cs
@@ -0,0 +1,106 @@
+namespace ServiceControl.Management.PowerShell.Validation
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
+ using ServiceControlInstaller.Engine.Instances;
+
+ // Checks that only the parameters the selected storage type uses are given, like the transport connection string check.
+ static class PersistenceParameters
+ {
+ public static void Validate(PersistenceManifest manifest, string dbPath, int? databaseMaintenancePort, string databaseConnectionString, bool enableFullTextSearchOnBodiesSpecified)
+ {
+ Check(manifest, "DBPath", manifest.Settings.Any(s => s.Name.EndsWith("/DBPath")), !string.IsNullOrWhiteSpace(dbPath));
+ Check(manifest, "DatabaseMaintenancePort", manifest.Settings.Any(s => s.Name.EndsWith("/DatabaseMaintenancePort")), databaseMaintenancePort.HasValue);
+ Check(manifest, "DatabaseConnectionString", manifest.Settings.Any(s => s.Mandatory && s.Name.EndsWith("/Database/ConnectionString")), !string.IsNullOrWhiteSpace(databaseConnectionString));
+
+ if (enableFullTextSearchOnBodiesSpecified && !manifest.Settings.Any(s => s.Name.EndsWith("/EnableFullTextSearchOnBodies")))
+ {
+ throw new Exception($"The '{manifest.Name}' storage type does not use EnableFullTextSearchOnBodies.");
+ }
+ }
+
+ // Returns null for storage types that keep message bodies in the database.
+ public static MessageBodyStorage CreateMessageBodyStorage(PersistenceManifest manifest, string storageTypeName, IReadOnlyDictionary parameters)
+ {
+ var supportsBodyStorage = manifest.Settings.Any(s => s.Name.EndsWith("/MessageBody/StorageType"));
+ var specified = parameters.Where(p => !string.IsNullOrWhiteSpace(p.Value)).Select(p => p.Key).ToList();
+
+ if (!supportsBodyStorage)
+ {
+ if (storageTypeName != null)
+ {
+ specified.Insert(0, "MessageBodyStorageType");
+ }
+
+ if (specified.Count > 0)
+ {
+ throw new Exception($"The '{manifest.Name}' storage type does not use {string.Join(", ", specified)}.");
+ }
+
+ return null;
+ }
+
+ var storageType = storageTypeName == null ? MessageBodyStorageType.FileSystem : Enum.Parse(storageTypeName);
+ var parameterPrefix = storageType switch
+ {
+ MessageBodyStorageType.FileSystem => "MessageBodyPath",
+ MessageBodyStorageType.AzureBlob => "AzureBlob",
+ MessageBodyStorageType.S3 => "S3",
+ _ => throw new Exception($"Message body storage type '{storageType}' is not supported.")
+ };
+
+ var unused = specified.Where(name => !name.StartsWith(parameterPrefix)).ToList();
+ if (unused.Count > 0)
+ {
+ throw new Exception($"The '{storageType}' message body storage type does not use {string.Join(", ", unused)}.");
+ }
+
+ string Value(string name) => parameters.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null;
+
+ var requiredParameter = storageType switch
+ {
+ MessageBodyStorageType.FileSystem => "MessageBodyPath",
+ MessageBodyStorageType.S3 => "S3BucketName",
+ // Azure Blob needs one of two parameters, which the installer checks.
+ MessageBodyStorageType.AzureBlob => null,
+ _ => null
+ };
+
+ if (requiredParameter != null && Value(requiredParameter) == null)
+ {
+ throw new Exception($"{requiredParameter} is mandatory for the '{storageType}' message body storage type.");
+ }
+
+ return new MessageBodyStorage
+ {
+ StorageType = storageType,
+ FileSystemStoragePath = Value("MessageBodyPath"),
+ AzureConnectionString = Value("AzureBlobConnectionString"),
+ AzureServiceUri = Value("AzureBlobServiceUri"),
+ AzureManagedIdentityClientId = Value("AzureBlobManagedIdentityClientId"),
+ AzureAuthorityHost = Value("AzureBlobAuthorityHost"),
+ AzureContainerName = Value("AzureBlobContainerName"),
+ S3BucketName = Value("S3BucketName"),
+ S3KeyPrefix = Value("S3KeyPrefix"),
+ S3Region = Value("S3Region"),
+ S3ServiceUrl = Value("S3ServiceUrl"),
+ S3AccessKeyId = Value("S3AccessKeyId"),
+ S3SecretAccessKey = Value("S3SecretAccessKey")
+ };
+ }
+
+ static void Check(PersistenceManifest manifest, string parameterName, bool isUsed, bool isSpecified)
+ {
+ if (isUsed && !isSpecified)
+ {
+ throw new Exception($"{parameterName} is mandatory for the '{manifest.Name}' storage type.");
+ }
+
+ if (!isUsed && isSpecified)
+ {
+ throw new Exception($"The '{manifest.Name}' storage type does not use {parameterName}.");
+ }
+ }
+ }
+}
diff --git a/src/ServiceControl.Management.PowerShell/Validation/StorageTypeValuesGenerator.cs b/src/ServiceControl.Management.PowerShell/Validation/StorageTypeValuesGenerator.cs
new file mode 100644
index 0000000000..ae7133a71e
--- /dev/null
+++ b/src/ServiceControl.Management.PowerShell/Validation/StorageTypeValuesGenerator.cs
@@ -0,0 +1,20 @@
+namespace ServiceControl.Management.PowerShell.Validation
+{
+ using System.Linq;
+ using System.Management.Automation;
+ using ServiceControlInstaller.Engine.Instances;
+
+ public class PrimaryStorageTypeValuesGenerator : IValidateSetValuesGenerator
+ {
+ public string[] GetValidValues() => ServiceControlPersisters.GetSupportedPrimaryManifests()
+ .Select(m => m.Name)
+ .ToArray();
+ }
+
+ public class AuditStorageTypeValuesGenerator : IValidateSetValuesGenerator
+ {
+ public string[] GetValidValues() => ServiceControlPersisters.GetSupportedAuditManifests()
+ .Select(m => m.Name)
+ .ToArray();
+ }
+}
diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Configuration/PersistenceManifestTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/PersistenceManifestTests.cs
index fe91cd2449..e7b0112f56 100644
--- a/src/ServiceControlInstaller.Engine.UnitTests/Configuration/PersistenceManifestTests.cs
+++ b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/PersistenceManifestTests.cs
@@ -56,6 +56,16 @@ public void Raven35IsDefault()
Assert.That(manifests.All(m => m.Name == "RavenDB35"));
}
+ [Test]
+ public void SupportedManifestsExcludeLegacyRavenDB35()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(ServiceControlPersisters.GetSupportedPrimaryManifests().Select(m => m.Name), Is.EquivalentTo(new[] { "PostgreSQL", "RavenDB", "SQLServer" }));
+ Assert.That(ServiceControlPersisters.GetSupportedAuditManifests().Select(m => m.Name), Is.EquivalentTo(new[] { "PostgreSQL", "RavenDB", "SQLServer" }));
+ });
+ }
+
[Test]
public void UnknownPersister()
{
diff --git a/src/ServiceControlInstaller.Engine/Instances/ServiceControlPersisters.cs b/src/ServiceControlInstaller.Engine/Instances/ServiceControlPersisters.cs
index 85edb819f0..a9d6fe8492 100644
--- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlPersisters.cs
+++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlPersisters.cs
@@ -35,6 +35,9 @@ static ServiceControlPersisters()
public static PersistenceManifest GetPrimaryPersistence(string name) => GetPersistenceManifest(primaryPersistenceManifests, name);
public static PersistenceManifest GetAuditPersistence(string name) => GetPersistenceManifest(auditPersistenceManifests, name);
+ public static PersistenceManifest[] GetSupportedPrimaryManifests() => primaryPersistenceManifests.Where(m => m.IsSupported).ToArray();
+ public static PersistenceManifest[] GetSupportedAuditManifests() => auditPersistenceManifests.Where(m => m.IsSupported).ToArray();
+
static PersistenceManifest GetPersistenceManifest(PersistenceManifest[] manifestCollection, string name)
{
if (string.IsNullOrEmpty(name))
From 7f11b252a933f883ef58cdb37fd797d516f70ee5 Mon Sep 17 00:00:00 2001
From: Andreas Bednarz <110360248+abparticular@users.noreply.github.com>
Date: Wed, 4 Nov 2026 18:42:16 +1100
Subject: [PATCH 5/5] Choose SQL Server or PostgreSQL in the SCMU Add Instance
wizard
The Add Instance wizard now shows a storage type picker for the storage types that can be installed, which excludes the legacy RavenDB35 type. The Error and
Audit instances use the same storage type.
For SQL Server and PostgreSQL, the wizard asks for a database connection string and hides the RavenDB database path and maintenance port fields. When both instances are added, the Audit instance uses the same connection string as the Error instance, unless the user enters a different one.
Both instances store message bodies on the file system. The wizard shows only the path, which defaults to %ProgramData%\Particular\ServiceControl\\MessageBodies.
---
.../ServiceControlAddScreenLoadedTests.cs | 162 ++++++++++++++++++
.../AddAuditInstanceValidationTests.cs | 129 ++++++++++++++
.../AddErrorInstanceValidationTests.cs | 91 ++++++++++
.../InstanceAdd/MessageBodyStorageOptions.cs | 30 ++++
.../ServiceControlAddAttachment.cs | 16 +-
.../UI/InstanceAdd/ServiceControlAddView.xaml | 105 ++++++++++--
.../InstanceAdd/ServiceControlAddViewModel.cs | 117 +++++++++++++
.../ServiceControlAddViewModelValidator.cs | 37 +++-
.../ServiceControlAuditInformation.cs | 35 ++++
.../InstanceAdd/ServiceControlInformation.cs | 35 ++++
.../Validation/Validations.cs | 2 +
11 files changed, 733 insertions(+), 26 deletions(-)
create mode 100644 src/ServiceControl.Config/UI/InstanceAdd/MessageBodyStorageOptions.cs
diff --git a/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs b/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs
index cf18a3c496..e4515fb079 100644
--- a/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs
+++ b/src/ServiceControl.Config.Tests/ServiceControlAddScreenLoadedTests.cs
@@ -37,6 +37,168 @@ public void Transports_are_populated()
}
}
+ [Test]
+ public void Storage_types_are_offered_with_no_default_selection()
+ {
+ var viewModel = new ServiceControlAddViewModel();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.StorageTypes.Select(m => m.Name), Is.EquivalentTo(new[] { "PostgreSQL", "RavenDB", "SQLServer" }));
+ Assert.That(viewModel.SelectedStorageType, Is.Null);
+ Assert.That(viewModel.ShowStorageTypePicker, Is.True);
+ Assert.That(viewModel.ErrorSelectedStorageType, Is.Null);
+ Assert.That(viewModel.ShowErrorStorageConnectionString, Is.False);
+ Assert.That(viewModel.AuditSelectedStorageType, Is.Null);
+ Assert.That(viewModel.ShowAuditStorageConnectionString, Is.False);
+ }
+ }
+
+ [TestCase("RavenDB")]
+ [TestCase("SQLServer")]
+ [TestCase("PostgreSQL")]
+ public void Selecting_a_storage_type_applies_it_to_both_instances(string storageTypeName)
+ {
+ var viewModel = new ServiceControlAddViewModel();
+ var raisedPropertyNames = new System.Collections.Generic.HashSet();
+ viewModel.PropertyChanged += (_, e) => raisedPropertyNames.Add(e.PropertyName);
+
+ viewModel.SelectedStorageType = viewModel.StorageTypes.Single(m => m.Name == storageTypeName);
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ErrorSelectedStorageType, Is.SameAs(ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence(storageTypeName)));
+ Assert.That(viewModel.AuditSelectedStorageType, Is.SameAs(ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetAuditPersistence(storageTypeName)));
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.SelectedStorageType)));
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.ShowAuditStorageSettings)));
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.ShowAuditStorageConnectionStringEntry)));
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.ShowStorageConnectionString)));
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.ShowStorageSettings)));
+ }
+ }
+
+ [Test]
+ public void Changing_storage_type_notifies_bound_visibility_properties()
+ {
+ var viewModel = new ServiceControlAddViewModel();
+ var raisedPropertyNames = new System.Collections.Generic.HashSet();
+ viewModel.PropertyChanged += (_, e) => raisedPropertyNames.Add(e.PropertyName);
+
+ viewModel.ErrorSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence("SQLServer");
+
+ using (Assert.EnterMultipleScope())
+ {
+ // These are what the XAML's Visibility bindings actually listen to - if WPF never
+ // hears these changed, the connection string box silently never appears/disappears,
+ // even though the underlying ErrorSelectedStorageType value is correct.
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.ShowErrorStorageConnectionString)));
+ Assert.That(raisedPropertyNames, Does.Contain(nameof(viewModel.ShowErrorDatabasePathFields)));
+ Assert.That(viewModel.ShowErrorStorageConnectionString, Is.True);
+ }
+ }
+
+ [TestCase("RavenDB", true)]
+ [TestCase("SQLServer", false)]
+ [TestCase("PostgreSQL", false)]
+ public void Database_maintenance_port_is_shown_only_for_RavenDB(string storageTypeName, bool expected)
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ ErrorSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence(storageTypeName),
+ AuditSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetAuditPersistence(storageTypeName)
+ };
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowErrorDatabaseMaintenancePort, Is.EqualTo(expected));
+ Assert.That(viewModel.ShowAuditDatabaseMaintenancePort, Is.EqualTo(expected));
+ }
+ }
+
+ [TestCase("RavenDB", true)]
+ [TestCase("SQLServer", false)]
+ [TestCase("PostgreSQL", false)]
+ public void Full_text_search_option_is_shown_only_for_RavenDB(string storageTypeName, bool expected)
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ ErrorSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence(storageTypeName),
+ AuditSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetAuditPersistence(storageTypeName)
+ };
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowErrorFullTextSearchOnBodies, Is.EqualTo(expected));
+ Assert.That(viewModel.ShowAuditFullTextSearchOnBodies, Is.EqualTo(expected));
+ }
+ }
+
+ [Test]
+ public void Message_body_path_is_shown_only_for_storage_types_that_support_it()
+ {
+ var viewModel = new ServiceControlAddViewModel();
+
+ viewModel.ErrorSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence("RavenDB");
+ Assert.That(viewModel.ShowErrorMessageBodyPath, Is.False);
+
+ viewModel.ErrorSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence("SQLServer");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowErrorMessageBodyPath, Is.True);
+ Assert.That(viewModel.ErrorMessageBodyPath, Does.StartWith(programDataPath).And.EndWith("MessageBodies"));
+ }
+ }
+
+ [Test]
+ public void Audit_message_body_storage_is_shown_for_SQL_Server_with_its_own_default_path()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ AuditSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetAuditPersistence("SQLServer")
+ };
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowAuditMessageBodyPath, Is.True);
+ Assert.That(viewModel.AuditMessageBodyPath, Does.StartWith(programDataPath).And.EndWith("MessageBodies"));
+ Assert.That(viewModel.AuditMessageBodyPath, Is.Not.EqualTo(viewModel.ErrorMessageBodyPath));
+ }
+ }
+
+ [Test]
+ public void Both_instances_store_message_bodies_on_the_file_system()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ SelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence("SQLServer"),
+ ErrorMessageBodyPath = @"D:\ErrorBodies",
+ AuditMessageBodyPath = @"D:\AuditBodies"
+ };
+
+ var errorStorage = viewModel.ServiceControl.CreateMessageBodyStorage();
+ var auditStorage = viewModel.ServiceControlAudit.CreateMessageBodyStorage();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(errorStorage.StorageType, Is.EqualTo(ServiceControlInstaller.Engine.Instances.MessageBodyStorageType.FileSystem));
+ Assert.That(errorStorage.FileSystemStoragePath, Is.EqualTo(@"D:\ErrorBodies"));
+ Assert.That(auditStorage.StorageType, Is.EqualTo(ServiceControlInstaller.Engine.Instances.MessageBodyStorageType.FileSystem));
+ Assert.That(auditStorage.FileSystemStoragePath, Is.EqualTo(@"D:\AuditBodies"));
+ }
+ }
+
+ [Test]
+ public void Message_body_storage_is_not_created_for_RavenDB()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ ErrorSelectedStorageType = ServiceControlInstaller.Engine.Instances.ServiceControlPersisters.GetPrimaryPersistence("RavenDB")
+ };
+
+ Assert.That(viewModel.ServiceControl.CreateMessageBodyStorage(), Is.Null);
+ }
+
[Test]
public void Transport_connection_string_is_null()
{
diff --git a/src/ServiceControl.Config.Tests/Validation/AddAuditInstanceValidationTests.cs b/src/ServiceControl.Config.Tests/Validation/AddAuditInstanceValidationTests.cs
index 449208f04a..99896aa476 100644
--- a/src/ServiceControl.Config.Tests/Validation/AddAuditInstanceValidationTests.cs
+++ b/src/ServiceControl.Config.Tests/Validation/AddAuditInstanceValidationTests.cs
@@ -151,6 +151,135 @@ public void
#endregion
+ #region storagetype
+
+ [Test]
+ public void Storage_type_cannot_be_empty_when_adding_audit_instance()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallAuditInstance = true,
+ InstallErrorInstance = false,
+ SubmitAttempted = true,
+ SelectedStorageType = null
+ };
+
+ viewModel.NotifyOfPropertyChange(nameof(viewModel.SelectedStorageType));
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+ var errors = notifyErrorInfo.GetErrors(nameof(viewModel.SelectedStorageType));
+
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ [TestCase("SQLServer")]
+ [TestCase("PostgreSQL")]
+ public void Storage_connection_string_cannot_be_empty_for_storage_types_that_require_one_when_adding_audit_instance(string storageTypeName)
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallAuditInstance = true,
+ InstallErrorInstance = false,
+ SelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence(storageTypeName),
+ SubmitAttempted = true,
+ StorageConnectionString = string.Empty
+ };
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+ var errors = notifyErrorInfo.GetErrors(nameof(viewModel.StorageConnectionString));
+
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ [Test]
+ public void Storage_connection_string_can_be_empty_for_RavenDB_when_adding_audit_instance()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallAuditInstance = true,
+ SelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("RavenDB"),
+ SubmitAttempted = true,
+ StorageConnectionString = string.Empty
+ };
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+ var errors = notifyErrorInfo.GetErrors(nameof(viewModel.StorageConnectionString));
+
+ Assert.That(errors, Is.Empty);
+ }
+
+ [Test]
+ public void Audit_instance_uses_the_shared_connection_string_by_default_when_adding_both_instances()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ InstallAuditInstance = true,
+ SelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("SQLServer"),
+ SubmitAttempted = true,
+ StorageConnectionString = "Server=.;Database=ServiceControl",
+ AuditStorageConnectionString = string.Empty
+ };
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.AuditStorageConnectionString));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowAuditUseSharedStorageConnectionString, Is.True);
+ Assert.That(viewModel.ShowAuditStorageConnectionStringEntry, Is.False);
+ Assert.That(viewModel.AuditStorageConnectionStringToUse, Is.EqualTo("Server=.;Database=ServiceControl"));
+ Assert.That(errors, Is.Empty);
+ }
+ }
+
+ [Test]
+ public void Audit_connection_string_is_required_when_choosing_a_different_one()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ InstallAuditInstance = true,
+ SelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("PostgreSQL"),
+ SubmitAttempted = true,
+ StorageConnectionString = "Host=localhost;Database=error",
+ AuditUseOwnStorageConnectionString = true,
+ AuditStorageConnectionString = string.Empty
+ };
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.AuditStorageConnectionString));
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowAuditStorageConnectionStringEntry, Is.True);
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ viewModel.AuditStorageConnectionString = "Host=localhost;Database=audit";
+
+ Assert.That(viewModel.AuditStorageConnectionStringToUse, Is.EqualTo("Host=localhost;Database=audit"));
+ }
+
+ [Test]
+ public void Audit_instance_uses_the_shared_connection_string_when_added_alone()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = false,
+ InstallAuditInstance = true,
+ SelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("SQLServer"),
+ StorageConnectionString = "Server=.;Database=Audit"
+ };
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(viewModel.ShowAuditUseSharedStorageConnectionString, Is.False);
+ Assert.That(viewModel.ShowAuditStorageConnectionStringEntry, Is.False);
+ Assert.That(viewModel.AuditStorageConnectionStringToUse, Is.EqualTo("Server=.;Database=Audit"));
+ }
+ }
+
+ #endregion
+
#region auditinstancename
[Test]
diff --git a/src/ServiceControl.Config.Tests/Validation/AddErrorInstanceValidationTests.cs b/src/ServiceControl.Config.Tests/Validation/AddErrorInstanceValidationTests.cs
index d81a07c4bd..1619b865dc 100644
--- a/src/ServiceControl.Config.Tests/Validation/AddErrorInstanceValidationTests.cs
+++ b/src/ServiceControl.Config.Tests/Validation/AddErrorInstanceValidationTests.cs
@@ -150,6 +150,97 @@ public void Transport_connection_string_cannot_be_null_if_sample_connection_stri
}
#endregion
+ #region storagetype
+
+ [Test]
+ public void Storage_type_cannot_be_empty_when_adding_error_instance()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ SubmitAttempted = true,
+ SelectedStorageType = null
+ };
+
+ viewModel.NotifyOfPropertyChange(nameof(viewModel.SelectedStorageType));
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+ var errors = notifyErrorInfo.GetErrors(nameof(viewModel.SelectedStorageType));
+
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ [TestCase("SQLServer")]
+ [TestCase("PostgreSQL")]
+ public void Storage_connection_string_cannot_be_empty_for_storage_types_that_require_one_when_adding_error_instance(string storageTypeName)
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ ErrorSelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence(storageTypeName),
+ SubmitAttempted = true,
+ StorageConnectionString = string.Empty
+ };
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+ var errors = notifyErrorInfo.GetErrors(nameof(viewModel.StorageConnectionString));
+
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ [Test]
+ public void Message_body_path_cannot_be_empty_for_file_system_storage()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ ErrorSelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("SQLServer"),
+ SubmitAttempted = true
+ };
+ viewModel.ErrorMessageBodyPath = string.Empty;
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.ErrorMessageBodyPath));
+
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ [Test]
+ public void Error_and_audit_message_body_paths_must_differ()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ InstallAuditInstance = true,
+ ErrorSelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("SQLServer"),
+ AuditSelectedStorageType = ServiceControlPersisters.GetAuditPersistence("SQLServer")
+ };
+ viewModel.ErrorMessageBodyPath = @"C:\ProgramData\Particular\ServiceControl\Shared\MessageBodies";
+ viewModel.AuditMessageBodyPath = @"C:\ProgramData\Particular\ServiceControl\Shared\MessageBodies";
+
+ var errors = GetNotifyErrorInfo(viewModel).GetErrors(nameof(viewModel.AuditMessageBodyPath));
+
+ Assert.That(errors, Is.Not.Empty);
+ }
+
+ [Test]
+ public void Storage_connection_string_can_be_empty_for_RavenDB_when_adding_error_instance()
+ {
+ var viewModel = new ServiceControlAddViewModel
+ {
+ InstallErrorInstance = true,
+ ErrorSelectedStorageType = ServiceControlPersisters.GetPrimaryPersistence("RavenDB"),
+ SubmitAttempted = true,
+ StorageConnectionString = string.Empty
+ };
+
+ var notifyErrorInfo = GetNotifyErrorInfo(viewModel);
+ var errors = notifyErrorInfo.GetErrors(nameof(viewModel.StorageConnectionString));
+
+ Assert.That(errors, Is.Empty);
+ }
+
+ #endregion
+
#region errorinstancename
[Test]
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/MessageBodyStorageOptions.cs b/src/ServiceControl.Config/UI/InstanceAdd/MessageBodyStorageOptions.cs
new file mode 100644
index 0000000000..63aeb9eab9
--- /dev/null
+++ b/src/ServiceControl.Config/UI/InstanceAdd/MessageBodyStorageOptions.cs
@@ -0,0 +1,30 @@
+namespace ServiceControl.Config.UI.InstanceAdd;
+
+using System.Linq;
+using System.Windows.Input;
+using Commands;
+using Extensions;
+using ServiceControlInstaller.Engine.Instances;
+
+// Instances added from the wizard always store message bodies on the file system.
+public class MessageBodyStorageOptions
+{
+ public MessageBodyStorageOptions() => SelectPath = new SelectPathCommand(p => Path = p, isFolderPicker: true, defaultPath: Path);
+
+ public static bool IsSupportedBy(PersistenceManifest manifest) => manifest?.Settings.Any(s => s.Name.EndsWith("/MessageBody/StorageType")) == true;
+
+ public string Path
+ {
+ get => path;
+ set => path = value.SanitizeFilePath();
+ }
+
+ public ICommand SelectPath { get; }
+
+ // Returns null for storage types that keep message bodies in the database.
+ public MessageBodyStorage Create(PersistenceManifest manifest) => IsSupportedBy(manifest)
+ ? new MessageBodyStorage { StorageType = MessageBodyStorageType.FileSystem, FileSystemStoragePath = Path }
+ : null;
+
+ string path;
+}
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs
index 8cf5c88352..39e5a2ba76 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddAttachment.cs
@@ -53,7 +53,7 @@ async Task Add(CancellationToken cancellationToken)
viewModel.InProgress = true;
- var serviceControlNewInstance = viewModel.InstallErrorInstance ? ServiceControlNewInstance.CreateWithDefaultPersistence() : null;
+ var serviceControlNewInstance = viewModel.InstallErrorInstance ? ServiceControlNewInstance.CreateWithPersistence(viewModel.ServiceControl.SelectedStorageType.Name) : null;
if (viewModel.InstallErrorInstance)
{
@@ -61,18 +61,20 @@ async Task Add(CancellationToken cancellationToken)
serviceControlNewInstance.Name = viewModel.ServiceControl.InstanceName.Replace(' ', '.');
serviceControlNewInstance.InstanceName = viewModel.ServiceControl.InstanceName.Replace(' ', '.');
serviceControlNewInstance.ServiceDescription = viewModel.ServiceControl.Description;
- serviceControlNewInstance.DBPath = viewModel.ServiceControl.DatabasePath;
+ serviceControlNewInstance.DBPath = viewModel.ServiceControl.ShowDatabasePathFields ? viewModel.ServiceControl.DatabasePath : null;
+ serviceControlNewInstance.MessageBodyStorage = viewModel.ServiceControl.CreateMessageBodyStorage();
serviceControlNewInstance.LogPath = viewModel.ServiceControl.LogPath;
serviceControlNewInstance.InstallPath = viewModel.ServiceControl.DestinationPath;
serviceControlNewInstance.HostName = viewModel.ServiceControl.HostName;
serviceControlNewInstance.Port = Convert.ToInt32(viewModel.ServiceControl.PortNumber);
- serviceControlNewInstance.DatabaseMaintenancePort = Convert.ToInt32(viewModel.ServiceControl.DatabaseMaintenancePortNumber);
+ serviceControlNewInstance.DatabaseMaintenancePort = viewModel.ShowErrorDatabaseMaintenancePort ? Convert.ToInt32(viewModel.ServiceControl.DatabaseMaintenancePortNumber) : null;
serviceControlNewInstance.VirtualDirectory = null;
serviceControlNewInstance.ErrorQueue = viewModel.ServiceControl.ErrorQueueName;
serviceControlNewInstance.ErrorLogQueue = viewModel.ServiceControl.ErrorForwarding.Value ? viewModel.ServiceControl.ErrorForwardingQueueName : null;
serviceControlNewInstance.ForwardErrorMessages = viewModel.ServiceControl.ErrorForwarding.Value;
serviceControlNewInstance.TransportPackage = viewModel.SelectedTransport;
serviceControlNewInstance.ConnectionString = viewModel.ConnectionString;
+ serviceControlNewInstance.PersistenceConnectionString = viewModel.ServiceControl.StorageConnectionString;
serviceControlNewInstance.ErrorRetentionPeriod = viewModel.ServiceControl.ErrorRetentionPeriod;
serviceControlNewInstance.ServiceAccount = viewModel.ServiceControl.ServiceAccount;
serviceControlNewInstance.ServiceAccountPwd = viewModel.ServiceControl.Password;
@@ -80,24 +82,26 @@ async Task Add(CancellationToken cancellationToken)
serviceControlNewInstance.EnableIntegratedServicePulse = viewModel.ServiceControl.EnableIntegratedServicePulse.Value;
}
- var auditNewInstance = viewModel.InstallAuditInstance ? ServiceControlAuditNewInstance.CreateWithDefaultPersistence() : null;
+ var auditNewInstance = viewModel.InstallAuditInstance ? ServiceControlAuditNewInstance.CreateWithPersistence(viewModel.ServiceControlAudit.SelectedStorageType.Name) : null;
if (viewModel.InstallAuditInstance)
{
auditNewInstance.DisplayName = viewModel.ServiceControlAudit.InstanceName;
auditNewInstance.Name = viewModel.ServiceControlAudit.InstanceName.Replace(' ', '.');
auditNewInstance.InstanceName = viewModel.ServiceControlAudit.InstanceName.Replace(' ', '.');
auditNewInstance.ServiceDescription = viewModel.ServiceControlAudit.Description;
- auditNewInstance.DBPath = viewModel.ServiceControlAudit.DatabasePath;
+ auditNewInstance.DBPath = viewModel.ServiceControlAudit.ShowDatabasePathFields ? viewModel.ServiceControlAudit.DatabasePath : null;
+ auditNewInstance.MessageBodyStorage = viewModel.ServiceControlAudit.CreateMessageBodyStorage();
auditNewInstance.LogPath = viewModel.ServiceControlAudit.LogPath;
auditNewInstance.InstallPath = viewModel.ServiceControlAudit.DestinationPath;
auditNewInstance.HostName = viewModel.ServiceControlAudit.HostName;
auditNewInstance.Port = Convert.ToInt32(viewModel.ServiceControlAudit.PortNumber);
- auditNewInstance.DatabaseMaintenancePort = Convert.ToInt32(viewModel.ServiceControlAudit.DatabaseMaintenancePortNumber);
+ auditNewInstance.DatabaseMaintenancePort = viewModel.ShowAuditDatabaseMaintenancePort ? Convert.ToInt32(viewModel.ServiceControlAudit.DatabaseMaintenancePortNumber) : null;
auditNewInstance.AuditLogQueue = viewModel.ServiceControlAudit.AuditForwarding.Value ? viewModel.ServiceControlAudit.AuditForwardingQueueName : null;
auditNewInstance.AuditQueue = viewModel.ServiceControlAudit.AuditQueueName;
auditNewInstance.ForwardAuditMessages = viewModel.ServiceControlAudit.AuditForwarding.Value;
auditNewInstance.TransportPackage = viewModel.SelectedTransport;
auditNewInstance.ConnectionString = viewModel.ConnectionString;
+ auditNewInstance.PersistenceConnectionString = viewModel.AuditStorageConnectionStringToUse;
auditNewInstance.AuditRetentionPeriod = viewModel.ServiceControlAudit.AuditRetentionPeriod;
auditNewInstance.ServiceAccount = viewModel.ServiceControlAudit.ServiceAccount;
auditNewInstance.ServiceAccountPwd = viewModel.ServiceControlAudit.Password;
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml
index 8b34111dc8..903ee88e24 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddView.xaml
@@ -71,6 +71,29 @@
TextWrapping="Wrap"
Visibility="{Binding ShowConnectionString,
Converter={StaticResource boolToVis}}" />
+
+
+
+
+
+
+
+
+
+
@@ -135,7 +158,9 @@
-
+
+ Text="{Binding ErrorDatabasePath}"
+ Visibility="{Binding ShowErrorDatabasePathFields, Converter={StaticResource boolToVis}}" />
+
+
+ SelectedValue="{Binding ErrorEnableFullTextSearchOnBodies}"
+ Visibility="{Binding ShowErrorFullTextSearchOnBodies, Converter={StaticResource boolToVis}}" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Text="{Binding AuditDatabasePath}"
+ Visibility="{Binding ShowAuditDatabasePathFields, Converter={StaticResource boolToVis}}" />
+
+
-
-
-
-
+
+
+
+
+
+
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs
index 02ddc46a89..4eba84d7be 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModel.cs
@@ -8,6 +8,7 @@ namespace ServiceControl.Config.UI.InstanceAdd
using System.Windows.Input;
using PropertyChanged;
using ServiceControl.Config.Extensions;
+ using ServiceControlInstaller.Engine.Configuration.ServiceControl;
using ServiceControlInstaller.Engine.Instances;
using Validar;
using Xaml.Controls;
@@ -45,6 +46,9 @@ public ServiceControlAddViewModel(Func getWindowsServiceNames = null)
NotifyOfPropertyChange(nameof(ServiceControlQueueAddressOptions));
NotifyOfPropertyChange(nameof(ShowServiceControlQueueAddressSelection));
NotifyOfPropertyChange(nameof(ShowServiceControlQueueAddressEntry));
+ NotifyOfPropertyChange(nameof(ShowAuditUseSharedStorageConnectionString));
+ NotifyOfPropertyChange(nameof(ShowAuditStorageConnectionStringEntry));
+ NotifyOfPropertyChange(nameof(ShowAuditStorageSettings));
}
};
}
@@ -115,6 +119,31 @@ public override void OnSelectedTransportChanged()
NotifyOfPropertyChange(nameof(ConnectionString));
}
+ // Error and Audit have separate manifests for each storage type, matched by name.
+ public IEnumerable StorageTypes => ErrorStorageTypes.Where(manifest => AuditStorageTypes.Any(audit => audit.Name == manifest.Name));
+
+ public PersistenceManifest SelectedStorageType
+ {
+ get => ErrorSelectedStorageType;
+ set
+ {
+ ErrorSelectedStorageType = value;
+ AuditSelectedStorageType = value == null ? null : AuditStorageTypes.Single(audit => audit.Name == value.Name);
+ }
+ }
+
+ public bool ShowStorageTypePicker => StorageTypes.Count() > 1;
+
+ public bool ShowStorageSettings => ShowStorageTypePicker || ShowStorageConnectionString;
+
+ public string StorageConnectionString
+ {
+ get => ErrorStorageConnectionString;
+ set => ErrorStorageConnectionString = value;
+ }
+
+ public bool ShowStorageConnectionString => ShowErrorStorageConnectionString;
+
public string ErrorInstanceName
{
get => ServiceControl.InstanceName;
@@ -127,6 +156,8 @@ protected void OnErrorInstanceNameChanged()
"Particular Software", ErrorInstanceName);
ErrorDatabasePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Particular", "ServiceControl", ErrorInstanceName, "DB");
+ ErrorMessageBodyPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "Particular", "ServiceControl", ErrorInstanceName, "MessageBodies");
ErrorLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Particular", "ServiceControl", ErrorInstanceName, "Logs");
}
@@ -200,6 +231,38 @@ public string ErrorDatabaseMaintenancePortNumber
set => ServiceControl.DatabaseMaintenancePortNumber = value;
}
+ public IEnumerable ErrorStorageTypes => ServiceControl.StorageTypes;
+
+ public PersistenceManifest ErrorSelectedStorageType
+ {
+ get => ServiceControl.SelectedStorageType;
+ set => ServiceControl.SelectedStorageType = value;
+ }
+
+ public string ErrorStorageConnectionString
+ {
+ get => ServiceControl.StorageConnectionString;
+ set => ServiceControl.StorageConnectionString = value;
+ }
+
+ public bool ShowErrorStorageConnectionString => ErrorSelectedStorageType != null && ErrorSelectedStorageType.Settings.Any(s => s.Mandatory && s.Name.EndsWith("/ConnectionString"));
+
+ public bool ShowErrorDatabasePathFields => ErrorSelectedStorageType == null || ErrorSelectedStorageType.Settings.Any(s => s.Name.EndsWith("/DBPath"));
+
+ public bool ShowErrorDatabaseMaintenancePort => ErrorSelectedStorageType == null || ErrorSelectedStorageType.Settings.Any(s => s.Name == ServiceControlSettings.DatabaseMaintenancePort.Name);
+
+ public bool ShowErrorFullTextSearchOnBodies => ErrorSelectedStorageType == null || ErrorSelectedStorageType.Settings.Any(s => s.Name == ServiceControlSettings.EnableFullTextSearchOnBodies.Name);
+
+ public bool ShowErrorMessageBodyPath => MessageBodyStorageOptions.IsSupportedBy(ErrorSelectedStorageType);
+
+ public ICommand ErrorSelectMessageBodyPath => ServiceControl.MessageBodyStorage.SelectPath;
+
+ public string ErrorMessageBodyPath
+ {
+ get => ServiceControl.MessageBodyStorage.Path;
+ set => ServiceControl.MessageBodyStorage.Path = value.SanitizeFilePath();
+ }
+
public ICommand ErrorSelectDestinationPath => ServiceControl.SelectDestinationPath;
public string ErrorDestinationPath
@@ -294,6 +357,8 @@ protected void OnAuditInstanceNameChanged()
"Particular Software", AuditInstanceName);
AuditDatabasePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Particular", "ServiceControl", AuditInstanceName, "DB");
+ AuditMessageBodyPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "Particular", "ServiceControl", AuditInstanceName, "MessageBodies");
AuditLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Particular", "ServiceControl", AuditInstanceName, "Logs");
}
@@ -367,6 +432,58 @@ public string AuditDatabaseMaintenancePortNumber
set => ServiceControlAudit.DatabaseMaintenancePortNumber = value;
}
+ public IEnumerable AuditStorageTypes => ServiceControlAudit.StorageTypes;
+
+ public PersistenceManifest AuditSelectedStorageType
+ {
+ get => ServiceControlAudit.SelectedStorageType;
+ set => ServiceControlAudit.SelectedStorageType = value;
+ }
+
+ public string AuditStorageConnectionString
+ {
+ get => ServiceControlAudit.StorageConnectionString;
+ set => ServiceControlAudit.StorageConnectionString = value;
+ }
+
+ public bool ShowAuditStorageSettings => ShowAuditUseSharedStorageConnectionString;
+
+ public bool AuditUseSharedStorageConnectionString { get; set; } = true;
+
+ public bool AuditUseOwnStorageConnectionString
+ {
+ get => !AuditUseSharedStorageConnectionString;
+ set => AuditUseSharedStorageConnectionString = !value;
+ }
+
+ // An Audit instance added on its own always uses the shared connection string.
+ public bool ShowAuditUseSharedStorageConnectionString => InstallErrorInstance && ShowAuditStorageConnectionString;
+
+ public bool ShowAuditStorageConnectionStringEntry => ShowAuditUseSharedStorageConnectionString && AuditUseOwnStorageConnectionString;
+
+ public string AuditStorageConnectionStringToUse =>
+ !ShowAuditStorageConnectionString ? null
+ : ShowAuditStorageConnectionStringEntry ? AuditStorageConnectionString
+ : StorageConnectionString;
+
+ public bool ShowAuditStorageConnectionString => AuditSelectedStorageType != null && AuditSelectedStorageType.Settings.Any(s => s.Mandatory && s.Name.EndsWith("/ConnectionString"));
+
+ public bool ShowAuditDatabasePathFields => AuditSelectedStorageType == null || AuditSelectedStorageType.Settings.Any(s => s.Name.EndsWith("/DBPath"));
+
+ public bool ShowAuditDatabaseMaintenancePort => AuditSelectedStorageType == null || AuditSelectedStorageType.Settings.Any(s => s.Name == AuditInstanceSettingsList.DatabaseMaintenancePort.Name);
+
+ public bool ShowAuditFullTextSearchOnBodies => AuditSelectedStorageType == null || AuditSelectedStorageType.Settings.Any(s => s.Name == AuditInstanceSettingsList.EnableFullTextSearchOnBodies.Name);
+
+ public bool ShowAuditMessageBodyPath => MessageBodyStorageOptions.IsSupportedBy(AuditSelectedStorageType);
+
+ public ICommand AuditSelectMessageBodyPath => ServiceControlAudit.MessageBodyStorage.SelectPath;
+
+ public string AuditMessageBodyPath
+ {
+ get => ServiceControlAudit.MessageBodyStorage.Path;
+ set => ServiceControlAudit.MessageBodyStorage.Path = value.SanitizeFilePath();
+ }
+
public ICommand AuditSelectDestinationPath => ServiceControlAudit.SelectDestinationPath;
public string AuditDestinationPath
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs
index 50b27017a8..91c32aa82a 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAddViewModelValidator.cs
@@ -46,6 +46,22 @@ public ServiceControlAddViewModelValidator()
.WithMessage("Error instance name is already in use")
.When(viewModel => viewModel.InstallErrorInstance);
+ RuleFor(x => x.SelectedStorageType)
+ .NotEmpty()
+ .When(x => (x.InstallErrorInstance || x.InstallAuditInstance) && x.SubmitAttempted);
+
+ RuleFor(x => x.StorageConnectionString)
+ .NotEmpty()
+ .WithMessage(Validation.Validations.MSG_THIS_STORAGE_TYPE_REQUIRES_A_CONNECTION_STRING)
+ .When(x => (x.InstallErrorInstance || x.InstallAuditInstance) && x.ShowStorageConnectionString && x.SubmitAttempted);
+
+ RuleFor(x => x.ErrorMessageBodyPath)
+ .NotEmpty()
+ .ValidPath()
+ .MustNotBeIn(x => Validations.UsedPaths(x.ErrorInstanceName))
+ .WithMessage(string.Format(Validation.Validations.MSG_MUST_BE_UNIQUE, "Message Body Paths"))
+ .When(x => x.InstallErrorInstance && x.ShowErrorMessageBodyPath);
+
RuleFor(x => x.ErrorServiceAccount)
.NotEmpty()
.When(x => x.InstallErrorInstance && x.SubmitAttempted);
@@ -85,7 +101,7 @@ public ServiceControlAddViewModelValidator()
.WithMessage(string.Format(Validation.Validations.MSG_PORTS_NOT_EQUAL, "Database Maintenance", "Audit"))
.NotEqual(x => x.AuditDatabaseMaintenancePortNumber)
.WithMessage(string.Format(Validation.Validations.MSG_PORTS_NOT_EQUAL, "Database Maintenance", "Audit Database Maintenance"))
- .When(x => x.InstallErrorInstance);
+ .When(x => x.InstallErrorInstance && x.ShowErrorDatabaseMaintenancePort);
RuleFor(x => x.ErrorDestinationPath)
.NotEmpty()
@@ -157,6 +173,23 @@ public ServiceControlAddViewModelValidator()
.WithMessage(string.Format(Validation.Validations.MSG_CANTCONTAINWHITESPACE, "Error instance queue address"))
.When(viewModel => viewModel.InstallAuditInstance && !viewModel.InstallErrorInstance);
+ RuleFor(x => x.AuditStorageConnectionString)
+ .NotEmpty()
+ .WithMessage(Validation.Validations.MSG_THIS_STORAGE_TYPE_REQUIRES_A_CONNECTION_STRING)
+ .When(x => x.InstallAuditInstance && x.ShowAuditStorageConnectionStringEntry && x.SubmitAttempted);
+
+ RuleFor(x => x.AuditMessageBodyPath)
+ .NotEmpty()
+ .ValidPath()
+ .MustNotBeIn(x => Validations.UsedPaths(x.AuditInstanceName))
+ .WithMessage(string.Format(Validation.Validations.MSG_MUST_BE_UNIQUE, "Audit Message Body Paths"))
+ .When(x => x.InstallAuditInstance && x.ShowAuditMessageBodyPath);
+
+ RuleFor(x => x.AuditMessageBodyPath)
+ .NotEqual(x => x.ErrorMessageBodyPath, System.StringComparer.OrdinalIgnoreCase)
+ .WithMessage(string.Format(Validation.Validations.MSG_MUST_BE_UNIQUE, "Message Body Paths"))
+ .When(x => x.InstallAuditInstance && x.ShowAuditMessageBodyPath && x.InstallErrorInstance && x.ShowErrorMessageBodyPath);
+
RuleFor(x => x.AuditServiceAccount)
.NotEmpty()
.When(x => x.InstallAuditInstance && x.SubmitAttempted);
@@ -196,7 +229,7 @@ public ServiceControlAddViewModelValidator()
.WithMessage(string.Format(Validation.Validations.MSG_PORTS_NOT_EQUAL, "Audit Database Maintenance", "ServiceControl"))
.NotEqual(x => x.ErrorDatabaseMaintenancePortNumber)
.WithMessage(string.Format(Validation.Validations.MSG_PORTS_NOT_EQUAL, "Audit Database Maintenance", "ServiceControl Database Maintenance"))
- .When(x => x.InstallAuditInstance);
+ .When(x => x.InstallAuditInstance && x.ShowAuditDatabaseMaintenancePort);
RuleFor(x => x.AuditDestinationPath)
.NotEmpty()
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAuditInformation.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAuditInformation.cs
index 9a8fc02769..6b219c29d9 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAuditInformation.cs
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlAuditInformation.cs
@@ -49,10 +49,44 @@ public ServiceControlAuditInformation(ServiceControlEditorViewModel viewModelPar
DatabaseMaintenancePortNumber = "44445";
EnableFullTextSearchOnBodies = EnableFullTextSearchOnBodiesOptions.First(p => p.Value); //Default to On.
ViewModelParent = viewModelParent;
+
+ // Only auto-select when there's no real choice to make (picker hidden, RavenDB-only);
+ // otherwise leave it unselected so the combo box shows "Please Select...", like transport.
+ if (StorageTypes.Count() == 1)
+ {
+ SelectedStorageType = StorageTypes.First();
+ }
}
public ServiceControlEditorViewModel ViewModelParent { get; }
+ public IEnumerable StorageTypes { get; } = ServiceControlPersisters.GetSupportedAuditManifests();
+
+ // Intentionally not editable after creation - changing storage backend on an existing
+ // instance has no supported data migration path yet, so this is only ever set at Add time.
+ public PersistenceManifest SelectedStorageType
+ {
+ get => selectedStorageType;
+ set
+ {
+ StorageConnectionString = null;
+ selectedStorageType = value;
+ }
+ }
+
+ public string StorageConnectionString { get; set; }
+
+ public bool ShowStorageTypePicker => StorageTypes.Count() > 1;
+
+ public bool ShowStorageConnectionString => SelectedStorageType != null && SelectedStorageType.Settings.Any(s => s.Mandatory && s.Name.EndsWith("/ConnectionString"));
+
+ public bool ShowDatabasePathFields => SelectedStorageType == null || SelectedStorageType.Settings.Any(s => s.Name.EndsWith("/DBPath"));
+
+ public MessageBodyStorageOptions MessageBodyStorage { get; } = new();
+
+ // Returns null for storage types that keep message bodies in the database.
+ public MessageBodyStorage CreateMessageBodyStorage() => MessageBodyStorage.Create(SelectedStorageType);
+
public int MinimumAuditRetentionPeriod => SettingConstants.AuditRetentionPeriodMinInDays;
public int MaximumAuditRetentionPeriod => SettingConstants.AuditRetentionPeriodMaxInDays;
@@ -137,4 +171,5 @@ public void UpdateFromInstance(ServiceControlAuditInstance instance)
EnableFullTextSearchOnBodies = EnableFullTextSearchOnBodiesOptions.FirstOrDefault(p => p.Value == instance.EnableFullTextSearchOnBodies);
}
ForwardingOption auditForwarding;
+ PersistenceManifest selectedStorageType;
}
\ No newline at end of file
diff --git a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlInformation.cs b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlInformation.cs
index 13e6d1289c..8290777817 100644
--- a/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlInformation.cs
+++ b/src/ServiceControl.Config/UI/InstanceAdd/ServiceControlInformation.cs
@@ -63,6 +63,13 @@ public ServiceControlInformation(ServiceControlEditorViewModel viewModelParent)
EnableFullTextSearchOnBodies = EnableFullTextSearchOnBodiesOptions.First(p => p.Value); //Default to On.
EnableIntegratedServicePulse = EnableIntegratedServicePulseOptions.First(p => p.Value); //Default to On.
ViewModelParent = viewModelParent;
+
+ // Only auto-select when there's no real choice to make (picker hidden, RavenDB-only);
+ // otherwise leave it unselected so the combo box shows "Please Select...", like transport.
+ if (StorageTypes.Count() == 1)
+ {
+ SelectedStorageType = StorageTypes.First();
+ }
}
public int MaximumErrorRetentionPeriod => SettingConstants.ErrorRetentionPeriodMaxInDays;
@@ -73,6 +80,33 @@ public ServiceControlInformation(ServiceControlEditorViewModel viewModelParent)
public ServiceControlEditorViewModel ViewModelParent { get; }
+ public IEnumerable StorageTypes { get; } = ServiceControlPersisters.GetSupportedPrimaryManifests();
+
+ // Intentionally not editable after creation - changing storage backend on an existing
+ // instance has no supported data migration path yet, so this is only ever set at Add time.
+ public PersistenceManifest SelectedStorageType
+ {
+ get => selectedStorageType;
+ set
+ {
+ StorageConnectionString = null;
+ selectedStorageType = value;
+ }
+ }
+
+ public string StorageConnectionString { get; set; }
+
+ public bool ShowStorageTypePicker => StorageTypes.Count() > 1;
+
+ public bool ShowStorageConnectionString => SelectedStorageType != null && SelectedStorageType.Settings.Any(s => s.Mandatory && s.Name.EndsWith("/ConnectionString"));
+
+ public bool ShowDatabasePathFields => SelectedStorageType == null || SelectedStorageType.Settings.Any(s => s.Name.EndsWith("/DBPath"));
+
+ public MessageBodyStorageOptions MessageBodyStorage { get; } = new();
+
+ // Returns null for storage types that keep message bodies in the database.
+ public MessageBodyStorage CreateMessageBodyStorage() => MessageBodyStorage.Create(SelectedStorageType);
+
public double ErrorRetention { get; set; }
public TimeSpan ErrorRetentionPeriod => ErrorRetentionUnits == TimeSpanUnits.Days ? TimeSpan.FromDays(ErrorRetention) : TimeSpan.FromHours(ErrorRetention);
@@ -144,4 +178,5 @@ public void UpdateFromInstance(ServiceControlInstance instance)
}
ForwardingOption errorForwarding;
+ PersistenceManifest selectedStorageType;
}
\ No newline at end of file
diff --git a/src/ServiceControl.Config/Validation/Validations.cs b/src/ServiceControl.Config/Validation/Validations.cs
index 7fb7ba8373..23e68f6f52 100644
--- a/src/ServiceControl.Config/Validation/Validations.cs
+++ b/src/ServiceControl.Config/Validation/Validations.cs
@@ -221,6 +221,8 @@ public static IRuleBuilderOptions ValidHostname(this IRuleBuilder<
public const string MSG_THIS_TRANSPORT_REQUIRES_A_CONNECTION_STRING = "This transport requires a connection string.";
+ public const string MSG_THIS_STORAGE_TYPE_REQUIRES_A_CONNECTION_STRING = "This storage type requires a database connection string.";
+
public const string MSG_CANTCONTAINWHITESPACE = "{0} cannot contain white space.";
public const string MSG_SELECTAUDITFORWARDING = "Must select audit forwarding.";