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(); + } +}