diff --git a/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs b/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs new file mode 100644 index 0000000..4a71c9b --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.ServerFindings.cs @@ -0,0 +1,257 @@ +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Servers; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Repository; + +/// What a server finding is about: a Windows server or a synthetic check (insight.ServerFindings.Scope). +public static class FindingScope +{ + public const string Server = "Server"; + public const string Check = "Check"; +} + +/// A finding about a Windows server or a synthetic check, as stored. +public sealed record StoredServerFinding +{ + public long FindingId { get; init; } + public string Scope { get; init; } = FindingScope.Server; + public int ScopeId { get; init; } + + /// The server's or the check's name. + public string ScopeName { get; init; } = string.Empty; + + public string RuleId { get; init; } = string.Empty; + public string Subject { get; init; } = string.Empty; + public Severity Severity { get; init; } + public string Title { get; init; } = string.Empty; + public string Explanation { get; init; } = string.Empty; + public string Recommendation { get; init; } = string.Empty; + public IReadOnlyList Evidence { get; init; } = []; + public IReadOnlyList References { get; init; } = []; + public DateTime FirstSeenAt { get; init; } + public DateTime LastSeenAt { get; init; } + public DateTime? ResolvedAt { get; init; } +} + +public sealed partial class InsightRepository +{ + private sealed record ServerFindingRow(long FindingId, string Scope, int ScopeId, string? ScopeName, string RuleId, string Subject, byte Severity, string Title, + string Explanation, string Recommendation, string EvidenceJson, string ReferencesJson, DateTime FirstSeenAt, DateTime LastSeenAt, DateTime? ResolvedAt); + + private const string ServerFindingColumns = """ + f.FindingId, f.Scope, f.ScopeId, COALESCE(w.Name, c.Name) AS ScopeName, f.RuleId, f.Subject, f.Severity, f.Title, f.Explanation, f.Recommendation, + f.EvidenceJson, f.ReferencesJson, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt + """; + + private const string ServerFindingFrom = """ + insight.ServerFindings f + LEFT JOIN insight.WindowsServers w ON f.Scope = 'Server' AND w.ServerId = f.ScopeId + LEFT JOIN insight.SyntheticChecks c ON f.Scope = 'Check' AND c.CheckId = f.ScopeId + """; + + /// + /// Stores the rule outcomes of one server or check and opens, updates and resolves its findings, like + /// for instances. Returns what changed, for notifications. + /// + public async Task SaveScopedRuleResultsAsync(string scope, int scopeId, IReadOnlyList results, DateTimeOffset evaluatedAt, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(results); + for (var attempt = 1; ; attempt++) + { + try + { + return await SaveScopedRuleResultsOnceAsync(scope, scopeId, results, evaluatedAt, cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) when (ex.Number == DeadlockVictim && attempt < 3) + { + await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task SaveScopedRuleResultsOnceAsync(string scope, int scopeId, IReadOnlyList results, DateTimeOffset evaluatedAt, + CancellationToken cancellationToken) + { + var now = Utc(evaluatedAt); + var opened = new List<(long, Finding)>(); + var resolved = new List(); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync( + "EXEC sp_getapplock @Resource = N'SQLInsight:ServerFindings', @LockMode = N'Exclusive', @LockOwner = N'Transaction', @LockTimeout = 60000;", + transaction: transaction).ConfigureAwait(false); + foreach (var result in results) + { + await connection.ExecuteAsync(""" + MERGE insight.ServerRuleStates AS t + USING (SELECT @scope AS Scope, @scopeId AS ScopeId, @ruleId AS RuleId) AS s ON t.Scope = s.Scope AND t.ScopeId = s.ScopeId AND t.RuleId = s.RuleId + WHEN MATCHED THEN UPDATE SET RuleTitle = @title, Outcome = @outcome, Reason = @reason, EvaluatedAt = @now + WHEN NOT MATCHED THEN INSERT (Scope, ScopeId, RuleId, RuleTitle, Outcome, Reason, EvaluatedAt) VALUES (@scope, @scopeId, @ruleId, @title, @outcome, @reason, @now); + """, new { scope, scopeId, ruleId = result.RuleId, title = result.RuleTitle, outcome = (byte)result.Outcome, reason = result.Reason, now }, transaction) + .ConfigureAwait(false); + + // Not measured this time: whatever was found before stays open (a missing right should not "resolve" a full disk). + if (result.Outcome is not (RuleOutcome.Passed or RuleOutcome.Findings or RuleOutcome.NotApplicable)) + { + continue; + } + + var current = result.Findings.Select(f => f.Subject ?? string.Empty).ToList(); + resolved.AddRange(await connection.QueryAsync(""" + UPDATE insight.ServerFindings SET ResolvedAt = @now + OUTPUT inserted.FindingId, inserted.RuleId, inserted.Subject, inserted.Title, inserted.Severity + WHERE Scope = @scope AND ScopeId = @scopeId AND RuleId = @ruleId AND ResolvedAt IS NULL + AND Subject NOT IN (SELECT value FROM OPENJSON(@current)); + """, new { scope, scopeId, ruleId = result.RuleId, now, current = JsonSerializer.Serialize(current, Json) }, transaction).ConfigureAwait(false)); + + foreach (var finding in result.Findings) + { + var newId = await connection.QuerySingleOrDefaultAsync(""" + UPDATE insight.ServerFindings + SET Severity = @severity, Title = @title, Explanation = @explanation, Recommendation = @recommendation, + EvidenceJson = @evidence, ReferencesJson = @references, LastSeenAt = @now + WHERE Scope = @scope AND ScopeId = @scopeId AND RuleId = @ruleId AND Subject = @subject AND ResolvedAt IS NULL; + + IF @@ROWCOUNT = 0 + INSERT insight.ServerFindings (Scope, ScopeId, RuleId, Subject, Severity, Title, Explanation, Recommendation, EvidenceJson, ReferencesJson, FirstSeenAt, LastSeenAt) + OUTPUT inserted.FindingId + VALUES (@scope, @scopeId, @ruleId, @subject, @severity, @title, @explanation, @recommendation, @evidence, @references, @now, @now); + """, new + { + scope, + scopeId, + ruleId = finding.RuleId, + subject = finding.Subject ?? string.Empty, + severity = (byte)finding.Severity, + title = finding.Title, + explanation = finding.Explanation, + recommendation = finding.Recommendation, + evidence = JsonSerializer.Serialize(finding.Evidence, Json), + references = JsonSerializer.Serialize(finding.References, Json), + now, + }, transaction).ConfigureAwait(false); + if (newId is { } id) + { + opened.Add((id, finding)); + } + } + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return new FindingChanges(opened, resolved); + } + + /// Server and check findings: active, resolved or all; for one server or check, or all of them. + public async Task> GetServerFindingsAsync(string? scope = null, int? scopeId = null, FindingStatus status = FindingStatus.Active, + int top = 500, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync($""" + SELECT TOP (@top) {ServerFindingColumns} + FROM {ServerFindingFrom} + WHERE (@scope IS NULL OR f.Scope = @scope) AND (@scopeId IS NULL OR f.ScopeId = @scopeId) + AND (@status = 2 OR (@status = 0 AND f.ResolvedAt IS NULL) OR (@status = 1 AND f.ResolvedAt IS NOT NULL)) + ORDER BY CASE WHEN f.ResolvedAt IS NULL THEN 0 ELSE 1 END, f.Severity DESC, f.FirstSeenAt, f.FindingId; + """, new { scope, scopeId, status = (int)status, top }).ConfigureAwait(false); + return rows.Select(Map).ToList(); + } + + public async Task GetServerFindingAsync(long id, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var row = await connection.QuerySingleOrDefaultAsync( + $"SELECT {ServerFindingColumns} FROM {ServerFindingFrom} WHERE f.FindingId = @id;", new { id }).ConfigureAwait(false); + return row is null ? null : Map(row); + } + + /// The earlier occurrences of the same finding (same server or check, rule and subject). + public async Task> GetServerFindingHistoryAsync(StoredServerFinding finding, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(finding); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync($""" + SELECT TOP (20) {ServerFindingColumns} FROM {ServerFindingFrom} + WHERE f.Scope = @Scope AND f.ScopeId = @ScopeId AND f.RuleId = @RuleId AND f.Subject = @Subject AND f.FindingId <> @FindingId + ORDER BY f.FirstSeenAt DESC; + """, new { finding.Scope, finding.ScopeId, finding.RuleId, finding.Subject, finding.FindingId }).ConfigureAwait(false); + return rows.Select(Map).ToList(); + } + + public async Task> GetServerRuleStatesAsync(string scope, int scopeId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string RuleId, string RuleTitle, byte Outcome, string? Reason, DateTime EvaluatedAt)>( + "SELECT RuleId, RuleTitle, Outcome, Reason, EvaluatedAt FROM insight.ServerRuleStates WHERE Scope = @scope AND ScopeId = @scopeId ORDER BY RuleId;", + new { scope, scopeId }).ConfigureAwait(false); + return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList(); + } + + /// Active findings per server or check and severity, for overviews. + public async Task>> GetServerFindingCountsAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string Scope, int ScopeId, byte Severity, int Count)>( + "SELECT Scope, ScopeId, Severity, COUNT(*) FROM insight.ServerFindings WHERE ResolvedAt IS NULL GROUP BY Scope, ScopeId, Severity;").ConfigureAwait(false); + return rows.GroupBy(r => (r.Scope, r.ScopeId)).ToDictionary( + g => g.Key, + g => (IReadOnlyDictionary)g.ToDictionary(r => (Severity)r.Severity, r => r.Count)); + } + + private static StoredServerFinding Map(ServerFindingRow r) => new() + { + FindingId = r.FindingId, + Scope = r.Scope, + ScopeId = r.ScopeId, + ScopeName = r.ScopeName ?? string.Empty, + RuleId = r.RuleId, + Subject = r.Subject, + Severity = (Severity)r.Severity, + Title = r.Title, + Explanation = r.Explanation, + Recommendation = r.Recommendation, + Evidence = JsonSerializer.Deserialize>(r.EvidenceJson, Json) ?? [], + References = JsonSerializer.Deserialize>(r.ReferencesJson, Json) ?? [], + FirstSeenAt = r.FirstSeenAt, + LastSeenAt = r.LastSeenAt, + ResolvedAt = r.ResolvedAt, + }; + + // ---- Requests about servers and checks --------------------------------------------------------------------- + + /// Queues a server test or a check run for the agent (SQLInsight Web: INSERT only). + public async Task AddServerRequestAsync(string kind, int targetId, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + if (!ServerRequestKind.Handles(kind)) + { + throw new ArgumentException($"Ukendt anmodning: {kind}.", nameof(kind)); + } + + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + INSERT insight.AgentRequests (Kind, TargetId, Status, RequestedBy, RequestedAt) + OUTPUT INSERTED.RequestId + VALUES (@kind, @targetId, 'Pending', @by, @now); + """, new { kind, targetId, by, now = Utc(now) }).ConfigureAwait(false); + } + + /// The newest request of a kind about a server or check. + public async Task GetLatestServerRequestAsync(string kind, int targetId, 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 TargetId = @targetId ORDER BY RequestId DESC;", new { kind, targetId }).ConfigureAwait(false); + } + + /// The server or check a request is about (insight.AgentRequests.TargetId). + public async Task GetRequestTargetAsync(long requestId, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync("SELECT TargetId FROM insight.AgentRequests WHERE RequestId = @requestId;", new { requestId }).ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs b/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs new file mode 100644 index 0000000..0f98f13 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.SyntheticChecks.cs @@ -0,0 +1,245 @@ +using System.Text.Json; +using Dapper; +using SQLInsight.Core.SyntheticChecks; + +namespace SQLInsight.Repository; + +/// A synthetic check (insight.SyntheticChecks). +public sealed record SyntheticCheckRow +{ + public int CheckId { get; init; } + public string Name { get; init; } = string.Empty; + public string Kind { get; init; } = SyntheticCheckKind.Tcp; + + /// The Windows server the check belongs to (and whose service it looks at), if any. + public int? ServerId { get; init; } + + public string? ServerName { get; init; } + public string SettingsJson { get; init; } = "{}"; + + /// The sealed SQL login password. Only the agent can open it; never shown or returned. + public byte[]? SecretCipher { get; init; } + + public string? SecretKeyId { get; init; } + public int IntervalSeconds { get; init; } = SyntheticCheckLimits.DefaultIntervalSeconds; + public int TimeoutSeconds { get; init; } = SyntheticCheckLimits.DefaultTimeoutSeconds; + public bool Paused { 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; } + + /// The agent could not open the stored password (Danish). + public string? SecretError { get; init; } + + public DateTime? LastRunAt { get; init; } + public bool? LastSuccess { get; init; } + public int? LastDurationMs { get; init; } + public string? LastMessage { get; init; } + public int ConsecutiveFailures { get; init; } + + public SyntheticCheckSettings Settings => SyntheticCheckSettings.FromJson(SettingsJson); + + public bool HasSecret => SecretCipher is { Length: > 0 }; +} + +/// A new or changed check from SQLInsight Web. A null on a change keeps the stored password. +public sealed record SyntheticCheckInput( + string Name, + string Kind, + int? ServerId, + SyntheticCheckSettings Settings, + byte[]? SecretCipher, + string? SecretKeyId, + int IntervalSeconds, + int TimeoutSeconds, + bool ClearSecret = false, + bool Paused = false); + +/// One stored run of a check. +public sealed record StoredSyntheticCheckResult +{ + public long ResultId { get; init; } + public int CheckId { get; init; } + public DateTime RunAt { get; init; } + public bool Success { get; init; } + public int DurationMs { get; init; } + public string Message { get; init; } = string.Empty; + public string? DetailsJson { get; init; } + public DateTime? CertificateExpiresAt { get; init; } + public string? CertificateSubject { get; init; } + public string? ServiceState { get; init; } + public bool Partial { get; init; } + + public IReadOnlyList Details + { + get + { + if (string.IsNullOrWhiteSpace(DetailsJson)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(DetailsJson, InsightRepository.Json) ?? []; + } + catch (JsonException) + { + return []; + } + } + } + + public SyntheticCheckResult ToResult() => new() + { + At = new DateTimeOffset(DateTime.SpecifyKind(RunAt, DateTimeKind.Utc)), + Success = Success, + DurationMs = DurationMs, + Message = Message, + Details = Details, + CertificateExpiresAt = CertificateExpiresAt is { } c ? new DateTimeOffset(DateTime.SpecifyKind(c, DateTimeKind.Utc)) : null, + CertificateSubject = CertificateSubject, + ServiceState = ServiceState, + Partial = Partial, + }; +} + +public sealed partial class InsightRepository +{ + private const string CheckColumns = """ + c.CheckId, c.Name, c.Kind, c.ServerId, w.Name AS ServerName, c.SettingsJson, c.SecretCipher, c.SecretKeyId, c.IntervalSeconds, c.TimeoutSeconds, c.Paused, + c.CreatedAt, c.CreatedBy, c.UpdatedAt, c.UpdatedBy, c.RemovedAt, c.SecretError, c.LastRunAt, c.LastSuccess, c.LastDurationMs, c.LastMessage, c.ConsecutiveFailures + """; + + /// The checks (not removed), by name. + public async Task> GetSyntheticChecksAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync($""" + SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId + WHERE c.RemovedAt IS NULL ORDER BY c.Name; + """).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task GetSyntheticCheckAsync(int id, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync($""" + SELECT {CheckColumns} FROM insight.SyntheticChecks c LEFT JOIN insight.WindowsServers w ON w.ServerId = c.ServerId + WHERE c.CheckId = @id AND c.RemovedAt IS NULL; + """, new { id }).ConfigureAwait(false); + } + + /// Adds a check (SQLInsight Web: INSERT only). A name in use throws SqlException 2601. + public async Task AddSyntheticCheckAsync(SyntheticCheckInput check, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + INSERT insight.SyntheticChecks (Name, Kind, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused, + CreatedAt, CreatedBy, UpdatedAt, UpdatedBy) + OUTPUT INSERTED.CheckId + VALUES (@name, @Kind, @ServerId, @settings, @SecretCipher, @SecretKeyId, @IntervalSeconds, @TimeoutSeconds, @Paused, @now, @by, @now, @by); + """, new + { + name = check.Name.Trim(), check.Kind, check.ServerId, settings = check.Settings.ToJson(), check.SecretCipher, check.SecretKeyId, + check.IntervalSeconds, check.TimeoutSeconds, check.Paused, by, now = Utc(now), + }).ConfigureAwait(false); + } + + /// Changes a check (not its kind). A name in use throws SqlException 2601. + public async Task UpdateSyntheticCheckAsync(int id, SyntheticCheckInput check, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteAsync(""" + UPDATE insight.SyntheticChecks + SET Name = @name, ServerId = @ServerId, SettingsJson = @settings, IntervalSeconds = @IntervalSeconds, TimeoutSeconds = @TimeoutSeconds, + SecretCipher = CASE WHEN @ClearSecret = 1 THEN NULL WHEN @SecretCipher IS NOT NULL THEN @SecretCipher ELSE SecretCipher END, + SecretKeyId = CASE WHEN @ClearSecret = 1 THEN NULL WHEN @SecretCipher IS NOT NULL THEN @SecretKeyId ELSE SecretKeyId END, + SecretError = CASE WHEN @ClearSecret = 1 OR @SecretCipher IS NOT NULL THEN NULL ELSE SecretError END, + UpdatedAt = @now, UpdatedBy = @by + WHERE CheckId = @id AND RemovedAt IS NULL AND Kind = @Kind; + """, new + { + id, name = check.Name.Trim(), check.Kind, check.ServerId, settings = check.Settings.ToJson(), check.SecretCipher, check.SecretKeyId, check.ClearSecret, + check.IntervalSeconds, check.TimeoutSeconds, by, now = Utc(now), + }).ConfigureAwait(false) > 0; + } + + public async Task SetSyntheticCheckPausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteAsync( + "UPDATE insight.SyntheticChecks SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE CheckId = @id AND RemovedAt IS NULL;", + new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0; + } + + public async Task RemoveSyntheticCheckAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteAsync( + "UPDATE insight.SyntheticChecks SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by WHERE CheckId = @id AND RemovedAt IS NULL;", + new { id, by, now = Utc(now) }).ConfigureAwait(false) > 0; + } + + /// The latest runs of a check, newest first. + public async Task> GetSyntheticCheckResultsAsync(int id, int top = 50, DateTimeOffset? from = null, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT TOP (@top) ResultId, CheckId, RunAt, Success, DurationMs, Message, DetailsJson, CertificateExpiresAt, CertificateSubject, ServiceState, [Partial] + FROM insight.SyntheticCheckResults WHERE CheckId = @id AND (@from IS NULL OR RunAt >= @from) + ORDER BY RunAt DESC, ResultId DESC; + """, new { id, top, from = from?.UtcDateTime }).ConfigureAwait(false); + return rows.ToList(); + } + + // ---- The agent ---------------------------------------------------------------------------------------------- + + /// Stores a run and the check's latest state (and how many times in a row it has failed). + public async Task RecordSyntheticCheckResultAsync(int id, SyntheticCheckResult result, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(result); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + INSERT insight.SyntheticCheckResults (CheckId, RunAt, Success, DurationMs, Message, DetailsJson, CertificateExpiresAt, CertificateSubject, ServiceState, [Partial]) + VALUES (@id, @at, @Success, @DurationMs, LEFT(@Message, 1000), @details, @certificate, LEFT(@CertificateSubject, 400), LEFT(@ServiceState, 32), @Partial); + UPDATE insight.SyntheticChecks + SET LastRunAt = @at, LastSuccess = @Success, LastDurationMs = @DurationMs, LastMessage = LEFT(@Message, 1000), + ConsecutiveFailures = CASE WHEN @Success = 1 THEN 0 ELSE ConsecutiveFailures + 1 END + WHERE CheckId = @id; + """, new + { + id, at = Utc(result.At), result.Success, result.DurationMs, result.Message, + details = SerializeDetails(result.Details), + certificate = result.CertificateExpiresAt is { } c ? Utc(c) : (DateTime?)null, result.CertificateSubject, result.ServiceState, result.Partial, + }).ConfigureAwait(false); + } + + /// At most 20 details of at most 400 characters, so the JSON always fits (never cut in the middle). + private static string? SerializeDetails(IReadOnlyList details) + { + if (details.Count == 0) + { + return null; + } + + var json = JsonSerializer.Serialize(details.Take(20).Select(d => d with { Name = Cut(d.Name, 100), Value = Cut(d.Value, 400) }), Json); + return json.Length <= 4000 ? json : null; + } + + /// The agent could (null) or could not open a check's stored password. + public async Task SetSyntheticCheckSecretErrorAsync(int id, string? error, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.SyntheticChecks SET SecretError = LEFT(@error, 1000) + WHERE CheckId = @id AND ISNULL(SecretError, N'') <> ISNULL(@error, N''); + """, new { id, error }).ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs b/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs new file mode 100644 index 0000000..51f1900 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.WindowsServers.cs @@ -0,0 +1,411 @@ +using System.Globalization; +using System.Text.Json; +using Dapper; +using SQLInsight.Core.Servers; + +namespace SQLInsight.Repository; + +/// A Windows server the agent looks at (insight.WindowsServers). +public sealed record WindowsServerRow +{ + public int ServerId { get; init; } + public string Name { get; init; } = string.Empty; + public string Host { get; init; } = string.Empty; + public string Role { get; init; } = ServerRoles.Other; + public string Source { get; init; } = ServerSources.Web; + public bool Paused { get; init; } + public string? WatchedServices { get; init; } + public string? IgnoredServices { 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? LastContactAt { get; init; } + public DateTime? LastErrorAt { get; init; } + + /// Why the agent could not reach the server last time (Danish), when that is newer than the last contact. + public string? LastError { get; init; } + + public string? Protocol { get; init; } + public string? OperatingSystem { get; init; } + public string? OsVersion { get; init; } + public DateTime? LastBootUpTime { get; init; } + public double? TotalMemoryMb { get; init; } + public int? LogicalProcessors { get; init; } + public string? AreasJson { get; init; } + + public bool IsSqlHost => Source == ServerSources.SqlHost; + + public IReadOnlyList Watched => InsightRepository.ReadList(WatchedServices); + + public IReadOnlyList Ignored => InsightRepository.ReadList(IgnoredServices); + + /// Which areas the agent could read at the last collection, and why not. + public IReadOnlyList Areas => + string.IsNullOrWhiteSpace(AreasJson) ? [] : JsonSerializer.Deserialize>(AreasJson, InsightRepository.Json) ?? []; + + /// The agent reached the server at its last attempt. + public bool Reachable => LastContactAt is { } contact && (LastErrorAt is null || LastErrorAt <= contact); + + public ServerIdentity ToIdentity() => new(ServerId, Name, Host, Role) { WatchedServices = Watched, IgnoredServices = Ignored }; +} + +/// A new Windows server from SQLInsight Web. +public sealed record NewWindowsServer(string Name, string Host, string Role, IReadOnlyList WatchedServices, IReadOnlyList IgnoredServices, bool Paused = false); + +/// A server's CPU and memory over time. +public sealed record StoredServerPerformance(DateTime SampledAt, double? CpuPercent, double? AvailableMb, double? AvailablePct, double? PagesPerSec, double? CommitPct); + +/// A Windows event group as stored. is sensitive (local UI only). +public sealed record StoredServerEvent(long EventRowId, DateTime CollectedAt, string LogName, string Source, long EventId, byte Level, string Category, int Count, + DateTime FirstAt, DateTime LastAt, string? Sample); + +public sealed partial class InsightRepository +{ + private const string ServerColumns = """ + ServerId, Name, Host, Role, Source, Paused, WatchedServices, IgnoredServices, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy, RemovedAt, + LastContactAt, LastErrorAt, LastError, Protocol, OperatingSystem, OsVersion, LastBootUpTime, TotalMemoryMb, LogicalProcessors, AreasJson + """; + + internal static IReadOnlyList ReadList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(json, Json) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static string? WriteList(IReadOnlyList? list) => list is null || list.Count == 0 ? null : JsonSerializer.Serialize(list, Json); + + /// The Windows servers (not removed), by name. + public async Task> GetWindowsServersAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync( + $"SELECT {ServerColumns} FROM insight.WindowsServers WHERE RemovedAt IS NULL ORDER BY Name;").ConfigureAwait(false); + return rows.ToList(); + } + + public async Task GetWindowsServerAsync(int id, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync( + $"SELECT {ServerColumns} FROM insight.WindowsServers WHERE ServerId = @id AND RemovedAt IS NULL;", new { id }).ConfigureAwait(false); + } + + /// Adds a server (SQLInsight Web: INSERT only). A name or host in use throws SqlException 2601. + public async Task AddWindowsServerAsync(NewWindowsServer server, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync(""" + INSERT insight.WindowsServers (Name, Host, Role, Source, Paused, WatchedServices, IgnoredServices, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy) + OUTPUT INSERTED.ServerId + VALUES (@name, @host, @Role, 'Web', @Paused, @watched, @ignored, @now, @by, @now, @by); + """, new + { + name = server.Name.Trim(), host = ServerHost.Normalize(server.Host), server.Role, server.Paused, + watched = WriteList(server.WatchedServices), ignored = WriteList(server.IgnoredServices), by, now = Utc(now), + }).ConfigureAwait(false); + } + + /// Role and the services to watch and ignore. Allowed for SQL Server hosts too. + public async Task UpdateWindowsServerAsync(int id, string role, IReadOnlyList watched, IReadOnlyList ignored, string? by, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteAsync(""" + UPDATE insight.WindowsServers SET Role = @role, WatchedServices = @watched, IgnoredServices = @ignored, UpdatedAt = @now, UpdatedBy = @by + WHERE ServerId = @id AND RemovedAt IS NULL; + """, new { id, role, watched = WriteList(watched), ignored = WriteList(ignored), by, now = Utc(now) }).ConfigureAwait(false) > 0; + } + + public async Task SetWindowsServerPausedAsync(int id, bool paused, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteAsync( + "UPDATE insight.WindowsServers SET Paused = @paused, UpdatedAt = @now, UpdatedBy = @by WHERE ServerId = @id AND RemovedAt IS NULL;", + new { id, paused, by, now = Utc(now) }).ConfigureAwait(false) > 0; + } + + /// Stops looking at a server added in SQLInsight Web. A SQL Server host follows its instances; it can be paused. + public async Task RemoveWindowsServerAsync(int id, string? by, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var removed = await connection.ExecuteAsync(""" + UPDATE insight.WindowsServers SET RemovedAt = @now, RemovedBy = @by, UpdatedAt = @now, UpdatedBy = @by + WHERE ServerId = @id AND RemovedAt IS NULL AND Source = 'Web'; + """, new { id, by, now = Utc(now) }).ConfigureAwait(false); + if (removed > 0) + { + return ConfigurationChangeOutcome.Changed; + } + + return await GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false) is { IsSqlHost: true } + ? ConfigurationChangeOutcome.FromConfigFile + : ConfigurationChangeOutcome.NotFound; + } + + // ---- The agent ---------------------------------------------------------------------------------------------- + + /// + /// Brings the SQL Server hosts in step with the monitored instances: a host not yet known (from any source) is + /// added, one no instance uses any more is removed. Pause and service lists of the rows that stay are kept. + /// + public async Task SyncSqlHostServersAsync(IReadOnlyList<(string Host, string Name)> hosts, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(hosts); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + var at = Utc(now); + var normalized = hosts.Select(h => (Host: ServerHost.Normalize(h.Host), h.Name)).DistinctBy(h => h.Host, StringComparer.OrdinalIgnoreCase).ToList(); + await connection.ExecuteAsync(""" + UPDATE insight.WindowsServers SET RemovedAt = @at, RemovedBy = N'SQLInsight Agent: ingen overvåget instans på serveren', UpdatedAt = @at + WHERE Source = 'SqlHost' AND RemovedAt IS NULL AND Host NOT IN (SELECT value FROM OPENJSON(@hosts)); + """, new { hosts = JsonSerializer.Serialize(normalized.Select(h => h.Host)), at }, transaction).ConfigureAwait(false); + foreach (var (host, name) in normalized) + { + await connection.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM insight.WindowsServers WITH (UPDLOCK, HOLDLOCK) WHERE Host = @host AND RemovedAt IS NULL) + INSERT insight.WindowsServers (Name, Host, Role, Source, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy) + VALUES (CASE WHEN EXISTS (SELECT 1 FROM insight.WindowsServers WHERE Name = @name AND RemovedAt IS NULL) THEN @host ELSE @name END, + @host, 'Sql', 'SqlHost', @at, N'SQLInsight Agent (SQL Server-host)', @at, N'SQLInsight Agent (SQL Server-host)'); + """, new { host, name, at }, transaction).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + /// Records a successful contact: what the server is, and which areas could be read. + public async Task RecordServerContactAsync(int id, ServerOsInfo os, string? protocol, IReadOnlyList? areas, DateTimeOffset now, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(os); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.WindowsServers + SET LastContactAt = @now, Protocol = @protocol, OperatingSystem = LEFT(@caption, 256), OsVersion = LEFT(@version, 64), LastBootUpTime = @boot, + TotalMemoryMb = @memory, LogicalProcessors = @processors, AreasJson = COALESCE(@areas, AreasJson) + WHERE ServerId = @id; + """, new + { + id, now = Utc(now), protocol, caption = os.Caption, version = os.Version, boot = os.LastBootUpTime is { } b ? Utc(b) : (DateTime?)null, + memory = os.TotalMemoryMb, processors = os.LogicalProcessors, areas = areas is null ? null : JsonSerializer.Serialize(areas, Json), + }).ConfigureAwait(false); + } + + /// Which areas could be read (and why not), after any area's collection. + public async Task RecordServerAreasAsync(int id, IReadOnlyList areas, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(areas); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync("UPDATE insight.WindowsServers SET AreasJson = @areas WHERE ServerId = @id;", + new { id, areas = JsonSerializer.Serialize(areas, Json) }).ConfigureAwait(false); + } + + public async Task RecordServerErrorAsync(int id, string error, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync("UPDATE insight.WindowsServers SET LastErrorAt = @now, LastError = LEFT(@error, 1000) WHERE ServerId = @id;", + new { id, error, now = Utc(now) }).ConfigureAwait(false); + } + + public async Task SaveServerPerformanceAsync(int id, ServerPerformanceSample sample, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sample); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + IF NOT EXISTS (SELECT 1 FROM insight.ServerPerformance WHERE ServerId = @id AND SampledAt = @at) + INSERT insight.ServerPerformance (ServerId, SampledAt, CpuPercent, AvailableMb, AvailablePct, PagesPerSec, CommitPct) + VALUES (@id, @at, @cpu, @available, @availablePct, @pages, @commit); + """, new + { + id, at = Utc(sample.At), cpu = sample.CpuPercent, available = sample.AvailableMb, availablePct = sample.AvailablePercent, pages = sample.PagesPerSecond, + commit = sample.CommitPercent, + }).ConfigureAwait(false); + } + + /// The samples since , oldest first: the agent's window after a restart, and the charts. + public async Task> GetServerPerformanceAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT SampledAt, CAST(CpuPercent AS float) AS CpuPercent, CAST(AvailableMb AS float) AS AvailableMb, CAST(AvailablePct AS float) AS AvailablePct, + CAST(PagesPerSec AS float) AS PagesPerSec, CAST(CommitPct AS float) AS CommitPct + FROM insight.ServerPerformance WHERE ServerId = @id AND SampledAt >= @from ORDER BY SampledAt; + """, new { id, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task SaveServerDisksAsync(int id, DateTimeOffset at, IReadOnlyList disks, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(disks); + if (disks.Count == 0) + { + return; + } + + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + INSERT insight.ServerDiskSamples (ServerId, Volume, SampledAt, SizeGb, FreeGb) + SELECT @id, Volume, @at, SizeGb, FreeGb + FROM OPENJSON(@json) WITH (Volume nvarchar(260), SizeGb float, FreeGb float) j + WHERE NOT EXISTS (SELECT 1 FROM insight.ServerDiskSamples s WHERE s.ServerId = @id AND s.Volume = j.Volume AND s.SampledAt = @at); + """, new + { + id, at = Utc(at), + json = JsonSerializer.Serialize(disks.GroupBy(d => d.Volume, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .Select(d => new { Volume = d.Volume.Length > 260 ? d.Volume[..260] : d.Volume, d.SizeGb, d.FreeGb }), Json), + }).ConfigureAwait(false); + } + + /// Free space per volume since , averaged per hour: what the forecast is computed from. + public async Task>> GetServerDiskHistoryAsync(int id, DateTimeOffset from, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string Volume, DateTime Hour, double SizeGb, double FreeGb)>(""" + SELECT Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], MAX(SizeGb), AVG(FreeGb) + FROM insight.ServerDiskSamples WHERE ServerId = @id AND SampledAt >= @from + GROUP BY Volume, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) + ORDER BY Volume, [Hour]; + """, new { id, from = from.UtcDateTime }).ConfigureAwait(false); + return rows.GroupBy(r => r.Volume, StringComparer.OrdinalIgnoreCase).ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.Select(r => new DiskHistoryPoint(new DateTimeOffset(DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc)), r.SizeGb, r.FreeGb)).ToList(), + StringComparer.OrdinalIgnoreCase); + } + + public async Task SaveServerEventsAsync(int id, ServerEventSnapshot snapshot, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (snapshot.Groups.Count == 0) + { + return; + } + + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + INSERT insight.ServerEvents (ServerId, CollectedAt, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample) + SELECT @id, @at, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample + FROM OPENJSON(@json) WITH (LogName nvarchar(64), Source nvarchar(256), EventId bigint, [Level] tinyint, Category varchar(32), [Count] int, + FirstAt datetime2(0), LastAt datetime2(0), Sample nvarchar(500)); + """, new + { + id, + at = Utc(snapshot.CapturedAt), + json = JsonSerializer.Serialize(snapshot.Groups.Select(g => new + { + LogName = Cut(g.Log, 64), + Source = Cut(g.Source, 256), + g.EventId, + Level = (byte)Math.Clamp(g.Level, 0, 255), + Category = g.Category.ToString(), + g.Count, + FirstAt = Iso(g.FirstAt), + LastAt = Iso(g.LastAt), + Sample = g.Sample is null ? null : Cut(g.Sample, 500), + }), Json), + }).ConfigureAwait(false); + + static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture); + } + + private static string Cut(string value, int length) => value.Length <= length ? value : value[..length]; + + /// Event groups last seen in [from, to], newest first. + public async Task> GetServerEventsAsync(int id, DateTimeOffset from, DateTimeOffset to, int top = 500, + CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT TOP (@top) EventRowId, CollectedAt, LogName, Source, EventId, [Level], Category, [Count], FirstAt, LastAt, Sample + FROM insight.ServerEvents WHERE ServerId = @id AND LastAt >= @from AND FirstAt <= @to + ORDER BY LastAt DESC, EventRowId DESC; + """, new { id, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false); + return rows.ToList(); + } + + /// The latest collection of an area, as JSON. + public async Task SaveServerSnapshotAsync(int id, string area, object data, DateTimeOffset capturedAt, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(data); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + MERGE insight.ServerSnapshots AS t + USING (SELECT @id AS ServerId, @area AS Area) AS s ON t.ServerId = s.ServerId AND t.Area = s.Area + WHEN MATCHED THEN UPDATE SET CapturedAt = @at, Json = @json + WHEN NOT MATCHED THEN INSERT (ServerId, Area, CapturedAt, Json) VALUES (@id, @area, @at, @json); + """, new { id, area, at = Utc(capturedAt), json = JsonSerializer.Serialize(data, data.GetType(), Json) }).ConfigureAwait(false); + } + + public async Task GetServerSnapshotAsync(int id, string area, CancellationToken cancellationToken = default) + where T : class + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var json = await connection.QuerySingleOrDefaultAsync( + "SELECT Json FROM insight.ServerSnapshots WHERE ServerId = @id AND Area = @area;", new { id, area }).ConfigureAwait(false); + if (json is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, Json); + } + catch (JsonException) + { + return null; + } + } + + /// + /// Deletes server samples, events and check results past retention, and resolved server findings, in small batches. + /// Called hourly by the agent. + /// + public async Task RunServerMaintenanceAsync(ServerRetention retention, DateTimeOffset now, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(retention); + var utc = now.UtcDateTime; + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.ServerPerformance", "SampledAt < @cutoff", utc.AddDays(-retention.SampleDays)).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.ServerDiskSamples", "SampledAt < @cutoff", utc.AddDays(-retention.DiskHistoryDays)).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.ServerEvents", "LastAt < @cutoff", utc.AddDays(-retention.EventDays)).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.SyntheticCheckResults", "RunAt < @cutoff", utc.AddDays(-retention.CheckResultDays)).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.ServerFindings", "ResolvedAt < @cutoff", utc.AddDays(-retention.ResolvedFindingDays)).ConfigureAwait(false); + + // Removed servers and checks: their samples and snapshots go at once; findings and history follow retention. + await connection.ExecuteAsync(""" + DELETE s FROM insight.ServerSnapshots s JOIN insight.WindowsServers w ON w.ServerId = s.ServerId WHERE w.RemovedAt IS NOT NULL; + UPDATE f SET ResolvedAt = @now FROM insight.ServerFindings f + WHERE f.ResolvedAt IS NULL AND ((f.Scope = 'Server' AND EXISTS (SELECT 1 FROM insight.WindowsServers w WHERE w.ServerId = f.ScopeId AND (w.RemovedAt IS NOT NULL OR w.Paused = 1))) + OR (f.Scope = 'Check' AND EXISTS (SELECT 1 FROM insight.SyntheticChecks c WHERE c.CheckId = f.ScopeId AND (c.RemovedAt IS NOT NULL OR c.Paused = 1)))); + """, new { now = Utc(now) }, commandTimeout: 300).ConfigureAwait(false); + } +} + +/// How long Windows server data is kept (SQLInsight:Servers and SQLInsight:SyntheticChecks). +public sealed record ServerRetention +{ + /// Per-minute CPU and memory. + public int SampleDays { get; init; } = 14; + + /// Free space per volume: must cover the forecast's history. + public int DiskHistoryDays { get; init; } = 35; + + public int EventDays { get; init; } = 90; + + public int CheckResultDays { get; init; } = 14; + + public int ResolvedFindingDays { get; init; } = 365; +} diff --git a/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql b/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql new file mode 100644 index 0000000..09ee7f1 --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0018_windows_servers_synthetic_checks.sql @@ -0,0 +1,196 @@ +/* 1.5: Windows servers and synthetic checks. + + WindowsServers the Windows servers the agent looks at: the computers behind the monitored SQL Server + instances (Source 'SqlHost', kept in step by the agent) and servers added in SQLInsight Web + (AOS and other application servers, file servers). The agent reads them remotely, as its own + identity, with least privilege (db/permissions/grant-windows-server.ps1); it never changes them. + ServerPerformance CPU, available memory, paging and commit per minute. Kept SQLInsight:Servers:SampleDays. + ServerDiskSamples free space per volume every 15 minutes: the history the "fuld om ca. N dage" forecast is + computed from. Kept SQLInsight:Servers:DiskHistoryDays. + ServerEvents errors and critical events from the System and Application logs, summed per collection, + source and event id like insight.ErrorLogEvents. Sample is a message: local only. Kept EventDays. + ServerSnapshots the latest collection per area (services, tasks, disks with forecasts …) for display, and so a + restarted agent continues where it stopped. + SyntheticChecks checks the agent runs on a schedule: TCP port, HTTP(S), SQL login + query, DNS, Dynamics AX + logon. A SQL login password is sealed with the agent's public key (insight.AgentKeys), as in 1.3. + SyntheticCheckResults every run, kept SQLInsight:SyntheticChecks:ResultDays. + ServerFindings, findings and rule outcomes of the Windows server rules (Scope 'Server') and synthetic check rules + ServerRuleStates (Scope 'Check'), like insight.Findings and insight.RuleStates for instances. */ +CREATE TABLE insight.WindowsServers +( + ServerId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_WindowsServers PRIMARY KEY, + Name nvarchar(128) NOT NULL, + Host nvarchar(255) NOT NULL, + Role varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Role DEFAULT ('Other') + CONSTRAINT CK_WindowsServers_Role CHECK (Role IN ('Sql', 'Application', 'File', 'Other')), + Source varchar(16) NOT NULL CONSTRAINT DF_WindowsServers_Source DEFAULT ('Web') + CONSTRAINT CK_WindowsServers_Source CHECK (Source IN ('Web', 'SqlHost')), + Paused bit NOT NULL CONSTRAINT DF_WindowsServers_Paused DEFAULT (0), + WatchedServices nvarchar(4000) NULL, + IgnoredServices nvarchar(4000) NULL, + CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_CreatedAt DEFAULT (SYSUTCDATETIME()), + CreatedBy nvarchar(256) NULL, + UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_WindowsServers_UpdatedAt DEFAULT (SYSUTCDATETIME()), + UpdatedBy nvarchar(256) NULL, + RemovedAt datetime2(0) NULL, + RemovedBy nvarchar(256) NULL, + /* What the agent saw. */ + LastContactAt datetime2(0) NULL, + LastErrorAt datetime2(0) NULL, + LastError nvarchar(1000) NULL, + Protocol varchar(16) NULL, + OperatingSystem nvarchar(256) NULL, + OsVersion nvarchar(64) NULL, + LastBootUpTime datetime2(0) NULL, + TotalMemoryMb float NULL, + LogicalProcessors int NULL, + AreasJson nvarchar(4000) NULL +); +CREATE UNIQUE INDEX UQ_WindowsServers_Host ON insight.WindowsServers (Host) WHERE RemovedAt IS NULL; +CREATE UNIQUE INDEX UQ_WindowsServers_Name ON insight.WindowsServers (Name) WHERE RemovedAt IS NULL; + +CREATE TABLE insight.ServerPerformance +( + ServerId int NOT NULL, + SampledAt datetime2(0) NOT NULL, + CpuPercent real NULL, + AvailableMb real NULL, + AvailablePct real NULL, + PagesPerSec real NULL, + CommitPct real NULL, + CONSTRAINT PK_ServerPerformance PRIMARY KEY (ServerId, SampledAt) +); + +CREATE TABLE insight.ServerDiskSamples +( + ServerId int NOT NULL, + Volume nvarchar(260) NOT NULL, + SampledAt datetime2(0) NOT NULL, + SizeGb float NOT NULL, + FreeGb float NOT NULL, + CONSTRAINT PK_ServerDiskSamples PRIMARY KEY (ServerId, Volume, SampledAt) +); + +CREATE TABLE insight.ServerEvents +( + EventRowId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ServerEvents PRIMARY KEY NONCLUSTERED, + ServerId int NOT NULL, + CollectedAt datetime2(0) NOT NULL, + LogName nvarchar(64) NOT NULL, + Source nvarchar(256) NOT NULL, + EventId bigint NOT NULL, + [Level] tinyint NOT NULL, + Category varchar(32) NOT NULL, + [Count] int NOT NULL, + FirstAt datetime2(0) NOT NULL, + LastAt datetime2(0) NOT NULL, + Sample nvarchar(500) NULL +); +CREATE CLUSTERED INDEX CX_ServerEvents ON insight.ServerEvents (ServerId, LastAt); + +CREATE TABLE insight.ServerSnapshots +( + ServerId int NOT NULL, + Area varchar(32) NOT NULL, + CapturedAt datetime2(0) NOT NULL, + Json nvarchar(max) NOT NULL, + CONSTRAINT PK_ServerSnapshots PRIMARY KEY (ServerId, Area) +); + +CREATE TABLE insight.SyntheticChecks +( + CheckId int IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SyntheticChecks PRIMARY KEY, + Name nvarchar(128) NOT NULL, + Kind varchar(16) NOT NULL CONSTRAINT CK_SyntheticChecks_Kind CHECK (Kind IN ('Tcp', 'Http', 'Sql', 'Dns', 'AxLogon')), + ServerId int NULL CONSTRAINT FK_SyntheticChecks_Server REFERENCES insight.WindowsServers (ServerId), + SettingsJson nvarchar(4000) NOT NULL, + SecretCipher varbinary(2048) NULL, + SecretKeyId varchar(64) NULL, + IntervalSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Interval DEFAULT (300) + CONSTRAINT CK_SyntheticChecks_Interval CHECK (IntervalSeconds BETWEEN 60 AND 86400), + TimeoutSeconds int NOT NULL CONSTRAINT DF_SyntheticChecks_Timeout DEFAULT (15) + CONSTRAINT CK_SyntheticChecks_Timeout CHECK (TimeoutSeconds BETWEEN 1 AND 120), + Paused bit NOT NULL CONSTRAINT DF_SyntheticChecks_Paused DEFAULT (0), + CreatedAt datetime2(0) NOT NULL CONSTRAINT DF_SyntheticChecks_CreatedAt DEFAULT (SYSUTCDATETIME()), + CreatedBy nvarchar(256) NULL, + UpdatedAt datetime2(0) NOT NULL CONSTRAINT DF_SyntheticChecks_UpdatedAt DEFAULT (SYSUTCDATETIME()), + UpdatedBy nvarchar(256) NULL, + RemovedAt datetime2(0) NULL, + RemovedBy nvarchar(256) NULL, + SecretError nvarchar(1000) NULL, + /* What the agent saw. */ + LastRunAt datetime2(0) NULL, + LastSuccess bit NULL, + LastDurationMs int NULL, + LastMessage nvarchar(1000) NULL, + ConsecutiveFailures int NOT NULL CONSTRAINT DF_SyntheticChecks_Failures DEFAULT (0) +); +CREATE UNIQUE INDEX UQ_SyntheticChecks_Name ON insight.SyntheticChecks (Name) WHERE RemovedAt IS NULL; + +CREATE TABLE insight.SyntheticCheckResults +( + ResultId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_SyntheticCheckResults PRIMARY KEY NONCLUSTERED, + CheckId int NOT NULL, + RunAt datetime2(0) NOT NULL, + Success bit NOT NULL, + DurationMs int NOT NULL, + Message nvarchar(1000) NOT NULL, + DetailsJson nvarchar(4000) NULL, + CertificateExpiresAt datetime2(0) NULL, + CertificateSubject nvarchar(400) NULL, + ServiceState varchar(32) NULL, + [Partial] bit NOT NULL CONSTRAINT DF_SyntheticCheckResults_Partial DEFAULT (0) +); +CREATE CLUSTERED INDEX CX_SyntheticCheckResults ON insight.SyntheticCheckResults (CheckId, RunAt); + +CREATE TABLE insight.ServerFindings +( + FindingId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ServerFindings PRIMARY KEY, + Scope varchar(8) NOT NULL CONSTRAINT CK_ServerFindings_Scope CHECK (Scope IN ('Server', 'Check')), + ScopeId int NOT NULL, + RuleId varchar(40) NOT NULL, + Subject nvarchar(400) NOT NULL, + Severity tinyint NOT NULL, + Title nvarchar(1000) NOT NULL, + Explanation nvarchar(max) NOT NULL, + Recommendation nvarchar(max) NOT NULL, + EvidenceJson nvarchar(max) NOT NULL, + ReferencesJson nvarchar(max) NOT NULL, + FirstSeenAt datetime2(0) NOT NULL, + LastSeenAt datetime2(0) NOT NULL, + ResolvedAt datetime2(0) NULL +); +CREATE UNIQUE INDEX UX_ServerFindings_Active ON insight.ServerFindings (Scope, ScopeId, RuleId, Subject) WHERE ResolvedAt IS NULL; +CREATE INDEX IX_ServerFindings_Resolved ON insight.ServerFindings (ResolvedAt) WHERE ResolvedAt IS NOT NULL; + +CREATE TABLE insight.ServerRuleStates +( + Scope varchar(8) NOT NULL, + ScopeId int NOT NULL, + RuleId varchar(40) NOT NULL, + RuleTitle nvarchar(400) NOT NULL, + Outcome tinyint NOT NULL, + Reason nvarchar(max) NULL, + EvaluatedAt datetime2(0) NOT NULL, + CONSTRAINT PK_ServerRuleStates PRIMARY KEY (Scope, ScopeId, RuleId) +); + +/* Requests about a Windows server or a synthetic check name it in TargetId. */ +ALTER TABLE insight.AgentRequests ADD TargetId int NULL; +ALTER TABLE insight.AgentRequests DROP CONSTRAINT CK_AgentRequests_Kind; +ALTER TABLE insight.AgentRequests ADD CONSTRAINT CK_AgentRequests_Kind + CHECK (Kind IN ('ConnectionTest', 'DatabaseDiscovery', 'InstanceDiscovery', 'ServerTest', 'SyntheticCheckRun')); +GO + +CREATE INDEX IX_AgentRequests_Target ON insight.AgentRequests (Kind, TargetId, RequestId) WHERE TargetId IS NOT NULL; +GO + +/* SQLInsight Web only has db_datareader. As in 0016 it gets exactly what it needs to change the configuration: add + servers and checks, change what may be changed, and remove (RemovedAt, no DELETE). Nothing the agent records. Host, + Name of a server and Kind of a check are fixed once created. Who may change it in the web UI is decided by + SQLInsight:Web:AdminGroups. */ +GRANT INSERT ON insight.WindowsServers TO public; +GRANT UPDATE ON insight.WindowsServers (Role, Paused, WatchedServices, IgnoredServices, UpdatedAt, UpdatedBy, RemovedAt, RemovedBy) TO public; +GRANT INSERT ON insight.SyntheticChecks TO public; +GRANT UPDATE ON insight.SyntheticChecks (Name, ServerId, SettingsJson, SecretCipher, SecretKeyId, IntervalSeconds, TimeoutSeconds, Paused, + UpdatedAt, UpdatedBy, RemovedAt, RemovedBy, SecretError) TO public; diff --git a/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs b/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs new file mode 100644 index 0000000..13f7d9b --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/ServerRepositoryTests.cs @@ -0,0 +1,252 @@ +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.IntegrationTests; + +/// +/// Windows servers and synthetic checks in the repository (0018): what SQLInsight Web may change with its read-only +/// login, the SQL Server hosts kept in step by the agent, samples, events, check results, findings and retention. +/// +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class ServerRepositoryTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightSrv_" + Guid.NewGuid().ToString("N")[..8]; + private InsightRepository _repository = null!; + private string _webConnectionString = string.Empty; + private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow; + + 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 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 async Task ExecuteAsWebAsync(string sqlText) + { + await using var connection = new SqlConnection(_webConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = sqlText; + await command.ExecuteNonQueryAsync(); + } + + private static SyntheticCheckInput Check(string name, int? serverId = null, byte[]? secret = null) => + new(name, SyntheticCheckKind.Tcp, serverId, new SyntheticCheckSettings { Host = "aos01", Port = 2712, ServiceName = serverId is null ? null : "AOS60$01" }, secret, + secret is null ? null : "key-1", 300, 15); + + [Fact] + public async Task Web_login_can_add_and_change_servers_and_checks_but_nothing_the_agent_records() + { + var web = await ReadOnlyRepositoryAsync(); + + var server = await web.AddWindowsServerAsync(new NewWindowsServer("AOS01", "AOS01.firma.local", ServerRoles.Application, ["AOS60$01"], []), @"FIRMA\anna", Now); + Assert.True(await web.UpdateWindowsServerAsync(server, ServerRoles.Application, ["AOS60$01", "W3SVC"], ["MyBatch"], @"FIRMA\anna", Now)); + Assert.True(await web.SetWindowsServerPausedAsync(server, true, @"FIRMA\anna", Now)); + var check = await web.AddSyntheticCheckAsync(Check("AOS01 port", server, [1, 2, 3]), @"FIRMA\anna", Now); + Assert.True(await web.UpdateSyntheticCheckAsync(check, Check("AOS01 AOS-port", server) with { IntervalSeconds = 120 }, @"FIRMA\anna", Now)); + Assert.True(await web.SetSyntheticCheckPausedAsync(check, true, @"FIRMA\anna", Now)); + var request = await web.AddServerRequestAsync(ServerRequestKind.ServerTest, server, @"FIRMA\anna", Now); + + var row = (await _repository.GetWindowsServerAsync(server))!; + Assert.Equal(("aos01.firma.local", ServerRoles.Application, true, ServerSources.Web), (row.Host, row.Role, row.Paused, row.Source)); + Assert.Equal(["AOS60$01", "W3SVC"], row.Watched); + Assert.Equal(["MyBatch"], row.Ignored); + var checkRow = (await _repository.GetSyntheticCheckAsync(check))!; + Assert.Equal(("AOS01 AOS-port", 120, true, "AOS01"), (checkRow.Name, checkRow.IntervalSeconds, checkRow.HasSecret, checkRow.ServerName)); + Assert.Equal("AOS60$01", checkRow.Settings.ServiceName); + Assert.Equal(server, await web.GetRequestTargetAsync(request)); + Assert.Equal(request, (await web.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, server))!.RequestId); + + // What only the agent does is refused for the web login. + await Assert.ThrowsAsync(() => web.RecordServerContactAsync(server, new ServerOsInfo { Caption = "x" }, "DCOM", null, Now)); + await Assert.ThrowsAsync(() => web.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now, 1, 1, 1, 1, 1))); + await Assert.ThrowsAsync(() => web.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now, Success = true, Message = "ok" })); + await Assert.ThrowsAsync(() => web.RecordServerAreasAsync(server, [])); + await Assert.ThrowsAsync(() => web.SaveServerSnapshotAsync(server, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = Now }, Now)); + await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.WindowsServers SET Host = N'evil' WHERE ServerId = {server}")); + await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.SyntheticChecks SET Kind = 'Sql' WHERE CheckId = {check}")); + await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"DELETE FROM insight.SyntheticChecks WHERE CheckId = {check}")); + await Assert.ThrowsAsync(() => ExecuteAsWebAsync("DELETE FROM insight.ServerFindings")); + + Assert.True(await web.RemoveSyntheticCheckAsync(check, @"FIRMA\anna", Now)); + Assert.Equal(ConfigurationChangeOutcome.Changed, await web.RemoveWindowsServerAsync(server, @"FIRMA\anna", Now)); + Assert.Empty(await _repository.GetWindowsServersAsync()); + Assert.Empty(await _repository.GetSyntheticChecksAsync()); + } + + [Fact] + public async Task Names_and_hosts_are_unique_among_servers_in_use() + { + await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, Now); + + var host = await Assert.ThrowsAsync(() => _repository.AddWindowsServerAsync(new NewWindowsServer("Fil", "FIL01", ServerRoles.File, [], []), null, Now)); + var name = await Assert.ThrowsAsync(() => _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil02", ServerRoles.File, [], []), null, Now)); + Assert.True(host.Number is 2601 or 2627); + Assert.True(name.Number is 2601 or 2627); + } + + [Fact] + public async Task Sql_server_hosts_follow_the_instances_and_keep_their_settings() + { + await _repository.AddWindowsServerAsync(new NewWindowsServer("SQL02 (manuelt)", "sql02.firma.local", ServerRoles.Sql, [], []), null, Now); + await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01"), ("SQL02.firma.local", "SQL02"), ("sql01.firma.local", "SQL01")], Now); + + var servers = await _repository.GetWindowsServersAsync(); + Assert.Equal(["SQL01", "SQL02 (manuelt)"], servers.Select(s => s.Name).ToList()); + var sql01 = servers.Single(s => s.Name == "SQL01"); + Assert.True(sql01.IsSqlHost); + Assert.Equal(ServerRoles.Sql, sql01.Role); + await _repository.UpdateWindowsServerAsync(sql01.ServerId, ServerRoles.Sql, ["MSSQLSERVER"], [], "anna", Now); + + Assert.Equal(ConfigurationChangeOutcome.FromConfigFile, await _repository.RemoveWindowsServerAsync(sql01.ServerId, "anna", Now)); + await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], Now); + Assert.Equal(["MSSQLSERVER"], (await _repository.GetWindowsServerAsync(sql01.ServerId))!.Watched); + + await _repository.SyncSqlHostServersAsync([], Now); + Assert.Equal(["SQL02 (manuelt)"], (await _repository.GetWindowsServersAsync()).Select(s => s.Name).ToList()); + } + + [Fact] + public async Task Samples_disk_history_events_and_snapshots_round_trip() + { + var id = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now); + await _repository.RecordServerContactAsync(id, new ServerOsInfo { Caption = "Windows Server 2019", Version = "10.0.17763", TotalMemoryMb = 16384, LogicalProcessors = 4, + LastBootUpTime = Now.AddDays(-3) }, "WS-Man", [new ServerAreaStatus(ServerArea.Events, false, "Adgang nægtet", ServerArea.Right(ServerArea.Events))], Now); + await _repository.RecordServerErrorAsync(id, "RPC-serveren er ikke tilgængelig.", Now.AddMinutes(-5)); + for (var m = 0; m < 5; m++) + { + await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(Now.AddMinutes(-m), 10 + m, 4000, 16384, 3, 50)); + } + + await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(Now, 99, 1, 1, 1, 1)); // same minute: kept once + for (var h = 0; h < 60; h++) + { + await _repository.SaveServerDisksAsync(id, Now.AddHours(-h), [new ServerDisk(@"D:\", "Data", "NTFS", 100, 20 + (h * 0.5)), new ServerDisk(@"C:\", null, "NTFS", 80, 40)]); + } + + var (groups, _) = ServerEventAnalysis.Group([new ServerEventEntry("System", "disk", 153, 2, Now.AddMinutes(-3), "Disk 1 svarede ikke")], []); + await _repository.SaveServerEventsAsync(id, new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now, Groups = groups, Recent = groups }); + await _repository.SaveServerSnapshotAsync(id, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = Now, Services = [new ServerService("W3SVC", "IIS", "Running", "Auto")] }, Now); + + var row = (await _repository.GetWindowsServerAsync(id))!; + Assert.True(row.Reachable); + Assert.Equal(("WS-Man", "Windows Server 2019", 4), (row.Protocol, row.OperatingSystem, row.LogicalProcessors)); + Assert.Equal("Adgang nægtet", Assert.Single(row.Areas).Detail); + var performance = await _repository.GetServerPerformanceAsync(id, Now.AddHours(-1)); + Assert.Equal(5, performance.Count); + Assert.Equal(10, performance[^1].CpuPercent!.Value, 3); + var history = await _repository.GetServerDiskHistoryAsync(id, Now.AddDays(-14)); + Assert.Equal(2, history.Count); + Assert.InRange(history[@"D:\"].Count, 60, 61); + var forecast = DiskForecaster.Forecast(@"D:\", history[@"D:\"], Now); + Assert.Equal(20 / 12d, forecast.DaysUntilFull!.Value, 1); // 12 GB a day, 20 GB free + var events = await _repository.GetServerEventsAsync(id, Now.AddDays(-1), Now); + Assert.Equal(("disk", 153, "Disk", "Disk 1 svarede ikke"), (events[0].Source, events[0].EventId, events[0].Category, events[0].Sample)); + var services = await _repository.GetServerSnapshotAsync(id, ServerArea.Services); + Assert.Equal("W3SVC", Assert.Single(services!.Services).Name); + } + + private static RuleResult Found(string ruleId, params Finding[] findings) => + new() { RuleId = ruleId, RuleTitle = ruleId, Outcome = findings.Length == 0 ? RuleOutcome.Passed : RuleOutcome.Findings, Findings = findings }; + + private static Finding Finding(string ruleId, string subject, Severity severity = Severity.High) => new() + { + RuleId = ruleId, Subject = subject, Severity = severity, Title = "t " + subject, Explanation = "e", Recommendation = "r", + Evidence = [new Evidence("Eksempel", "\\\\fil01\\hemmeligt", Sensitive: true)], + }; + + [Fact] + public async Task Findings_open_update_resolve_per_server_and_check_and_survive_a_not_measurable_round() + { + var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now); + var check = await _repository.AddSyntheticCheckAsync(Check("AOS port"), null, Now); + + var first = await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, + [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\"), Finding("SQLI-WIN-005", @"disk: E:\"))], Now); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Check, check, [Found("SQLI-SYN-001", Finding("SQLI-SYN-001", "check"))], Now); + Assert.Equal(2, first.Opened.Count); + + // Not measurable (e.g. rights removed): the findings stay open. + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, + [new RuleResult { RuleId = "SQLI-WIN-005", RuleTitle = "x", Outcome = RuleOutcome.NotMeasurable, Reason = "Adgang nægtet" }], Now.AddMinutes(1)); + Assert.Equal(2, (await _repository.GetServerFindingsAsync(FindingScope.Server, server)).Count); + Assert.Equal("Adgang nægtet", Assert.Single(await _repository.GetServerRuleStatesAsync(FindingScope.Server, server)).Reason); + + var later = await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\", Severity.Critical))], Now.AddMinutes(2)); + Assert.Empty(later.Opened); + Assert.Equal(@"disk: E:\", Assert.Single(later.Resolved).Subject); + + var active = await _repository.GetServerFindingsAsync(); + Assert.Equal(2, active.Count); + Assert.Equal(("AOS01", Severity.Critical), (active.Single(f => f.Scope == FindingScope.Server).ScopeName, active.Single(f => f.Scope == FindingScope.Server).Severity)); + Assert.Equal("AOS port", active.Single(f => f.Scope == FindingScope.Check).ScopeName); + Assert.True(active[0].Evidence[0].Sensitive); + var counts = await _repository.GetServerFindingCountsAsync(); + Assert.Equal(1, counts[(FindingScope.Server, server)][Severity.Critical]); + var resolved = Assert.Single(await _repository.GetServerFindingsAsync(FindingScope.Server, server, FindingStatus.Resolved)); + Assert.Equal(resolved.FindingId, (await _repository.GetServerFindingAsync(resolved.FindingId))!.FindingId); + + // Back again: a new occurrence, with the old one as history. + var again = await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, + [Found("SQLI-WIN-005", Finding("SQLI-WIN-005", @"disk: D:\"), Finding("SQLI-WIN-005", @"disk: E:\"))], Now.AddMinutes(3)); + var reopened = Assert.Single(again.Opened); + Assert.Single(await _repository.GetServerFindingHistoryAsync((await _repository.GetServerFindingAsync(reopened.FindingId))!)); + } + + [Fact] + public async Task Check_results_count_failures_in_a_row_and_retention_bounds_everything() + { + var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, Now); + var check = await _repository.AddSyntheticCheckAsync(Check("AOS port", server), null, Now); + await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now.AddMinutes(-10), Success = true, DurationMs = 5, Message = "ok" }); + await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult + { + At = Now.AddMinutes(-5), Success = false, DurationMs = 15000, Message = "Timeout", ServiceState = "Running", + Details = [new SyntheticCheckDetail("Fejl", new string('x', 3000))], + }); + await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now, Success = false, DurationMs = 15000, Message = "Timeout", Partial = true }); + + var row = (await _repository.GetSyntheticCheckAsync(check))!; + Assert.Equal((false, 2, "Timeout"), (row.LastSuccess, row.ConsecutiveFailures, row.LastMessage)); + var results = await _repository.GetSyntheticCheckResultsAsync(check); + Assert.Equal([false, false, true], results.Select(r => r.Success).ToList()); + Assert.Equal("Running", results[1].ServiceState); + Assert.Equal(400, results[1].Details.Single().Value.Length); + Assert.True(results[0].ToResult().Partial); + + // Old data goes; removed and paused servers' findings are resolved. + await _repository.SaveServerPerformanceAsync(server, new ServerPerformanceSample(Now.AddDays(-20), 1, 1, 1, 1, 1)); + await _repository.SaveServerDisksAsync(server, Now.AddDays(-40), [new ServerDisk(@"C:\", null, null, 1, 1)]); + await _repository.RecordSyntheticCheckResultAsync(check, new SyntheticCheckResult { At = Now.AddDays(-20), Success = true, Message = "gammel" }); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, [Found("SQLI-WIN-001", Finding("SQLI-WIN-001", "cpu"))], Now); + await _repository.SetWindowsServerPausedAsync(server, true, null, Now); + + await _repository.RunServerMaintenanceAsync(new ServerRetention(), Now); + + Assert.Empty(await _repository.GetServerPerformanceAsync(server, Now.AddDays(-30))); + Assert.Empty(await _repository.GetServerDiskHistoryAsync(server, Now.AddDays(-60))); + Assert.Equal(3, (await _repository.GetSyntheticCheckResultsAsync(check)).Count); + Assert.Empty(await _repository.GetServerFindingsAsync(FindingScope.Server, server)); + } +}