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