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.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.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.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.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/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/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/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.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/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() 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."; 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/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/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/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..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 @@ -3087,7 +3372,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 +3401,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 +3593,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 +3622,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.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/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest index f021224da5..77a1c26cfa 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 }, { @@ -77,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 faa1f24c3e..7853239632 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 }, { @@ -77,5 +78,8 @@ "Name": "ServiceControl/MessageBody/MinCompressionSize", "Mandatory": false } + ], + "SettingsWithPathsToCleanup": [ + "ServiceControl/MessageBody/FileSystem/StoragePath" ] } 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/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.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.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..9b89a67257 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 @@ -92,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(); @@ -153,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/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..5a86735663 100644 --- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs +++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlBaseService.cs @@ -40,6 +40,39 @@ protected ServiceControlBaseService(IWindowsServiceController service) public bool InMaintenanceMode { get; set; } 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 @@ -211,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(); @@ -292,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/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..240999b9bb 100644 --- a/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs +++ b/src/ServiceControlInstaller.Engine/Instances/ServiceControlInstance.cs @@ -48,11 +48,19 @@ 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; } = []; - public PersistenceManifest PersistenceManifest { get; set; } + public string PersistenceConnectionString { get; set; } public void AddRemoteInstance(string apiUri) { @@ -218,7 +226,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); @@ -247,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 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/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)) 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(); }