From bfb27778d2a253fcf86c65ba4448249662ad4d71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:35:22 +0000 Subject: [PATCH] Fase 3: findings sync from agent to hub (queue, retry, data minimization) - Evidence can be marked sensitive (query text, logins, hosts, programs, job error messages); rules mark it. Sensitive values and the server name are sent as [udeladt] unless the customer allows them. - Full-state submission contract (all active findings and rule outcomes per instance), idempotent by submission id - Local outbound queue in the repository (migration 0002): newest state per instance only, exponential backoff up to an hour, permanent rejections (bad license, bad request) are given up, sent items purged - Hub status (license state, last sent, last error) for the web UI - Sync runs only with a valid license (or in grace); monitoring never depends on it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- SQLInsight.slnx | 1 + src/SQLInsight.Agent/Hub/HubOptions.cs | 33 +++++ src/SQLInsight.Agent/Hub/HubOutbox.cs | 42 +++++++ src/SQLInsight.Agent/Hub/HubSyncService.cs | 119 ++++++++++++++++++ src/SQLInsight.Agent/Hub/SubmissionBuilder.cs | 56 +++++++++ .../Monitoring/InstanceMonitor.cs | 7 +- .../Monitoring/MonitoringService.cs | 5 +- src/SQLInsight.Agent/Program.cs | 4 + src/SQLInsight.Agent/SQLInsight.Agent.csproj | 2 + src/SQLInsight.Agent/appsettings.json | 8 ++ .../FindingsSubmission.cs | 42 +++++-- src/SQLInsight.Core/Findings/Finding.cs | 6 +- .../InsightRepository.Hub.cs | 106 ++++++++++++++++ .../InsightRepository.Maintenance.cs | 1 + .../Migrations/0002_hub_sync.sql | 34 +++++ .../Availability/AvailabilityRules.cs | 2 +- .../Concurrency/ConcurrencyRules.cs | 8 +- src/SQLInsight.Rules/Performance/PlanRules.cs | 4 +- .../Performance/QueryStoreRules.cs | 6 +- .../SQLInsight.Agent.Tests.csproj | 14 +++ .../SubmissionBuilderTests.cs | 87 +++++++++++++ 21 files changed, 562 insertions(+), 25 deletions(-) create mode 100644 src/SQLInsight.Agent/Hub/HubOptions.cs create mode 100644 src/SQLInsight.Agent/Hub/HubOutbox.cs create mode 100644 src/SQLInsight.Agent/Hub/HubSyncService.cs create mode 100644 src/SQLInsight.Agent/Hub/SubmissionBuilder.cs create mode 100644 src/SQLInsight.Repository/InsightRepository.Hub.cs create mode 100644 src/SQLInsight.Repository/Migrations/0002_hub_sync.sql create mode 100644 tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj create mode 100644 tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs diff --git a/SQLInsight.slnx b/SQLInsight.slnx index e6114fe..02b0e57 100644 --- a/SQLInsight.slnx +++ b/SQLInsight.slnx @@ -13,6 +13,7 @@ + diff --git a/src/SQLInsight.Agent/Hub/HubOptions.cs b/src/SQLInsight.Agent/Hub/HubOptions.cs new file mode 100644 index 0000000..174bad3 --- /dev/null +++ b/src/SQLInsight.Agent/Hub/HubOptions.cs @@ -0,0 +1,33 @@ +namespace SQLInsight.Agent.Hub; + +/// +/// Findings sync to SQLInsight Hub (Professional and Enterprise). Off by default; nothing leaves the network unless +/// it is switched on. +/// +public sealed class HubOptions +{ + public const string SectionName = "SQLInsight:Hub"; + + public bool Enabled { get; set; } + + /// Base URL of SQLInsight Hub, e.g. https://hub.seosoft.dk/. + public Uri? Url { get; set; } + + /// The license text (SQLI1.…). + public string? License { get; set; } + + /// + /// Also send evidence marked sensitive: query text, logins, host and program names, job error messages. + /// Off by default; such values are sent as "[udeladt]". + /// + public bool SendSensitiveEvidence { get; set; } + + /// Also send the server's network name. Off by default; the instance is identified by its configured name. + public bool SendServerName { get; set; } + + /// How often the state is sent when nothing changed. Changes are sent at once. + public TimeSpan SubmitInterval { get; set; } = TimeSpan.FromMinutes(15); + + /// Additional public keys trusted for licenses (key id → base64 SubjectPublicKeyInfo). + public IDictionary TrustedKeys { get; } = new Dictionary(); +} diff --git a/src/SQLInsight.Agent/Hub/HubOutbox.cs b/src/SQLInsight.Agent/Hub/HubOutbox.cs new file mode 100644 index 0000000..15c626f --- /dev/null +++ b/src/SQLInsight.Agent/Hub/HubOutbox.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Agent.Hub; + +/// +/// Queues an instance's state for the hub: at once when findings changed, otherwise at most every SubmitInterval. +/// +public sealed class HubOutbox(IOptions options, InsightRepository repository) +{ + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private readonly Dictionary _lastQueued = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + + public bool Enabled => options.Value.Enabled && options.Value.Url is not null; + + public async Task QueueAsync(string instanceName, int instanceId, InstanceInfo info, IReadOnlyList results, bool changed, DateTimeOffset now, CancellationToken cancellationToken) + { + if (!Enabled) + { + return; + } + + lock (_lock) + { + if (!changed && _lastQueued.TryGetValue(instanceName, out var last) && now - last < options.Value.SubmitInterval) + { + return; + } + + _lastQueued[instanceName] = now; + } + + var active = await repository.GetActiveFindingsAsync(instanceId, cancellationToken).ConfigureAwait(false); + var submission = SubmissionBuilder.Build(instanceName, info, results, active, options.Value, now); + await repository.EnqueueSubmissionAsync(instanceName, submission.SubmissionId, JsonSerializer.Serialize(submission, Json), now, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/SQLInsight.Agent/Hub/HubSyncService.cs b/src/SQLInsight.Agent/Hub/HubSyncService.cs new file mode 100644 index 0000000..8bc4100 --- /dev/null +++ b/src/SQLInsight.Agent/Hub/HubSyncService.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.Options; +using SQLInsight.Licensing; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Hub; + +/// +/// Delivers queued submissions to SQLInsight Hub with retry and exponential backoff. Rejections the hub will keep +/// making (invalid license, bad request) are given up rather than retried forever. +/// +public sealed partial class HubSyncService( + IOptions options, + InsightRepository repository, + IHttpClientFactory httpClientFactory, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string AuthorizationScheme = "SQLInsight-License"; + public static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(PollInterval, time); + do + { + try + { + await RunOnceAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogRoundFailed(logger, ex); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); + } + + /// One delivery round. Returns how many submissions were sent. + public async Task RunOnceAsync(CancellationToken cancellationToken) + { + var settings = options.Value; + var status = CheckLicense(settings); + await repository.SaveHubStatusAsync(new HubStatusRow + { + Enabled = settings.Enabled, + HubUrl = settings.Url?.ToString(), + LicenseState = status.State.ToString(), + LicenseMessage = status.Message, + Customer = status.License?.Customer, + LicensePlan = status.License?.Plan.ToString(), + LicenseExpires = status.License?.ExpiresAt.UtcDateTime, + SendSensitive = settings.SendSensitiveEvidence, + UpdatedAt = time.GetUtcNow().UtcDateTime, + }, cancellationToken).ConfigureAwait(false); + + if (!settings.Enabled || settings.Url is null || !status.AllowsFindingsSync) + { + return 0; + } + + var sent = 0; + using var http = httpClientFactory.CreateClient("hub"); + foreach (var item in await repository.GetDueSubmissionsAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false)) + { + var now = time.GetUtcNow(); + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url, "api/v1/findings")) + { + Content = new StringContent(item.Payload, Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License); + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + await repository.MarkSubmissionSentAsync(item.OutboundId, now, cancellationToken).ConfigureAwait(false); + sent++; + continue; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var error = $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(body)}"; + var permanent = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.UnprocessableEntity; + await repository.MarkSubmissionFailedAsync(item.OutboundId, error, now, permanent ? null : now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false); + LogRejected(logger, item.InstanceName, error); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + await repository.MarkSubmissionFailedAsync(item.OutboundId, ex.Message, now, now + Backoff(item.Attempts), cancellationToken).ConfigureAwait(false); + LogUnreachable(logger, ex, item.InstanceName); + } + } + + return sent; + } + + private LicenseStatus CheckLicense(HubOptions settings) => + new LicenseVerifier(TrustedKeys.Embedded().With(settings.TrustedKeys), time).Check(settings.License); + + /// 1, 2, 4 … minutes, at most an hour. + public static TimeSpan Backoff(int attempts) => + TimeSpan.FromMinutes(Math.Min(MaxBackoff.TotalMinutes, Math.Pow(2, Math.Min(attempts, 10)))); + + private static string Truncate(string text) => text.Length <= 500 ? text : text[..500]; + + [LoggerMessage(Level = LogLevel.Warning, Message = "Hub rejected the submission for {Instance}: {Error}")] + private static partial void LogRejected(ILogger logger, string instance, string error); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Hub unreachable for {Instance}; will retry")] + private static partial void LogUnreachable(ILogger logger, Exception exception, string instance); + + [LoggerMessage(Level = LogLevel.Error, Message = "Hub sync round failed")] + private static partial void LogRoundFailed(ILogger logger, Exception exception); +} diff --git a/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs b/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs new file mode 100644 index 0000000..dbb6026 --- /dev/null +++ b/src/SQLInsight.Agent/Hub/SubmissionBuilder.cs @@ -0,0 +1,56 @@ +using SQLInsight.Contracts; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Agent.Hub; + +/// +/// Turns an instance's current state into a hub submission, leaving out what the customer has not allowed. +/// +public static class SubmissionBuilder +{ + public static readonly string AgentVersion = + typeof(SubmissionBuilder).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + public static FindingsSubmission Build( + string instanceName, InstanceInfo info, IReadOnlyList results, IReadOnlyList activeFindings, + HubOptions options, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(info); + ArgumentNullException.ThrowIfNull(results); + ArgumentNullException.ThrowIfNull(activeFindings); + ArgumentNullException.ThrowIfNull(options); + + return new FindingsSubmission + { + SubmissionId = Guid.NewGuid(), + AgentVersion = AgentVersion, + CollectedAt = now, + Instance = new InstanceSnapshot + { + InstanceKey = instanceName, + Release = info.Release.DisplayName(), + ProductVersion = info.Version.ToString(), + ProductLevel = info.ProductLevel, + ProductUpdateLevel = info.ProductUpdateLevel, + Edition = info.Edition, + ServerName = options.SendServerName ? info.ServerName : null, + }, + Rules = results.Select(r => new RuleOutcomeDto { RuleId = r.RuleId, Outcome = r.Outcome.ToString(), Reason = r.Reason }).ToList(), + Findings = activeFindings.Select(f => new FindingDto + { + RuleId = f.RuleId, + Subject = f.Subject, + Severity = f.Severity.ToString(), + Title = f.Title, + Explanation = f.Explanation, + Recommendation = f.Recommendation, + FirstSeenAt = new DateTimeOffset(DateTime.SpecifyKind(f.FirstSeenAt, DateTimeKind.Utc)), + Evidence = f.Evidence + .Select(e => new EvidenceDto(e.Name, e.Sensitive && !options.SendSensitiveEvidence ? FindingsSubmission.Redacted : e.Value)) + .ToList(), + }).ToList(), + }; + } +} diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs index c6e1a13..c772d82 100644 --- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs +++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs @@ -21,7 +21,8 @@ public sealed partial class InstanceMonitor( InsightRepository repository, ILogger logger, NotificationDispatcher? notifications = null, - Uri? webUrl = null) + Uri? webUrl = null, + Hub.HubOutbox? hubOutbox = null) { private bool? _wasReachable; @@ -103,6 +104,10 @@ public sealed partial class InstanceMonitor( events.AddRange(changes.Opened.Select(o => NotificationFactory.Opened(instance.Name, o.FindingId, o.Finding, webUrl, now))); events.AddRange(changes.Resolved.Select(r => NotificationFactory.Resolved(instance.Name, r.FindingId, r.RuleId, r.Title, (Core.Findings.Severity)r.Severity, webUrl, now))); await NotifyAsync(events, cancellationToken).ConfigureAwait(false); + if (hubOutbox is not null) + { + await hubOutbox.QueueAsync(instance.Name, instanceId, info, results, changes.Opened.Count + changes.Resolved.Count > 0, now, cancellationToken).ConfigureAwait(false); + } var findings = 0; var notMeasurable = 0; foreach (var result in results) diff --git a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs index 75bfb0b..7425fd7 100644 --- a/src/SQLInsight.Agent/Monitoring/MonitoringService.cs +++ b/src/SQLInsight.Agent/Monitoring/MonitoringService.cs @@ -18,7 +18,8 @@ public sealed partial class MonitoringService( TimeProvider timeProvider, ILoggerFactory loggerFactory, NotificationDispatcher notifications, - IOptions notificationOptions) : BackgroundService + IOptions notificationOptions, + Hub.HubOutbox hubOutbox) : BackgroundService { private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -30,7 +31,7 @@ public sealed partial class MonitoringService( var intervals = new Dictionary(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase); var monitors = settings.Instances .Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(), intervals, connectionFactory, ruleEngine, repository, - loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl)) + loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox)) .ToList(); if (monitors.Count == 0) { diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs index 255cbda..dc6adea 100644 --- a/src/SQLInsight.Agent/Program.cs +++ b/src/SQLInsight.Agent/Program.cs @@ -51,6 +51,10 @@ builder.Services.AddSingleton(sp => return new NotificationDispatcher(channels, options, sp.GetRequiredService(), sp.GetRequiredService>()); }); +builder.Services.Configure(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName)); +builder.Services.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj index fbe7931..9276c54 100644 --- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj +++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/SQLInsight.Agent/appsettings.json b/src/SQLInsight.Agent/appsettings.json index 037953b..b939261 100644 --- a/src/SQLInsight.Agent/appsettings.json +++ b/src/SQLInsight.Agent/appsettings.json @@ -13,6 +13,14 @@ "MaxParallelInstances": 4, "CollectorIntervals": { }, + "Hub": { + "Enabled": false, + "Url": "https://hub.seosoft.dk/", + "License": null, + "SendSensitiveEvidence": false, + "SendServerName": false, + "SubmitInterval": "00:15:00" + }, "Notifications": { "WebUrl": "https://sqlinsight.eksempel.local/", "NotifyResolved": true, diff --git a/src/SQLInsight.Contracts/FindingsSubmission.cs b/src/SQLInsight.Contracts/FindingsSubmission.cs index 7b41204..08b340a 100644 --- a/src/SQLInsight.Contracts/FindingsSubmission.cs +++ b/src/SQLInsight.Contracts/FindingsSubmission.cs @@ -1,38 +1,50 @@ namespace SQLInsight.Contracts; /// -/// What an agent sends to SQLInsight Hub when findings sync is enabled. This is a wire contract between -/// independently released agents and the hub: add optional members only, and bump -/// for anything else. +/// What an agent sends to SQLInsight Hub: the complete current state of one instance (all active findings and every +/// rule's outcome). Full state rather than changes, so a lost or repeated submission never leaves the hub wrong, and +/// only the newest submission per instance has to be delivered. /// /// -/// Only findings and instance metadata are sent. Table data is never sent, and query text only when the customer -/// has allowed it. +/// A wire contract between independently released agents and the hub: add optional members only, and bump +/// for anything else. Sensitive evidence (query text, logins, hosts, messages) is +/// replaced by unless the customer allows sending it. /// public sealed record FindingsSubmission { public const int CurrentSchemaVersion = 1; + public const string Redacted = "[udeladt]"; public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Unique per submission; the hub ignores a submission it has already processed. + public required Guid SubmissionId { get; init; } + public required string AgentVersion { get; init; } - public required string RulePackVersion { get; init; } public required DateTimeOffset CollectedAt { get; init; } public required InstanceSnapshot Instance { get; init; } - public required IReadOnlyList Results { get; init; } + public required IReadOnlyList Rules { get; init; } + public required IReadOnlyList Findings { get; init; } } public sealed record InstanceSnapshot { - /// A stable, customer-chosen name for the instance. The hub keys history on it. + /// The customer's name for the instance (SQLInsight:Instances:Name). The hub keys history on it. public required string InstanceKey { get; init; } + /// SQL Server release, e.g. "SQL Server 2019". + public required string Release { get; init; } + public required string ProductVersion { get; init; } public string? ProductLevel { get; init; } public string? ProductUpdateLevel { get; init; } - public required string Edition { get; init; } + public string? Edition { get; init; } + + /// The server's network name; only sent when the customer allows it. + public string? ServerName { get; init; } } -public sealed record RuleResultDto +public sealed record RuleOutcomeDto { public required string RuleId { get; init; } @@ -40,18 +52,26 @@ public sealed record RuleResultDto public required string Outcome { get; init; } public string? Reason { get; init; } - public IReadOnlyList Findings { get; init; } = []; } public sealed record FindingDto { + public required string RuleId { get; init; } + + /// Identifies the finding within the rule (e.g. "database: Sales"); empty for instance-wide findings. + public required string Subject { get; init; } + /// Info, Low, Medium, High or Critical. public required string Severity { get; init; } public required string Title { get; init; } public required string Explanation { get; init; } public required string Recommendation { get; init; } + public required DateTimeOffset FirstSeenAt { get; init; } public IReadOnlyList Evidence { get; init; } = []; } public sealed record EvidenceDto(string Name, string Value); + +/// The hub's answer. +public sealed record SubmissionReceipt(Guid SubmissionId, bool Duplicate, int ActiveFindings); diff --git a/src/SQLInsight.Core/Findings/Finding.cs b/src/SQLInsight.Core/Findings/Finding.cs index 5f77a4d..af24d1a 100644 --- a/src/SQLInsight.Core/Findings/Finding.cs +++ b/src/SQLInsight.Core/Findings/Finding.cs @@ -31,6 +31,10 @@ public sealed record Finding public IReadOnlyList References { get; init; } = []; } -public sealed record Evidence(string Name, string Value); +/// +/// The value can contain customer data: query text, logins, host or program names, error messages. Sensitive values +/// stay on-prem unless the customer allows sending them to SQLInsight Hub. +/// +public sealed record Evidence(string Name, string Value, bool Sensitive = false); public sealed record Reference(string Title, Uri Url); diff --git a/src/SQLInsight.Repository/InsightRepository.Hub.cs b/src/SQLInsight.Repository/InsightRepository.Hub.cs new file mode 100644 index 0000000..ebb0c0a --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Hub.cs @@ -0,0 +1,106 @@ +using Dapper; + +namespace SQLInsight.Repository; + +public sealed record OutboundSubmission(long OutboundId, Guid SubmissionId, string InstanceName, string Payload, int Attempts); + +public sealed record HubStatusRow +{ + public bool Enabled { get; init; } + public string? HubUrl { get; init; } + public string LicenseState { get; init; } = "None"; + public string LicenseMessage { get; init; } = string.Empty; + public string? Customer { get; init; } + public string? LicensePlan { get; init; } + public DateTime? LicenseExpires { get; init; } + public bool SendSensitive { get; init; } + public DateTime? LastSentAt { get; init; } + public string? LastError { get; init; } + public DateTime? LastErrorAt { get; init; } + public DateTime UpdatedAt { get; init; } + public int Pending { get; init; } +} + +public sealed partial class InsightRepository +{ + /// Queues the newest full state for an instance, replacing any older unsent state for it. + public async Task EnqueueSubmissionAsync(string instanceName, Guid submissionId, string payload, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + DELETE insight.OutboundSubmissions WHERE InstanceName = @instanceName AND SentAt IS NULL AND GaveUpAt IS NULL; + INSERT insight.OutboundSubmissions (SubmissionId, InstanceName, CreatedAt, Payload, NextAttemptAt) + VALUES (@submissionId, @instanceName, @now, @payload, @now); + """, new { instanceName, submissionId, payload, now = Utc(now) }).ConfigureAwait(false); + } + + public async Task> GetDueSubmissionsAsync(DateTimeOffset now, int max = 20, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT TOP (@max) OutboundId, SubmissionId, InstanceName, Payload, Attempts + FROM insight.OutboundSubmissions + WHERE SentAt IS NULL AND GaveUpAt IS NULL AND NextAttemptAt <= @now + ORDER BY NextAttemptAt; + """, new { max, now = Utc(now) }).ConfigureAwait(false); + return rows.ToList(); + } + + public async Task MarkSubmissionSentAsync(long outboundId, DateTimeOffset now, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.OutboundSubmissions SET SentAt = @now, Attempts = Attempts + 1, LastError = NULL WHERE OutboundId = @outboundId; + UPDATE insight.HubStatus SET LastSentAt = @now, LastError = NULL, LastErrorAt = NULL WHERE Id = 1; + """, new { outboundId, now = Utc(now) }).ConfigureAwait(false); + } + + /// Records a failed attempt. With null the submission is given up. + public async Task MarkSubmissionFailedAsync(long outboundId, string error, DateTimeOffset now, DateTimeOffset? nextAttemptAt, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + UPDATE insight.OutboundSubmissions + SET Attempts = Attempts + 1, LastError = @error, + NextAttemptAt = COALESCE(@next, NextAttemptAt), GaveUpAt = CASE WHEN @next IS NULL THEN @now END + WHERE OutboundId = @outboundId; + UPDATE insight.HubStatus SET LastError = @error, LastErrorAt = @now WHERE Id = 1; + """, new { outboundId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false); + } + + public async Task SaveHubStatusAsync(HubStatusRow status, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(status); + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + await connection.ExecuteAsync(""" + MERGE insight.HubStatus AS t + USING (SELECT 1 AS Id) AS s ON t.Id = s.Id + WHEN MATCHED THEN UPDATE SET Enabled = @Enabled, HubUrl = @HubUrl, LicenseState = @LicenseState, LicenseMessage = @LicenseMessage, + Customer = @Customer, LicensePlan = @LicensePlan, LicenseExpires = @LicenseExpires, SendSensitive = @SendSensitive, UpdatedAt = @UpdatedAt + WHEN NOT MATCHED THEN INSERT (Id, Enabled, HubUrl, LicenseState, LicenseMessage, Customer, LicensePlan, LicenseExpires, SendSensitive, UpdatedAt) + VALUES (1, @Enabled, @HubUrl, @LicenseState, @LicenseMessage, @Customer, @LicensePlan, @LicenseExpires, @SendSensitive, @UpdatedAt); + """, status).ConfigureAwait(false); + } + + public async Task GetHubStatusAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.QuerySingleOrDefaultAsync(""" + SELECT h.*, (SELECT COUNT(*) FROM insight.OutboundSubmissions WHERE SentAt IS NULL AND GaveUpAt IS NULL) AS Pending + FROM insight.HubStatus h WHERE h.Id = 1; + """).ConfigureAwait(false); + } + + /// The last submission sent (or queued) per instance, so the UI can show exactly what leaves the network. + public async Task> GetLatestSubmissionsAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync<(string, DateTime, DateTime?, string)>(""" + SELECT o.InstanceName, o.CreatedAt, o.SentAt, o.Payload + FROM insight.OutboundSubmissions o + WHERE o.OutboundId = (SELECT MAX(x.OutboundId) FROM insight.OutboundSubmissions x WHERE x.InstanceName = o.InstanceName) + ORDER BY o.InstanceName; + """).ConfigureAwait(false); + return rows.ToList(); + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs index 832c146..6a38ad1 100644 --- a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs +++ b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs @@ -68,6 +68,7 @@ public sealed partial class InsightRepository await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.OutboundSubmissions", "COALESCE(SentAt, GaveUpAt) < @cutoff", utc.AddDays(-7)).ConfigureAwait(false); } private static async Task DeleteInBatchesAsync(Microsoft.Data.SqlClient.SqlConnection connection, string table, string predicate, DateTime cutoff) diff --git a/src/SQLInsight.Repository/Migrations/0002_hub_sync.sql b/src/SQLInsight.Repository/Migrations/0002_hub_sync.sql new file mode 100644 index 0000000..c491ed2 --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0002_hub_sync.sql @@ -0,0 +1,34 @@ +/* Outbound queue to SQLInsight Hub. Each row is the full state of one instance, so only the newest unsent row per + instance matters; older unsent rows are removed when a new one is queued. */ +CREATE TABLE insight.OutboundSubmissions +( + OutboundId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_OutboundSubmissions PRIMARY KEY, + SubmissionId uniqueidentifier NOT NULL CONSTRAINT UQ_OutboundSubmissions_SubmissionId UNIQUE, + InstanceName nvarchar(128) NOT NULL, + CreatedAt datetime2(0) NOT NULL, + Payload nvarchar(max) NOT NULL, + Attempts int NOT NULL CONSTRAINT DF_OutboundSubmissions_Attempts DEFAULT (0), + NextAttemptAt datetime2(0) NOT NULL, + LastError nvarchar(max) NULL, + SentAt datetime2(0) NULL, + GaveUpAt datetime2(0) NULL +); +CREATE INDEX IX_OutboundSubmissions_Due ON insight.OutboundSubmissions (NextAttemptAt) WHERE SentAt IS NULL AND GaveUpAt IS NULL; + +/* What the agent last knew about the license and the hub, for the web UI. One row. */ +CREATE TABLE insight.HubStatus +( + Id tinyint NOT NULL CONSTRAINT PK_HubStatus PRIMARY KEY CONSTRAINT CK_HubStatus_Single CHECK (Id = 1), + Enabled bit NOT NULL, + HubUrl nvarchar(400) NULL, + LicenseState varchar(30) NOT NULL, + LicenseMessage nvarchar(1000) NOT NULL, + Customer nvarchar(200) NULL, + LicensePlan varchar(30) NULL, + LicenseExpires datetime2(0) NULL, + SendSensitive bit NOT NULL, + LastSentAt datetime2(0) NULL, + LastError nvarchar(max) NULL, + LastErrorAt datetime2(0) NULL, + UpdatedAt datetime2(0) NOT NULL +); diff --git a/src/SQLInsight.Rules/Availability/AvailabilityRules.cs b/src/SQLInsight.Rules/Availability/AvailabilityRules.cs index 9f33735..77b0317 100644 --- a/src/SQLInsight.Rules/Availability/AvailabilityRules.cs +++ b/src/SQLInsight.Rules/Availability/AvailabilityRules.cs @@ -138,7 +138,7 @@ public sealed class FailedJobRule : Rule + "Sørg for, at fejlende jobs sender en notifikation til en operator.", [ new Evidence("Fejlende trin", j.LastFailedStepName ?? "–"), - new Evidence("Fejl", j.LastFailedStepMessage ?? "–"), + new Evidence("Fejl", j.LastFailedStepMessage ?? "–", Sensitive: true), new Evidence("Fejl det seneste døgn", j.FailuresLastDay.ToString(System.Globalization.CultureInfo.InvariantCulture)), ], subject: $"job: {j.Name}", diff --git a/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs b/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs index f20353a..a81c248 100644 --- a/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs +++ b/src/SQLInsight.Rules/Concurrency/ConcurrencyRules.cs @@ -33,14 +33,14 @@ public sealed class BlockingRule : Rule var idleInTransaction = head.Status == "sleeping" && head.OpenTransactionCount > 0; var evidence = new List { - new("Head blocker", $"session {head.SessionId} ({head.Status}), {head.LoginName} fra {head.HostName} / {head.ProgramName}"), + new("Head blocker", $"session {head.SessionId} ({head.Status}), {head.LoginName} fra {head.HostName} / {head.ProgramName}", Sensitive: true), new("Åbne transaktioner", head.OpenTransactionCount.ToString(CultureInfo.InvariantCulture)), new("Blokerede sessioner", victims.Count.ToString(CultureInfo.InvariantCulture)), new("Længste ventetid", $"{Format.Number(longest / 1000.0)} s"), - new("Head blockers SQL", head.SqlText ?? "(ukendt)"), + new("Head blockers SQL", head.SqlText ?? "(ukendt)", Sensitive: true), }; evidence.AddRange(victims.OrderByDescending(v => v.WaitTimeMs).Take(3).Select(v => - new Evidence($"Blokeret session {v.SessionId}", $"{v.WaitType} på {v.WaitResource}: {v.SqlText}"))); + new Evidence($"Blokeret session {v.SessionId}", $"{v.WaitType} på {v.WaitResource}: {v.SqlText}", Sensitive: true))); findings.Add(CreateFinding( longest >= 300_000 || victims.Count >= 10 ? Severity.High : Severity.Medium, @@ -112,7 +112,7 @@ public sealed class DeadlockRule : Rule { evidence.Add(new Evidence( $"{deadlock.Time:yyyy-MM-dd HH:mm:ss} {(p.IsVictim ? "offer" : "vinder")} session {p.SessionId}", - $"{p.LockMode} på {p.WaitResource}, {p.ClientApp}: {p.InputBuffer}")); + $"{p.LockMode} på {p.WaitResource}, {p.ClientApp}: {p.InputBuffer}", Sensitive: true)); } } diff --git a/src/SQLInsight.Rules/Performance/PlanRules.cs b/src/SQLInsight.Rules/Performance/PlanRules.cs index 71c14e5..471ae9e 100644 --- a/src/SQLInsight.Rules/Performance/PlanRules.cs +++ b/src/SQLInsight.Rules/Performance/PlanRules.cs @@ -28,7 +28,7 @@ public abstract class PlanRule : Rule protected static List Basics(AnalyzedStatement s) => [ - new("Statement", QueryStoreText.Shorten(s.StatementText)), + new("Statement", QueryStoreText.Shorten(s.StatementText), Sensitive: true), new("Database", s.DatabaseName ?? "?"), new("Udførelser", Format.Number(s.Executions)), new("CPU i alt / gns.", $"{Format.Number(s.TotalWorkerTimeUs / 1_000_000.0)} s / {QueryStoreText.Ms(s.AverageWorkerTimeUs)}"), @@ -50,7 +50,7 @@ public sealed class ImplicitConversionRule : PlanRule } var evidence = Basics(s); - evidence.AddRange(s.Warnings.SeekPreventingConversions.Take(5).Select(c => new Evidence("Konvertering", c))); + evidence.AddRange(s.Warnings.SeekPreventingConversions.Take(5).Select(c => new Evidence("Konvertering", c, Sensitive: true))); return CreateFinding( Severity.Medium, $"Implicit konvertering forhindrer index seek i en tung forespørgsel ({s.DatabaseName})", diff --git a/src/SQLInsight.Rules/Performance/QueryStoreRules.cs b/src/SQLInsight.Rules/Performance/QueryStoreRules.cs index 93c6f0d..f9d992c 100644 --- a/src/SQLInsight.Rules/Performance/QueryStoreRules.cs +++ b/src/SQLInsight.Rules/Performance/QueryStoreRules.cs @@ -83,7 +83,7 @@ public sealed class PlanRegressionRule : Rule + "forceres som akut løsning, og årsagen findes bagefter. Følg op på, om forceringen lykkes (se SQLI-QS-002). " + "På Enterprise Edition kan automatic tuning (FORCE_LAST_GOOD_PLAN) gøre dette automatisk.", [ - new Evidence("Forespørgsel", QueryStoreText.Describe(slow.QueryText is null ? best : slow)), + new Evidence("Forespørgsel", QueryStoreText.Describe(slow.QueryText is null ? best : slow), Sensitive: true), new Evidence($"Ny plan {slow.PlanId} (seneste døgn)", $"{Format.Number(slow.Executions)} udførelser, gns. {QueryStoreText.Ms(slow.AverageDurationUs)}, {Format.Number(slow.AverageLogicalReads)} læsninger"), new Evidence($"Bedste plan {best.PlanId} (ugen før)", $"{Format.Number(best.Executions)} udførelser, gns. {QueryStoreText.Ms(best.AverageDurationUs)}, {Format.Number(best.AverageLogicalReads)} læsninger"), new Evidence("Ekstra tid seneste døgn", QueryStoreText.Seconds(worst.Extra)), @@ -120,7 +120,7 @@ public sealed class FailedForcedPlanRule : Rule ? "Genopret det index, planen bruger (se planens XML), eller fjern forceringen og find en ny god plan." : "Fjern forceringen, og vurdér forespørgslen igen. Se last_force_failure_reason_desc i sys.query_store_plan.", [ - new Evidence("Forespørgsel", QueryStoreText.Shorten(f.QueryText)), + new Evidence("Forespørgsel", QueryStoreText.Shorten(f.QueryText), Sensitive: true), new Evidence("Fejl", $"{Format.Number(f.FailureCount)} gange, senest {f.Reason}"), ], subject: $"query: {db.Name}/{f.QueryId}", @@ -170,7 +170,7 @@ public sealed class ParameterSensitivityRule : Rule continue; } - var evidence = new List { new("Forespørgsel", QueryStoreText.Describe(busiest)) }; + var evidence = new List { new("Forespørgsel", QueryStoreText.Describe(busiest), Sensitive: true) }; evidence.AddRange(plans.OrderBy(p => p.AverageDurationUs).Select(p => new Evidence( $"Plan {p.PlanId}", $"{Format.Number(p.Executions)} udførelser, gns. {QueryStoreText.Ms(p.AverageDurationUs)}, min. {QueryStoreText.Ms(p.MinDurationUs)}, maks. {QueryStoreText.Ms(p.MaxDurationUs)}"))); diff --git a/tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj b/tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj new file mode 100644 index 0000000..79dc568 --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs b/tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs new file mode 100644 index 0000000..0398889 --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs @@ -0,0 +1,87 @@ +using SQLInsight.Agent.Hub; +using SQLInsight.Contracts; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Agent.Tests; + +public class SubmissionBuilderTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero); + + private static readonly InstanceInfo Info = new() + { + ServerName = "SQL01.corp.local", + Version = SqlServerVersion.Parse("15.0.4490.9"), + ProductLevel = "RTM", + ProductUpdateLevel = "CU32", + Edition = "Enterprise Edition", + EngineEdition = EngineEdition.Enterprise, + }; + + private static readonly StoredFinding Blocking = new() + { + FindingId = 1, + RuleId = "SQLI-BLK-001", + Subject = "session: 51", + Severity = Severity.High, + Title = "Session 51 blokerer", + Explanation = "Forklaring", + Recommendation = "Anbefaling", + FirstSeenAt = Now.UtcDateTime.AddMinutes(-5), + Evidence = + [ + new Evidence("Blokerede sessioner", "3"), + new Evidence("Head blockers SQL", "UPDATE dbo.Salaries SET Amount = 100000 WHERE Name = 'Hansen'", Sensitive: true), + ], + }; + + private static RuleResult[] Results => [new RuleResult { RuleId = "SQLI-BLK-001", RuleTitle = "t", Outcome = RuleOutcome.Findings }]; + + [Fact] + public void Sensitive_evidence_and_server_name_stay_on_prem_by_default() + { + var submission = SubmissionBuilder.Build("prod-sql01", Info, Results, [Blocking], new HubOptions(), Now); + + var evidence = Assert.Single(submission.Findings).Evidence; + Assert.Equal("3", evidence[0].Value); + Assert.Equal(FindingsSubmission.Redacted, evidence[1].Value); + Assert.Null(submission.Instance.ServerName); + Assert.Equal("prod-sql01", submission.Instance.InstanceKey); + Assert.Equal("SQL Server 2019", submission.Instance.Release); + } + + [Fact] + public void Customer_can_allow_sensitive_evidence_and_server_name() + { + var options = new HubOptions { SendSensitiveEvidence = true, SendServerName = true }; + + var submission = SubmissionBuilder.Build("prod-sql01", Info, Results, [Blocking], options, Now); + + Assert.StartsWith("UPDATE dbo.Salaries", submission.Findings[0].Evidence[1].Value, StringComparison.Ordinal); + Assert.Equal("SQL01.corp.local", submission.Instance.ServerName); + } + + [Fact] + public void Every_submission_has_a_new_id_and_carries_rule_outcomes() + { + var a = SubmissionBuilder.Build("x", Info, Results, [], new HubOptions(), Now); + var b = SubmissionBuilder.Build("x", Info, Results, [], new HubOptions(), Now); + + Assert.NotEqual(a.SubmissionId, b.SubmissionId); + Assert.Equal("Findings", Assert.Single(a.Rules).Outcome); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 2)] + [InlineData(3, 8)] + [InlineData(10, 60)] + [InlineData(50, 60)] + public void Backoff_doubles_up_to_an_hour(int attempts, int minutes) + { + Assert.Equal(TimeSpan.FromMinutes(minutes), HubSyncService.Backoff(attempts)); + } +}