From 1199b1aa8643b8e1469d91dde0ed0a2591096968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 11:39:06 +0200 Subject: [PATCH] Repository: configured instances, discovered databases, agent request queue (0016) insight.ConfiguredInstances (Web or ConfigFile source, soft remove, sealed password), insight.ConfiguredDatabases (discovery result, monitored, AX to hub), insight.AgentRequests (queue for connection tests and discovery run by the agent) and insight.AgentKeys (the agent's public key and identity). The web login gets INSERT on ConfiguredInstances and AgentRequests and UPDATE on named columns only; no DELETE, no Source/Name change, nothing the agent records. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...InsightRepository.InstanceConfiguration.cs | 522 ++++++++++++++++++ .../0016_instance_configuration.sql | 113 ++++ .../InstanceConfigurationRepositoryTests.cs | 193 +++++++ 3 files changed, 828 insertions(+) create mode 100644 src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs create mode 100644 src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql create mode 100644 tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs diff --git a/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs b/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs new file mode 100644 index 0000000..f089a9c --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.InstanceConfiguration.cs @@ -0,0 +1,522 @@ +using Dapper; +using SQLInsight.Core.Configuration; + +namespace SQLInsight.Repository; + +/// Values of insight.ConfiguredInstances.Source. +public static class InstanceSource +{ + /// Added in SQLInsight Web (or API v1). + public const string Web = "Web"; + + /// SQLInsight:Instances in the agent's configuration file; shown as "fra konfigurationsfil". + public const string ConfigFile = "ConfigFile"; +} + +/// An instance the agent monitors (insight.ConfiguredInstances). +public sealed record ConfiguredInstanceRow +{ + public int ConfiguredInstanceId { get; init; } + public string Name { get; init; } = string.Empty; + public string Server { get; init; } = string.Empty; + public int? Port { get; init; } + public string Authentication { get; init; } = "Windows"; + public string? UserName { get; init; } + + /// The sealed password. Only the agent can open it; never shown or returned. + public byte[]? PasswordCipher { get; init; } + + public string? PasswordKeyId { get; init; } + public string Encryption { get; init; } = "Mandatory"; + public bool TrustServerCertificate { get; init; } + public string Source { get; init; } = InstanceSource.Web; + public bool Paused { get; init; } + public bool MonitorNewDatabases { get; init; } + public DateTime CreatedAt { get; init; } + public string? CreatedBy { get; init; } + public DateTime UpdatedAt { get; init; } + public string? UpdatedBy { get; init; } + public DateTime? RemovedAt { get; init; } + public DateTime? LastDiscoveryAt { get; init; } + public string? LastDiscoveryError { get; init; } + + /// The agent could not open the stored password (Danish). + public string? CredentialError { get; init; } + + public bool FromConfigFile => Source == InstanceSource.ConfigFile; + public bool HasPassword => PasswordCipher is { Length: > 0 }; + public string DataSource => ServerAddress.DataSource(Server, Port); +} + +/// A database on a configured instance, as last discovered, and whether it is monitored. +public sealed record ConfiguredDatabaseRow +{ + public int ConfiguredInstanceId { get; init; } + public string DatabaseName { get; init; } = string.Empty; + public bool Monitored { get; init; } + public bool SendDynamicsAxToHub { get; init; } + public int? DatabaseId { get; init; } + public string? State { get; init; } + public long? SizeMb { get; init; } + public string? RecoveryModel { get; init; } + public int? CompatibilityLevel { get; init; } + public bool IsReadOnly { get; init; } + public bool IsSystem { get; init; } + public bool IsSnapshot { get; init; } + public bool HasAccess { get; init; } + public bool IsDynamicsAx { get; init; } + public string? DynamicsAxVersion { get; init; } + public DateTime FirstSeenAt { get; init; } + public DateTime LastSeenAt { get; init; } + public bool Gone { get; init; } + public DateTime? SelectionChangedAt { get; init; } + public string? SelectionChangedBy { get; init; } +} + +/// A connection test or discovery asked for in SQLInsight Web (insight.AgentRequests). +public sealed record AgentRequestRow +{ + public long RequestId { get; init; } + public string Kind { get; init; } = string.Empty; + public int? ConfiguredInstanceId { get; init; } + public string? Parameters { get; init; } + public string Status { get; init; } = AgentRequestStatus.Pending; + public string? RequestedBy { get; init; } + public DateTime RequestedAt { get; init; } + public DateTime? StartedAt { get; init; } + public DateTime? CompletedAt { get; init; } + public string? Result { get; init; } + public string? Error { get; init; } + + public bool IsFinished => Status is AgentRequestStatus.Done or AgentRequestStatus.Failed; +} + +/// The agent's public key and identity (insight.AgentKeys). +public sealed record AgentKeyRow +{ + public string KeyId { get; init; } = string.Empty; + public byte[] PublicKey { get; init; } = []; + public string MachineName { get; init; } = string.Empty; + public string ServiceAccount { get; init; } = string.Empty; + public string? Domain { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime LastSeenAt { get; init; } +} + +/// A new instance from SQLInsight Web. +public sealed record NewConfiguredInstance( + string Name, + string Server, + int? Port, + string Authentication, + string? UserName, + byte[]? PasswordCipher, + string? PasswordKeyId, + string Encryption, + bool TrustServerCertificate, + bool MonitorNewDatabases, + bool Paused = false); + +/// New connection details for a Web instance. A null keeps the stored password. +public sealed record ConfiguredInstanceChange( + string Server, + int? Port, + string Authentication, + string? UserName, + byte[]? PasswordCipher, + string? PasswordKeyId, + bool ClearPassword, + string Encryption, + bool TrustServerCertificate); + +/// An instance in the agent's configuration file, without its password. +public sealed record ConfigFileInstance(string Name, string Server, string Authentication, string? UserName, string Encryption, bool TrustServerCertificate); + +/// Whether a database is monitored, and may send Dynamics AX findings to the hub. +public sealed record DatabaseChoice(string DatabaseName, bool Monitored, bool SendDynamicsAxToHub); + +public enum ConfigurationChangeOutcome +{ + Changed, + NotFound, + + /// The instance comes from the configuration file; change it there. + FromConfigFile, +} + +public sealed partial class InsightRepository +{ + private const string InstanceColumns = """ + ConfiguredInstanceId, Name, Server, Port, Authentication, UserName, PasswordCipher, PasswordKeyId, Encryption, TrustServerCertificate, + Source, Paused, MonitorNewDatabases, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy, RemovedAt, LastDiscoveryAt, LastDiscoveryError, CredentialError + """; + + /// The configured instances (not removed), by name. + public async Task> GetConfiguredInstancesAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync( + $"SELECT {InstanceColumns} FROM insight.ConfiguredInstances WHERE RemovedAt IS NULL ORDER BY Name;").ConfigureAwait(false); + return rows.ToList(); + } + + public async Task GetConfiguredInstanceAsync(int id, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + $"SELECT {InstanceColumns} FROM insight.ConfiguredInstances WHERE ConfiguredInstanceId = @id AND RemovedAt IS NULL;", new { id }).ConfigureAwait(false); + } + + /// Adds an instance (SQLInsight Web: INSERT only). A name in use throws SqlException 2601. + public async Task AddConfiguredInstanceAsync(NewConfiguredInstance instance, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(instance); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + INSERT insight.ConfiguredInstances (Name, Server, Port, Authentication, UserName, PasswordCipher, PasswordKeyId, Encryption, TrustServerCertificate, + Source, Paused, MonitorNewDatabases, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy) + OUTPUT INSERTED.ConfiguredInstanceId + VALUES (@Name, @Server, @Port, @Authentication, @UserName, @PasswordCipher, @PasswordKeyId, @Encryption, @TrustServerCertificate, + 'Web', @Paused, @MonitorNewDatabases, @now, @by, @now, @by); + """, new + { + Name = instance.Name.Trim(), Server = instance.Server.Trim(), instance.Port, instance.Authentication, instance.UserName, instance.PasswordCipher, + instance.PasswordKeyId, instance.Encryption, instance.TrustServerCertificate, instance.Paused, instance.MonitorNewDatabases, by, now = Utc(now), + }).ConfigureAwait(false); + } + + /// New connection details for an instance added in SQLInsight Web. + public async Task UpdateConfiguredInstanceAsync(int id, ConfiguredInstanceChange change, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(change); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var updated = await connection.ExecuteAsync(""" + UPDATE insight.ConfiguredInstances + SET Server = @Server, Port = @Port, Authentication = @Authentication, UserName = @UserName, + PasswordCipher = CASE WHEN @ClearPassword = 1 THEN NULL WHEN @PasswordCipher IS NOT NULL THEN @PasswordCipher ELSE PasswordCipher END, + PasswordKeyId = CASE WHEN @ClearPassword = 1 THEN NULL WHEN @PasswordCipher IS NOT NULL THEN @PasswordKeyId ELSE PasswordKeyId END, + CredentialError = CASE WHEN @ClearPassword = 1 OR @PasswordCipher IS NOT NULL THEN NULL ELSE CredentialError END, + Encryption = @Encryption, TrustServerCertificate = @TrustServerCertificate, UpdatedAt = @now, UpdatedBy = @by + WHERE ConfiguredInstanceId = @id AND RemovedAt IS NULL AND Source = 'Web'; + """, new + { + id, Server = change.Server.Trim(), change.Port, change.Authentication, change.UserName, change.PasswordCipher, change.PasswordKeyId, + change.ClearPassword, change.Encryption, change.TrustServerCertificate, by, now = Utc(now), + }).ConfigureAwait(false); + return updated > 0 ? ConfigurationChangeOutcome.Changed : await WhyNotAsync(id, cancellationToken).ConfigureAwait(false); + } + + /// Pauses or resumes monitoring of an instance. Allowed for instances from the configuration file too. + public async Task SetInstancePausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var updated = await connection.ExecuteAsync( + "UPDATE insight.ConfiguredInstances SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE ConfiguredInstanceId = @id AND RemovedAt IS NULL;", + new { id, paused, by, now = Utc(now) }).ConfigureAwait(false); + return updated > 0 ? ConfigurationChangeOutcome.Changed : ConfigurationChangeOutcome.NotFound; + } + + /// Whether databases that appear on the instance later are monitored at once. + public async Task SetMonitorNewDatabasesAsync(int id, bool monitorNew, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var updated = await connection.ExecuteAsync( + "UPDATE insight.ConfiguredInstances SET MonitorNewDatabases = @monitorNew, UpdatedAt = @now, UpdatedBy = @by WHERE ConfiguredInstanceId = @id AND RemovedAt IS NULL;", + new { id, monitorNew, by, now = Utc(now) }).ConfigureAwait(false); + return updated > 0 ? ConfigurationChangeOutcome.Changed : ConfigurationChangeOutcome.NotFound; + } + + /// + /// Stops monitoring an instance added in SQLInsight Web. The row is kept (RemovedAt), so the history stays and the + /// web UI needs no DELETE; the collected data under its name is untouched. + /// + public async Task RemoveConfiguredInstanceAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var updated = await connection.ExecuteAsync(""" + UPDATE insight.ConfiguredInstances SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by + WHERE ConfiguredInstanceId = @id AND RemovedAt IS NULL AND Source = 'Web'; + """, new { id, by, now = Utc(now) }).ConfigureAwait(false); + return updated > 0 ? ConfigurationChangeOutcome.Changed : await WhyNotAsync(id, cancellationToken).ConfigureAwait(false); + } + + private async Task WhyNotAsync(int id, CancellationToken cancellationToken) => + await GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) is { FromConfigFile: true } + ? ConfigurationChangeOutcome.FromConfigFile + : ConfigurationChangeOutcome.NotFound; + + /// The databases found on an instance, by name; system databases first. + public async Task> GetConfiguredDatabasesAsync(int id, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync( + "SELECT * FROM insight.ConfiguredDatabases WHERE ConfiguredInstanceId = @id ORDER BY IsSystem DESC, DatabaseName;", new { id }).ConfigureAwait(false); + return rows.ToList(); + } + + /// + /// Sets which databases are monitored and may send Dynamics AX findings to the hub. Only databases the agent has + /// found can be chosen; the names that are not known are returned. Sending AX findings is only kept for AX databases. + /// + public async Task> SetDatabaseChoicesAsync(int id, IReadOnlyCollection choices, string? by, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(choices); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var unknown = new List(); + foreach (var choice in choices) + { + // Only a real change is recorded, so "changed by" stays the person who made the choice. + var exists = await connection.ExecuteScalarAsync(""" + UPDATE insight.ConfiguredDatabases + SET Monitored = CASE WHEN IsSystem = 1 THEN 1 ELSE @Monitored END, + SendDynamicsAxToHub = CASE WHEN IsDynamicsAx = 1 THEN @SendDynamicsAxToHub ELSE 0 END, + SelectionChangedAt = @now, SelectionChangedBy = @by + WHERE ConfiguredInstanceId = @id AND DatabaseName = @DatabaseName + AND (Monitored <> CASE WHEN IsSystem = 1 THEN 1 ELSE @Monitored END + OR SendDynamicsAxToHub <> CASE WHEN IsDynamicsAx = 1 THEN @SendDynamicsAxToHub ELSE 0 END); + SELECT COUNT(*) FROM insight.ConfiguredDatabases WHERE ConfiguredInstanceId = @id AND DatabaseName = @DatabaseName; + """, new { id, choice.DatabaseName, choice.Monitored, choice.SendDynamicsAxToHub, by, now = Utc(now) }, transaction).ConfigureAwait(false); + if (exists == 0) + { + unknown.Add(choice.DatabaseName); + } + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return unknown; + } + + /// Queues a request for the agent (SQLInsight Web: INSERT only). + public async Task AddAgentRequestAsync(string kind, int? configuredInstanceId, string? parameters, string? by, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + INSERT insight.AgentRequests (Kind, ConfiguredInstanceId, Parameters, Status, RequestedBy, RequestedAt) + OUTPUT INSERTED.RequestId + VALUES (@kind, @configuredInstanceId, @parameters, 'Pending', @by, @now); + """, new { kind, configuredInstanceId, parameters, by, now = Utc(now) }).ConfigureAwait(false); + } + + public async Task GetAgentRequestAsync(long id, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + "SELECT * FROM insight.AgentRequests WHERE RequestId = @id;", new { id }).ConfigureAwait(false); + } + + /// The newest request of a kind for an instance (or for no instance, e.g. instance discovery). + public async Task GetLatestAgentRequestAsync(int? configuredInstanceId, string kind, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync(""" + SELECT TOP 1 * FROM insight.AgentRequests + WHERE Kind = @kind AND (ConfiguredInstanceId = @configuredInstanceId OR (@configuredInstanceId IS NULL AND ConfiguredInstanceId IS NULL)) + ORDER BY RequestId DESC; + """, new { configuredInstanceId, kind }).ConfigureAwait(false); + } + + /// The key the web UI seals passwords with: the one the agent reported most recently. + public async Task GetCurrentAgentKeyAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + "SELECT TOP 1 * FROM insight.AgentKeys ORDER BY LastSeenAt DESC, CreatedAt DESC;").ConfigureAwait(false); + } + + // ---- The agent ---------------------------------------------------------------------------------------------- + + /// + /// 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. + /// + public async Task> SyncConfigFileInstancesAsync(IReadOnlyList instances, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(instances); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + 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: 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) + { + 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) + 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; + """, new { instance.Name, instance.Server, instance.Authentication, instance.UserName, instance.Encryption, instance.TrustServerCertificate, at }, + transaction).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return ids; + } + + /// Every configured instance's database choices, for the agent to build each instance's . + public async Task> GetAllDatabaseChoicesAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT d.ConfiguredInstanceId, d.DatabaseName, d.Monitored, d.SendDynamicsAxToHub, d.IsSystem, d.IsDynamicsAx, d.Gone + FROM insight.ConfiguredDatabases d + JOIN insight.ConfiguredInstances i ON i.ConfiguredInstanceId = d.ConfiguredInstanceId + WHERE i.RemovedAt IS NULL; + """).ConfigureAwait(false); + return rows.ToList(); + } + + /// + /// Takes the oldest pending request and marks it Running. Safe with several agents: a request is only taken once + /// (UPDLOCK, READPAST). + /// + public async Task ClaimAgentRequestAsync(DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync(""" + WITH next AS ( + SELECT TOP 1 * FROM insight.AgentRequests WITH (ROWLOCK, UPDLOCK, READPAST) + WHERE Status = 'Pending' + ORDER BY RequestId) + UPDATE next SET Status = 'Running', StartedAt = @now + OUTPUT INSERTED.*; + """, new { now = Utc(now) }).ConfigureAwait(false); + } + + public async Task CompleteAgentRequestAsync(long id, bool succeeded, string? result, string? error, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.AgentRequests SET Status = @status, Result = @result, Error = LEFT(@error, 2000), CompletedAt = @now + WHERE RequestId = @id; + """, new { id, status = succeeded ? AgentRequestStatus.Done : AgentRequestStatus.Failed, result, error, now = Utc(now) }).ConfigureAwait(false); + } + + /// + /// Requests the agent was running when it stopped, and ones that waited longer than , + /// are failed with a reason, so the web UI does not wait forever. Requests older than are deleted. + /// + public async Task CleanUpAgentRequestsAsync(DateTimeOffset now, TimeSpan runningFor, TimeSpan pendingFor, TimeSpan keep, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.AgentRequests SET Status = 'Failed', CompletedAt = @now, + Error = N'Agenten blev stoppet, mens anmodningen blev behandlet. Prøv igen.' + WHERE Status = 'Running' AND StartedAt < @running; + UPDATE insight.AgentRequests SET Status = 'Failed', CompletedAt = @now, + Error = N'Agenten tog ikke anmodningen op. Kører tjenesten SQLInsight Agent, og er den version 1.3 eller nyere?' + WHERE Status = 'Pending' AND RequestedAt < @pending; + DELETE FROM insight.AgentRequests WHERE RequestedAt < @keep; + """, new { now = Utc(now), running = Utc(now - runningFor), pending = Utc(now - pendingFor), keep = Utc(now - keep) }).ConfigureAwait(false); + } + + /// + /// Stores what a discovery found: new databases are added (monitored when the instance monitors new databases), + /// known ones updated, and ones no longer there marked Gone. Returns the summary. + /// + public async Task SaveDiscoveredDatabasesAsync(int id, IReadOnlyList databases, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(databases); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var at = Utc(now); + var monitorNew = await connection.ExecuteScalarAsync( + "SELECT MonitorNewDatabases FROM insight.ConfiguredInstances WITH (UPDLOCK) WHERE ConfiguredInstanceId = @id;", new { id }, transaction).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Instans {id} findes ikke."); + var known = (await connection.QueryAsync<(string Name, bool Gone)>( + "SELECT DatabaseName, Gone FROM insight.ConfiguredDatabases WHERE ConfiguredInstanceId = @id;", new { id }, transaction).ConfigureAwait(false)) + .ToDictionary(k => k.Name, k => k.Gone, StringComparer.OrdinalIgnoreCase); + var added = new List(); + foreach (var d in databases) + { + var isNew = !known.ContainsKey(d.Name); + if (isNew) + { + added.Add(d.Name); + } + + await connection.ExecuteAsync(""" + UPDATE insight.ConfiguredDatabases + SET DatabaseId = @DatabaseId, State = @State, SizeMb = @SizeMb, RecoveryModel = @RecoveryModel, CompatibilityLevel = @CompatibilityLevel, + IsReadOnly = @IsReadOnly, IsSystem = @IsSystem, IsSnapshot = @IsSnapshot, HasAccess = @HasAccess, IsDynamicsAx = @IsDynamicsAx, + DynamicsAxVersion = @DynamicsAxVersion, LastSeenAt = @at, Gone = 0, + SendDynamicsAxToHub = CASE WHEN @IsDynamicsAx = 1 THEN SendDynamicsAxToHub ELSE 0 END + WHERE ConfiguredInstanceId = @id AND DatabaseName = @Name; + IF @@ROWCOUNT = 0 + INSERT insight.ConfiguredDatabases (ConfiguredInstanceId, DatabaseName, Monitored, DatabaseId, State, SizeMb, RecoveryModel, CompatibilityLevel, + IsReadOnly, IsSystem, IsSnapshot, HasAccess, IsDynamicsAx, DynamicsAxVersion, FirstSeenAt, LastSeenAt) + VALUES (@id, @Name, @monitored, @DatabaseId, @State, @SizeMb, @RecoveryModel, @CompatibilityLevel, + @IsReadOnly, @IsSystem, @IsSnapshot, @HasAccess, @IsDynamicsAx, @DynamicsAxVersion, @at, @at); + """, new + { + id, d.Name, d.DatabaseId, d.State, d.SizeMb, d.RecoveryModel, d.CompatibilityLevel, d.IsReadOnly, d.IsSystem, d.IsSnapshot, d.HasAccess, + d.IsDynamicsAx, d.DynamicsAxVersion, at, monitored = d.IsSystem || (monitorNew && !d.IsSnapshot), + }, transaction).ConfigureAwait(false); + } + + var present = new HashSet(databases.Select(d => d.Name), StringComparer.OrdinalIgnoreCase); + var gone = known.Where(k => !k.Value && !present.Contains(k.Key)).Select(k => k.Key).ToList(); + await connection.ExecuteAsync(""" + UPDATE insight.ConfiguredDatabases SET Gone = 1 WHERE ConfiguredInstanceId = @id AND DatabaseName IN (SELECT value FROM OPENJSON(@gone)); + UPDATE insight.ConfiguredInstances SET LastDiscoveryAt = @at, LastDiscoveryError = NULL WHERE ConfiguredInstanceId = @id; + """, new { id, gone = System.Text.Json.JsonSerializer.Serialize(gone), at }, transaction).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return new DatabaseDiscoveryResult { Databases = databases.Count, New = added, Gone = gone, NewMonitored = monitorNew }; + } + + public async Task RecordDiscoveryErrorAsync(int id, string error, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync( + "UPDATE insight.ConfiguredInstances SET LastDiscoveryAt = @now, LastDiscoveryError = LEFT(@error, 1000) WHERE ConfiguredInstanceId = @id;", + new { id, error, now = Utc(now) }).ConfigureAwait(false); + } + + /// The agent could (null) or could not open an instance's stored password. + public async Task SetCredentialErrorAsync(int id, string? error, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.ConfiguredInstances SET CredentialError = LEFT(@error, 1000) + WHERE ConfiguredInstanceId = @id AND ISNULL(CredentialError, N'') <> ISNULL(@error, N''); + """, new { id, error }).ConfigureAwait(false); + } + + /// Publishes (or refreshes) the agent's public key and identity. + public async Task PublishAgentKeyAsync(AgentKeyRow key, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(key); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.AgentKeys SET PublicKey = @PublicKey, MachineName = @MachineName, ServiceAccount = @ServiceAccount, Domain = @Domain, LastSeenAt = @LastSeenAt + WHERE KeyId = @KeyId; + IF @@ROWCOUNT = 0 + INSERT insight.AgentKeys (KeyId, PublicKey, MachineName, ServiceAccount, Domain, CreatedAt, LastSeenAt) + VALUES (@KeyId, @PublicKey, @MachineName, @ServiceAccount, @Domain, @CreatedAt, @LastSeenAt); + """, new + { + key.KeyId, key.PublicKey, key.MachineName, key.ServiceAccount, key.Domain, + CreatedAt = Utc(new DateTimeOffset(DateTime.SpecifyKind(key.CreatedAt, DateTimeKind.Utc))), + LastSeenAt = Utc(new DateTimeOffset(DateTime.SpecifyKind(key.LastSeenAt, DateTimeKind.Utc))), + }).ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql b/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql new file mode 100644 index 0000000..25a9074 --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0016_instance_configuration.sql @@ -0,0 +1,113 @@ +/* 1.3: SQL Server instances and their databases are configured in SQLInsight Web, not only in appsettings.json. + + ConfiguredInstances the instances the agent monitors. Source 'Web' rows are added in SQLInsight Web; 'ConfigFile' + rows mirror SQLInsight:Instances in the agent's appsettings.json/sqlinsight.ini (the agent keeps + them in step at start). Connection details of ConfigFile rows stay in the file; the password + is never copied here. A Web row's SQL login password is sealed with the agent's public key + (insight.AgentKeys), so only the agent can read it (Core/Configuration/CredentialEnvelope.cs). + ConfiguredDatabases the databases the agent has found on each instance, which of them are monitored, and which + may send Dynamics AX findings to SQLInsight Hub (default no). + AgentRequests connection tests and discovery asked for in SQLInsight Web. They run in the agent, as the + agent's identity, which is what connects to the instances; the web UI shows the result. + AgentKeys the agent's public key, and the account and computer it runs as (for the GRANT script). */ +CREATE TABLE insight.ConfiguredInstances +( + ConfiguredInstanceId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ConfiguredInstances PRIMARY KEY, + Name nvarchar(128) NOT NULL, + Server nvarchar(256) NOT NULL, + Port int NULL CONSTRAINT CK_ConfiguredInstances_Port CHECK (Port BETWEEN 1 AND 65535), + Authentication varchar(32) NOT NULL CONSTRAINT DF_ConfiguredInstances_Authentication DEFAULT ('Windows') + CONSTRAINT CK_ConfiguredInstances_Authentication + CHECK (Authentication IN ('Windows', 'SqlLogin', 'EntraManagedIdentity', 'EntraServicePrincipal', 'EntraDefault')), + UserName nvarchar(128) NULL, + PasswordCipher varbinary(2048) NULL, + PasswordKeyId varchar(64) NULL, + Encryption varchar(16) NOT NULL CONSTRAINT DF_ConfiguredInstances_Encryption DEFAULT ('Mandatory') + CONSTRAINT CK_ConfiguredInstances_Encryption CHECK (Encryption IN ('Mandatory', 'Optional')), + TrustServerCertificate bit NOT NULL CONSTRAINT DF_ConfiguredInstances_Trust DEFAULT (0), + Source varchar(16) NOT NULL CONSTRAINT DF_ConfiguredInstances_Source DEFAULT ('Web') + CONSTRAINT CK_ConfiguredInstances_Source CHECK (Source IN ('Web', 'ConfigFile')), + Paused bit NOT NULL CONSTRAINT DF_ConfiguredInstances_Paused DEFAULT (0), + MonitorNewDatabases bit NOT NULL CONSTRAINT DF_ConfiguredInstances_MonitorNew DEFAULT (0), + CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_ConfiguredInstances_CreatedAt DEFAULT (SYSUTCDATETIME()), + CreatedBy nvarchar(256) NULL, + UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_ConfiguredInstances_UpdatedAt DEFAULT (SYSUTCDATETIME()), + UpdatedBy nvarchar(256) NULL, + RemovedAt datetime2(0) NULL, + 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) +); +CREATE UNIQUE INDEX UQ_ConfiguredInstances_Name ON insight.ConfiguredInstances (Name) WHERE RemovedAt IS NULL; + +CREATE TABLE insight.ConfiguredDatabases +( + ConfiguredInstanceId int NOT NULL + CONSTRAINT FK_ConfiguredDatabases_Instance REFERENCES insight.ConfiguredInstances (ConfiguredInstanceId) ON DELETE CASCADE, + DatabaseName nvarchar(128) NOT NULL, + Monitored bit NOT NULL, + SendDynamicsAxToHub bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_AxHub DEFAULT (0), + DatabaseId int NULL, + State nvarchar(60) NULL, + SizeMb bigint NULL, + RecoveryModel nvarchar(60) NULL, + CompatibilityLevel int NULL, + IsReadOnly bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_ReadOnly DEFAULT (0), + IsSystem bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_System DEFAULT (0), + IsSnapshot bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_Snapshot DEFAULT (0), + HasAccess bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_Access DEFAULT (0), + IsDynamicsAx bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_Ax DEFAULT (0), + DynamicsAxVersion varchar(16) NULL, + FirstSeenAt datetime2(0) NOT NULL, + LastSeenAt datetime2(0) NOT NULL, + Gone bit NOT NULL CONSTRAINT DF_ConfiguredDatabases_Gone DEFAULT (0), + SelectionChangedAt datetime2(0) NULL, + SelectionChangedBy nvarchar(256) NULL, + CONSTRAINT PK_ConfiguredDatabases PRIMARY KEY (ConfiguredInstanceId, DatabaseName) +); + +CREATE TABLE insight.AgentRequests +( + RequestId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_AgentRequests PRIMARY KEY, + Kind varchar(32) NOT NULL + CONSTRAINT CK_AgentRequests_Kind CHECK (Kind IN ('ConnectionTest', 'DatabaseDiscovery', 'InstanceDiscovery')), + ConfiguredInstanceId int NULL + CONSTRAINT FK_AgentRequests_Instance REFERENCES insight.ConfiguredInstances (ConfiguredInstanceId) ON DELETE CASCADE, + Parameters nvarchar(4000) NULL, + Status varchar(16) NOT NULL CONSTRAINT DF_AgentRequests_Status DEFAULT ('Pending') + CONSTRAINT CK_AgentRequests_Status CHECK (Status IN ('Pending', 'Running', 'Done', 'Failed')), + RequestedBy nvarchar(256) NULL, + RequestedAt datetime2(0) NOT NULL CONSTRAINT DF_AgentRequests_RequestedAt DEFAULT (SYSUTCDATETIME()), + StartedAt datetime2(0) NULL, + CompletedAt datetime2(0) NULL, + Result nvarchar(max) NULL, + Error nvarchar(2000) NULL +); +CREATE INDEX IX_AgentRequests_Pending ON insight.AgentRequests (RequestId) WHERE Status = 'Pending'; +CREATE INDEX IX_AgentRequests_Instance ON insight.AgentRequests (ConfiguredInstanceId, Kind, RequestId); + +CREATE TABLE insight.AgentKeys +( + KeyId varchar(64) NOT NULL CONSTRAINT PK_AgentKeys PRIMARY KEY, + PublicKey varbinary(2048) NOT NULL, + MachineName nvarchar(128) NOT NULL, + ServiceAccount nvarchar(256) NOT NULL, + Domain nvarchar(128) NULL, + CreatedAt datetime2(0) NOT NULL, + LastSeenAt datetime2(0) NOT NULL +); +GO + +/* SQLInsight Web only has db_datareader. Like insight.SupportCases (0005, 0012) it gets exactly what it needs to + change the configuration and queue requests, on these tables and columns, and nothing else: no DELETE (an instance + is removed by setting RemovedAt), no change of Source or Name, and nothing the agent records (discovery results, + request status, keys). Only the repository's own users exist in this database. Who may change the configuration + in the web UI is decided by SQLInsight:Web:AdminGroups. */ +GRANT INSERT ON insight.ConfiguredInstances TO public; +GRANT UPDATE ON insight.ConfiguredInstances (Server, Port, Authentication, UserName, PasswordCipher, PasswordKeyId, Encryption, TrustServerCertificate, + Paused, MonitorNewDatabases, UpdatedAt, UpdatedBy, RemovedAt, RemovedBy, CredentialError) TO public; +GRANT UPDATE ON insight.ConfiguredDatabases (Monitored, SendDynamicsAxToHub, SelectionChangedAt, SelectionChangedBy) TO public; +GRANT INSERT ON insight.AgentRequests TO public; diff --git a/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs b/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs new file mode 100644 index 0000000..09eb7d9 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs @@ -0,0 +1,193 @@ +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; + +namespace SQLInsight.IntegrationTests; + +/// +/// The instance configuration in the repository (0016): what SQLInsight Web may change with its read-only login and +/// the few grants it has, the configuration file mirrored as read-only instances, discovery results and the request queue. +/// +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class InstanceConfigurationRepositoryTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightCfg_" + Guid.NewGuid().ToString("N")[..8]; + private InsightRepository _repository = null!; + private string _webConnectionString = string.Empty; + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + } + + public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + + private static NewConfiguredInstance Instance(string name, bool monitorNew = false) => + new(name, "sql01\\PROD", 1450, "SqlLogin", "sqlinsight", [1, 2, 3], "key-1", "Mandatory", false, monitorNew); + + /// A login with db_datareader only, like SQLInsight Web's. + private async Task ReadOnlyRepositoryAsync() + { + var login = "web_" + Guid.NewGuid().ToString("N")[..8]; + const string password = "Read-Only-Login-1!"; + await sql.ExecuteAsSaAsync($""" + CREATE LOGIN [{login}] WITH PASSWORD = N'{password}', CHECK_POLICY = OFF; + USE [{_database}]; CREATE USER [{login}] FOR LOGIN [{login}]; ALTER ROLE db_datareader ADD MEMBER [{login}]; + """); + _webConnectionString = new SqlConnectionStringBuilder(sql.DatabaseConnectionString(_database)) + { + UserID = login, Password = password, IntegratedSecurity = false, + }.ConnectionString; + return new InsightRepository(_webConnectionString); + } + + private static DiscoveredDatabase Db(string name, bool ax = false, bool system = false) => + new() { Name = name, DatabaseId = 5, State = "ONLINE", SizeMb = 100, RecoveryModel = "FULL", CompatibilityLevel = 150, HasAccess = true, IsSystem = system, IsDynamicsAx = ax, DynamicsAxVersion = ax ? "3.0" : null }; + + [Fact] + public async Task Web_login_can_change_the_configuration_and_queue_requests_but_nothing_the_agent_records() + { + var web = await ReadOnlyRepositoryAsync(); + var now = DateTimeOffset.UtcNow; + + var id = await web.AddConfiguredInstanceAsync(Instance("prod-sql01"), @"FIRMA\anna", now); + Assert.Equal(ConfigurationChangeOutcome.Changed, await web.UpdateConfiguredInstanceAsync(id, + new ConfiguredInstanceChange("sql02", null, "Windows", null, null, null, ClearPassword: true, "Optional", true), @"FIRMA\anna", now)); + Assert.Equal(ConfigurationChangeOutcome.Changed, await web.SetInstancePausedAsync(id, true, @"FIRMA\anna", now)); + Assert.Equal(ConfigurationChangeOutcome.Changed, await web.SetMonitorNewDatabasesAsync(id, true, @"FIRMA\anna", now)); + var request = await web.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, @"FIRMA\anna", now); + + var row = (await _repository.GetConfiguredInstanceAsync(id))!; + Assert.Equal(("sql02", "Windows", false, true, true, InstanceSource.Web), (row.Server, row.Authentication, row.HasPassword, row.Paused, row.MonitorNewDatabases, row.Source)); + Assert.Equal(AgentRequestStatus.Pending, (await web.GetAgentRequestAsync(request))!.Status); + + // What only the agent does is refused for the web login. + 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}")); + + Assert.Equal(ConfigurationChangeOutcome.Changed, await web.RemoveConfiguredInstanceAsync(id, @"FIRMA\anna", now)); + Assert.Null(await _repository.GetConfiguredInstanceAsync(id)); + Assert.Empty(await _repository.GetConfiguredInstancesAsync()); + } + + [Fact] + public async Task Web_login_chooses_databases_the_agent_found_and_ax_to_hub_sticks_only_to_ax_databases() + { + var web = await ReadOnlyRepositoryAsync(); + var now = DateTimeOffset.UtcNow; + var id = await web.AddConfiguredInstanceAsync(Instance("ax-sql"), null, now); + await _repository.SaveDiscoveredDatabasesAsync(id, [Db("master", system: true), Db("AX30", ax: true), Db("Sales")], now); + + var unknown = await web.SetDatabaseChoicesAsync(id, + [ + new DatabaseChoice("AX30", true, true), new DatabaseChoice("Sales", true, true), new DatabaseChoice("master", false, false), new DatabaseChoice("Nope", true, false), + ], @"FIRMA\anna", now); + + Assert.Equal(["Nope"], unknown); + var databases = (await _repository.GetConfiguredDatabasesAsync(id)).ToDictionary(d => d.DatabaseName); + Assert.Equal((true, true), (databases["AX30"].Monitored, databases["AX30"].SendDynamicsAxToHub)); + Assert.Equal((true, false), (databases["Sales"].Monitored, databases["Sales"].SendDynamicsAxToHub)); // not AX: never to the hub + Assert.True(databases["master"].Monitored); // system databases are always monitored + Assert.Equal(@"FIRMA\anna", databases["AX30"].SelectionChangedBy); + await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredDatabases SET IsDynamicsAx = 1 WHERE ConfiguredInstanceId = {id}")); + } + + [Fact] + public async Task Discovery_adds_new_databases_by_the_instance_setting_and_marks_the_ones_gone() + { + var now = DateTimeOffset.UtcNow; + var manual = await _repository.AddConfiguredInstanceAsync(Instance("manual"), null, now); + var automatic = await _repository.AddConfiguredInstanceAsync(Instance("automatic", monitorNew: true), null, now); + + var first = await _repository.SaveDiscoveredDatabasesAsync(manual, [Db("master", system: true), Db("Sales"), Db("Old")], now); + await _repository.SaveDiscoveredDatabasesAsync(automatic, [Db("Sales")], now); + var second = await _repository.SaveDiscoveredDatabasesAsync(manual, [Db("master", system: true), Db("Sales"), Db("New")], now.AddHours(1)); + + Assert.Equal(["master", "Sales", "Old"], first.New); + Assert.Equal(["New"], second.New); + Assert.Equal(["Old"], second.Gone); + var databases = (await _repository.GetConfiguredDatabasesAsync(manual)).ToDictionary(d => d.DatabaseName); + Assert.Equal((true, false, false), (databases["master"].Monitored, databases["Sales"].Monitored, databases["New"].Monitored)); + Assert.True(databases["Old"].Gone); + Assert.True((await _repository.GetConfiguredDatabasesAsync(automatic)).Single().Monitored); + Assert.NotNull((await _repository.GetConfiguredInstanceAsync(manual))!.LastDiscoveryAt); + } + + [Fact] + public async Task Configuration_file_instances_are_mirrored_read_only_and_win_over_a_web_instance_with_the_same_name() + { + var now = DateTimeOffset.UtcNow; + var web = await _repository.AddConfiguredInstanceAsync(Instance("prod-sql02"), null, now); + + var ids = await _repository.SyncConfigFileInstancesAsync( + [ + new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false), + new ConfigFileInstance("prod-sql02", "sql02", "SqlLogin", "mon", "Optional", true), + ], now); + + var rows = (await _repository.GetConfiguredInstancesAsync()).ToDictionary(r => r.Name); + Assert.Equal(2, rows.Count); + Assert.All(rows.Values, r => Assert.True(r.FromConfigFile && r.MonitorNewDatabases && !r.HasPassword)); + Assert.Null(await _repository.GetConfiguredInstanceAsync(web)); + Assert.Equal(ConfigurationChangeOutcome.FromConfigFile, await _repository.RemoveConfiguredInstanceAsync(ids["prod-sql01"], null, now)); + Assert.Equal(ConfigurationChangeOutcome.FromConfigFile, await _repository.UpdateConfiguredInstanceAsync(ids["prod-sql01"], + 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. + 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)); + } + + [Fact] + public async Task A_request_is_claimed_once_and_stale_ones_are_failed_with_a_reason() + { + var now = DateTimeOffset.UtcNow; + var first = await _repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, "{\"host\":null}", "anna", now); + var second = await _repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, null, "anna", now); + + var claimed = await Task.WhenAll(_repository.ClaimAgentRequestAsync(now), _repository.ClaimAgentRequestAsync(now)); + Assert.Equal([first, second], claimed.Select(c => c!.RequestId).Order()); + Assert.Null(await _repository.ClaimAgentRequestAsync(now)); + + await _repository.CompleteAgentRequestAsync(first, true, "{\"instances\":[]}", null, now); + var third = await _repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, null, "anna", now.AddHours(-2)); + await _repository.CleanUpAgentRequestsAsync(now.AddMinutes(30), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(30), TimeSpan.FromDays(30)); + + Assert.Equal(AgentRequestStatus.Done, (await _repository.GetAgentRequestAsync(first))!.Status); + Assert.Contains("stoppet", (await _repository.GetAgentRequestAsync(second))!.Error, StringComparison.Ordinal); + Assert.Contains("tog ikke anmodningen op", (await _repository.GetAgentRequestAsync(third))!.Error, StringComparison.Ordinal); + Assert.Equal(third, (await _repository.GetLatestAgentRequestAsync(null, AgentRequestKind.InstanceDiscovery))!.RequestId); + } + + [Fact] + public async Task The_newest_agent_key_is_the_one_passwords_are_sealed_with() + { + var old = DateTime.UtcNow.AddDays(-1); + await _repository.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "old", PublicKey = [1], MachineName = "AGENT00", ServiceAccount = "a", CreatedAt = old, LastSeenAt = old }); + await _repository.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "new", PublicKey = [2], MachineName = "AGENT01", ServiceAccount = @"NT SERVICE\SQLInsight Agent", Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow }); + + var key = await (await ReadOnlyRepositoryAsync()).GetCurrentAgentKeyAsync(); + + Assert.Equal(("new", "AGENT01", "FIRMA"), (key!.KeyId, key.MachineName, key.Domain)); + } + + private async Task ExecuteAsWebAsync(string sql) + { + await using var connection = new SqlConnection(_webConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } +}