diff --git a/docs/servere-og-databaser.md b/docs/servere-og-databaser.md index 6a807b1..9df5fe1 100644 --- a/docs/servere-og-databaser.md +++ b/docs/servere-og-databaser.md @@ -47,7 +47,7 @@ AX-fund sendes som standard **ikke** til SQLInsight Hub. Kolonnen *AX-fund til S ## Servere fra konfigurationsfilen -Servere under `SQLInsight:Instances` i agentens `appsettings.json` eller `sqlinsight.ini` (fx `MONITOR_SERVER` fra installeren) virker som før: ved hver start spejler agenten dem ind i repositoriet, hvor de vises som **fra konfigurationsfil**. Forbindelsen (og en eventuel adgangskode) bliver i filen; den kopieres aldrig til repositoriet. Her kan de sættes på pause, og databaserne kan vælges, men de rettes og fjernes i filen. Som standard overvåges alle deres databaser, også nye, præcis som i 1.2. Bruges samme navn både i filen og i webbrugerfladen, vinder filen. +Servere under `SQLInsight:Instances` i agentens `appsettings.json` eller `sqlinsight.ini` (fx `MONITOR_SERVER` fra installeren) virker som før: ved hver start spejler agenten dem ind i repositoriet, hvor de vises som **fra konfigurationsfil**. Forbindelsen (og en eventuel adgangskode) bliver i filen; den kopieres aldrig til repositoriet. Her kan de sættes på pause, og databaserne kan vælges, men de rettes og fjernes i filen. Fjernes en server fra filen, markeres den som fjernet ved agentens næste start (det indsamlede bevares); sættes den i filen igen, får den sin pause og sine databasevalg tilbage. Som standard overvåges alle deres databaser, også nye, præcis som i 1.2. Bruges samme navn både i filen og i webbrugerfladen, vinder filen. Ændringer i webbrugerfladen (nye servere, pause, fjernelse, valg af databaser) bruges af agenten inden for et minut, uden genstart. Ændringer i konfigurationsfilen kræver stadig en genstart af agenten. diff --git a/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs b/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs index 8c6a60d..1394e18 100644 --- a/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs +++ b/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs @@ -349,8 +349,9 @@ public sealed partial class InsightRepository /// /// Brings the ConfigFile rows in step with SQLInsight:Instances in the agent's configuration: new ones are added - /// (monitoring every database, as before 1.3), changed ones updated, and ones no longer in the file deleted. A Web - /// instance with the same name gives way to the file. Returns the ids by name. + /// (monitoring every database, as before 1.3), changed ones updated, and ones no longer in the file marked removed + /// (RemovedAt, like a removed Web instance), so their pause and database choices come back with them if they are put + /// back in the file. A Web instance with the same name gives way to the file. Returns the ids by name. /// public async Task> SyncConfigFileInstancesAsync(IReadOnlyList instances, DateTimeOffset now, CancellationToken cancellationToken = default) @@ -361,27 +362,36 @@ public sealed partial class InsightRepository var at = Utc(now); var names = instances.Select(i => i.Name).ToList(); await connection.ExecuteAsync(""" - DELETE FROM insight.ConfiguredInstances - WHERE Source = 'ConfigFile' AND Name NOT IN (SELECT value FROM OPENJSON(@names)); + UPDATE insight.ConfiguredInstances + SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: ikke længere i konfigurationsfilen', UpdatedAt = @at, UpdatedBy = N'SQLInsight Agent (konfigurationsfil)' + WHERE Source = 'ConfigFile' AND RemovedAt IS NULL AND Name NOT IN (SELECT value FROM OPENJSON(@names)); UPDATE insight.ConfiguredInstances SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: navnet bruges i konfigurationsfilen' WHERE Source = 'Web' AND RemovedAt IS NULL AND Name IN (SELECT value FROM OPENJSON(@names)); """, new { names = System.Text.Json.JsonSerializer.Serialize(names), at }, transaction).ConfigureAwait(false); var ids = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var instance in instances) { + // At most one ConfigFile row per name: a removed one is brought back (with its choices) rather than a new one added. ids[instance.Name] = await connection.ExecuteScalarAsync(""" - UPDATE insight.ConfiguredInstances - SET Server = @Server, Port = NULL, Authentication = @Authentication, UserName = @UserName, Encryption = @Encryption, - TrustServerCertificate = @TrustServerCertificate, PasswordCipher = NULL, PasswordKeyId = NULL - WHERE Source = 'ConfigFile' AND Name = @Name - AND (Server <> @Server OR Authentication <> @Authentication OR ISNULL(UserName, N'') <> ISNULL(@UserName, N'') - OR Encryption <> @Encryption OR TrustServerCertificate <> @TrustServerCertificate OR Port IS NOT NULL); - IF NOT EXISTS (SELECT 1 FROM insight.ConfiguredInstances WHERE Source = 'ConfigFile' AND Name = @Name) + DECLARE @id int = (SELECT TOP (1) ConfiguredInstanceId FROM insight.ConfiguredInstances + WHERE Source = 'ConfigFile' AND Name = @Name ORDER BY CASE WHEN RemovedAt IS NULL THEN 0 ELSE 1 END, ConfiguredInstanceId DESC); + IF @id IS NULL + BEGIN INSERT insight.ConfiguredInstances (Name, Server, Authentication, UserName, Encryption, TrustServerCertificate, Source, Paused, MonitorNewDatabases, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy) VALUES (@Name, @Server, @Authentication, @UserName, @Encryption, @TrustServerCertificate, 'ConfigFile', 0, 1, @at, N'SQLInsight Agent (konfigurationsfil)', @at, N'SQLInsight Agent (konfigurationsfil)'); - SELECT ConfiguredInstanceId FROM insight.ConfiguredInstances WHERE Source = 'ConfigFile' AND Name = @Name; + SET @id = SCOPE_IDENTITY(); + END + ELSE + UPDATE insight.ConfiguredInstances + SET Server = @Server, Port = NULL, Authentication = @Authentication, UserName = @UserName, Encryption = @Encryption, + TrustServerCertificate = @TrustServerCertificate, PasswordCipher = NULL, PasswordKeyId = NULL, RemovedAt = NULL, RemovedBy = NULL, + UpdatedAt = @at, UpdatedBy = N'SQLInsight Agent (konfigurationsfil)' + WHERE ConfiguredInstanceId = @id + AND (RemovedAt IS NOT NULL OR Server <> @Server OR Authentication <> @Authentication OR ISNULL(UserName, N'') <> ISNULL(@UserName, N'') + OR Encryption <> @Encryption OR TrustServerCertificate <> @TrustServerCertificate OR Port IS NOT NULL); + SELECT @id; """, new { instance.Name, instance.Server, instance.Authentication, instance.UserName, instance.Encryption, instance.TrustServerCertificate, at }, transaction).ConfigureAwait(false); } diff --git a/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql b/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql index 25a9074..689492f 100644 --- a/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql +++ b/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql @@ -37,9 +37,10 @@ CREATE TABLE insight.ConfiguredInstances RemovedBy nvarchar(256) NULL, LastDiscoveryAt datetime2(0) NULL, LastDiscoveryError nvarchar(1000) NULL, - CredentialError nvarchar(1000) NULL, - /* An instance from the configuration file is removed by removing it from the file. */ - CONSTRAINT CK_ConfiguredInstances_RemoveWeb CHECK (Source = 'Web' OR RemovedAt IS NULL) + CredentialError nvarchar(1000) NULL + /* An instance from the configuration file is removed by removing it from the file: the agent then sets RemovedAt + (like a removed Web instance, so its history, pause and database choices are kept), and clears it again if the + instance comes back in the file. */ ); CREATE UNIQUE INDEX UQ_ConfiguredInstances_Name ON insight.ConfiguredInstances (Name) WHERE RemovedAt IS NULL; diff --git a/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs b/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs index 09eb7d9..c0e0a6f 100644 --- a/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs +++ b/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs @@ -68,7 +68,6 @@ public class InstanceConfigurationRepositoryTests(SqlServerFixture sql) : IAsync await Assert.ThrowsAsync(() => web.CompleteAgentRequestAsync(request, true, "{}", null, now)); await Assert.ThrowsAsync(() => web.SaveDiscoveredDatabasesAsync(id, [Db("Sales")], now)); await Assert.ThrowsAsync(() => web.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "x", PublicKey = [1], MachineName = "m", ServiceAccount = "a", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow })); - await Assert.ThrowsAsync(() => web.SyncConfigFileInstancesAsync([], now)); await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"DELETE FROM insight.ConfiguredInstances WHERE ConfiguredInstanceId = {id}")); await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredInstances SET Source = 'ConfigFile' WHERE ConfiguredInstanceId = {id}")); await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredInstances SET Name = N'x' WHERE ConfiguredInstanceId = {id}")); @@ -142,11 +141,21 @@ public class InstanceConfigurationRepositoryTests(SqlServerFixture sql) : IAsync new ConfiguredInstanceChange("evil", null, "Windows", null, null, null, false, "Mandatory", false), null, now)); Assert.Equal(ConfigurationChangeOutcome.Changed, await _repository.SetInstancePausedAsync(ids["prod-sql01"], true, null, now)); - // Started again with one instance changed and one gone: the same row is kept (with its choices), the other deleted. + // Started again with one instance changed and one gone: the same row is kept (with its choices), the other marked removed. var again = await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01b", "Windows", null, "Mandatory", false)], now); Assert.Equal(ids["prod-sql01"], again["prod-sql01"]); var row = Assert.Single(await _repository.GetConfiguredInstancesAsync()); Assert.Equal(("sql01b", true), (row.Server, row.Paused)); + Assert.Null(await _repository.GetConfiguredInstanceAsync(ids["prod-sql02"])); + + // Put back in the file: the same row comes back, nothing is lost and no second row is made. + var back = await _repository.SyncConfigFileInstancesAsync( + [ + new ConfigFileInstance("prod-sql01", "sql01b", "Windows", null, "Mandatory", false), + new ConfigFileInstance("prod-sql02", "sql02", "SqlLogin", "mon", "Optional", true), + ], now); + Assert.Equal(ids["prod-sql02"], back["prod-sql02"]); + Assert.Equal(2, (await _repository.GetConfiguredInstancesAsync()).Count); } [Fact]