Fase 3: findings sync from agent to hub (queue, retry, data minimization)
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
parent
1ba274d699
commit
bfb27778d2
21 changed files with 562 additions and 25 deletions
|
|
@ -13,6 +13,7 @@
|
||||||
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
|
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj" />
|
||||||
<Project Path="tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj" />
|
<Project Path="tests/SQLInsight.Collectors.Tests/SQLInsight.Collectors.Tests.csproj" />
|
||||||
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
|
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
|
||||||
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
|
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
|
||||||
|
|
|
||||||
33
src/SQLInsight.Agent/Hub/HubOptions.cs
Normal file
33
src/SQLInsight.Agent/Hub/HubOptions.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace SQLInsight.Agent.Hub;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Findings sync to SQLInsight Hub (Professional and Enterprise). Off by default; nothing leaves the network unless
|
||||||
|
/// it is switched on.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HubOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "SQLInsight:Hub";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base URL of SQLInsight Hub, e.g. https://hub.seosoft.dk/.</summary>
|
||||||
|
public Uri? Url { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The license text (SQLI1.…).</summary>
|
||||||
|
public string? License { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Also send evidence marked sensitive: query text, logins, host and program names, job error messages.
|
||||||
|
/// Off by default; such values are sent as "[udeladt]".
|
||||||
|
/// </summary>
|
||||||
|
public bool SendSensitiveEvidence { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Also send the server's network name. Off by default; the instance is identified by its configured name.</summary>
|
||||||
|
public bool SendServerName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How often the state is sent when nothing changed. Changes are sent at once.</summary>
|
||||||
|
public TimeSpan SubmitInterval { get; set; } = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
/// <summary>Additional public keys trusted for licenses (key id → base64 SubjectPublicKeyInfo).</summary>
|
||||||
|
public IDictionary<string, string> TrustedKeys { get; } = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
42
src/SQLInsight.Agent/Hub/HubOutbox.cs
Normal file
42
src/SQLInsight.Agent/Hub/HubOutbox.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an instance's state for the hub: at once when findings changed, otherwise at most every SubmitInterval.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HubOutbox(IOptions<HubOptions> options, InsightRepository repository)
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, DateTimeOffset> _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<RuleResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/SQLInsight.Agent/Hub/HubSyncService.cs
Normal file
119
src/SQLInsight.Agent/Hub/HubSyncService.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class HubSyncService(
|
||||||
|
IOptions<HubOptions> options,
|
||||||
|
InsightRepository repository,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
TimeProvider time,
|
||||||
|
ILogger<HubSyncService> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One delivery round. Returns how many submissions were sent.</summary>
|
||||||
|
public async Task<int> 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);
|
||||||
|
|
||||||
|
/// <summary>1, 2, 4 … minutes, at most an hour.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
56
src/SQLInsight.Agent/Hub/SubmissionBuilder.cs
Normal file
56
src/SQLInsight.Agent/Hub/SubmissionBuilder.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using SQLInsight.Contracts;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Repository;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Agent.Hub;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns an instance's current state into a hub submission, leaving out what the customer has not allowed.
|
||||||
|
/// </summary>
|
||||||
|
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<RuleResult> results, IReadOnlyList<StoredFinding> 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,8 @@ public sealed partial class InstanceMonitor(
|
||||||
InsightRepository repository,
|
InsightRepository repository,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
NotificationDispatcher? notifications = null,
|
NotificationDispatcher? notifications = null,
|
||||||
Uri? webUrl = null)
|
Uri? webUrl = null,
|
||||||
|
Hub.HubOutbox? hubOutbox = null)
|
||||||
{
|
{
|
||||||
private bool? _wasReachable;
|
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.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)));
|
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);
|
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 findings = 0;
|
||||||
var notMeasurable = 0;
|
var notMeasurable = 0;
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ public sealed partial class MonitoringService(
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
NotificationDispatcher notifications,
|
NotificationDispatcher notifications,
|
||||||
IOptions<NotificationOptions> notificationOptions) : BackgroundService
|
IOptions<NotificationOptions> notificationOptions,
|
||||||
|
Hub.HubOutbox hubOutbox) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
|
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
|
||||||
|
|
||||||
|
|
@ -30,7 +31,7 @@ public sealed partial class MonitoringService(
|
||||||
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
||||||
var monitors = settings.Instances
|
var monitors = settings.Instances
|
||||||
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(), intervals, connectionFactory, ruleEngine, repository,
|
.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();
|
.ToList();
|
||||||
if (monitors.Count == 0)
|
if (monitors.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ builder.Services.AddSingleton(sp =>
|
||||||
|
|
||||||
return new NotificationDispatcher(channels, options, sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<NotificationDispatcher>>());
|
return new NotificationDispatcher(channels, options, sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<NotificationDispatcher>>());
|
||||||
});
|
});
|
||||||
|
builder.Services.Configure<SQLInsight.Agent.Hub.HubOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName));
|
||||||
|
builder.Services.AddHttpClient("hub", c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
|
builder.Services.AddSingleton<SQLInsight.Agent.Hub.HubOutbox>();
|
||||||
|
builder.Services.AddHostedService<SQLInsight.Agent.Hub.HubSyncService>();
|
||||||
builder.Services.AddHostedService<MonitoringService>();
|
builder.Services.AddHostedService<MonitoringService>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
<ProjectReference Include="..\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
|
<ProjectReference Include="..\SQLInsight.Collectors\SQLInsight.Collectors.csproj" />
|
||||||
<ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" />
|
<ProjectReference Include="..\SQLInsight.Repository\SQLInsight.Repository.csproj" />
|
||||||
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
|
<ProjectReference Include="..\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
|
||||||
|
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@
|
||||||
"MaxParallelInstances": 4,
|
"MaxParallelInstances": 4,
|
||||||
"CollectorIntervals": {
|
"CollectorIntervals": {
|
||||||
},
|
},
|
||||||
|
"Hub": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Url": "https://hub.seosoft.dk/",
|
||||||
|
"License": null,
|
||||||
|
"SendSensitiveEvidence": false,
|
||||||
|
"SendServerName": false,
|
||||||
|
"SubmitInterval": "00:15:00"
|
||||||
|
},
|
||||||
"Notifications": {
|
"Notifications": {
|
||||||
"WebUrl": "https://sqlinsight.eksempel.local/",
|
"WebUrl": "https://sqlinsight.eksempel.local/",
|
||||||
"NotifyResolved": true,
|
"NotifyResolved": true,
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,50 @@
|
||||||
namespace SQLInsight.Contracts;
|
namespace SQLInsight.Contracts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What an agent sends to SQLInsight Hub when findings sync is enabled. This is a wire contract between
|
/// What an agent sends to SQLInsight Hub: the complete current state of one instance (all active findings and every
|
||||||
/// independently released agents and the hub: add optional members only, and bump
|
/// rule's outcome). Full state rather than changes, so a lost or repeated submission never leaves the hub wrong, and
|
||||||
/// <see cref="CurrentSchemaVersion"/> for anything else.
|
/// only the newest submission per instance has to be delivered.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Only findings and instance metadata are sent. Table data is never sent, and query text only when the customer
|
/// A wire contract between independently released agents and the hub: add optional members only, and bump
|
||||||
/// has allowed it.
|
/// <see cref="CurrentSchemaVersion"/> for anything else. Sensitive evidence (query text, logins, hosts, messages) is
|
||||||
|
/// replaced by <see cref="Redacted"/> unless the customer allows sending it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record FindingsSubmission
|
public sealed record FindingsSubmission
|
||||||
{
|
{
|
||||||
public const int CurrentSchemaVersion = 1;
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
public const string Redacted = "[udeladt]";
|
||||||
|
|
||||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
/// <summary>Unique per submission; the hub ignores a submission it has already processed.</summary>
|
||||||
|
public required Guid SubmissionId { get; init; }
|
||||||
|
|
||||||
public required string AgentVersion { get; init; }
|
public required string AgentVersion { get; init; }
|
||||||
public required string RulePackVersion { get; init; }
|
|
||||||
public required DateTimeOffset CollectedAt { get; init; }
|
public required DateTimeOffset CollectedAt { get; init; }
|
||||||
public required InstanceSnapshot Instance { get; init; }
|
public required InstanceSnapshot Instance { get; init; }
|
||||||
public required IReadOnlyList<RuleResultDto> Results { get; init; }
|
public required IReadOnlyList<RuleOutcomeDto> Rules { get; init; }
|
||||||
|
public required IReadOnlyList<FindingDto> Findings { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record InstanceSnapshot
|
public sealed record InstanceSnapshot
|
||||||
{
|
{
|
||||||
/// <summary>A stable, customer-chosen name for the instance. The hub keys history on it.</summary>
|
/// <summary>The customer's name for the instance (SQLInsight:Instances:Name). The hub keys history on it.</summary>
|
||||||
public required string InstanceKey { get; init; }
|
public required string InstanceKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>SQL Server release, e.g. "SQL Server 2019".</summary>
|
||||||
|
public required string Release { get; init; }
|
||||||
|
|
||||||
public required string ProductVersion { get; init; }
|
public required string ProductVersion { get; init; }
|
||||||
public string? ProductLevel { get; init; }
|
public string? ProductLevel { get; init; }
|
||||||
public string? ProductUpdateLevel { get; init; }
|
public string? ProductUpdateLevel { get; init; }
|
||||||
public required string Edition { get; init; }
|
public string? Edition { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The server's network name; only sent when the customer allows it.</summary>
|
||||||
|
public string? ServerName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RuleResultDto
|
public sealed record RuleOutcomeDto
|
||||||
{
|
{
|
||||||
public required string RuleId { get; init; }
|
public required string RuleId { get; init; }
|
||||||
|
|
||||||
|
|
@ -40,18 +52,26 @@ public sealed record RuleResultDto
|
||||||
public required string Outcome { get; init; }
|
public required string Outcome { get; init; }
|
||||||
|
|
||||||
public string? Reason { get; init; }
|
public string? Reason { get; init; }
|
||||||
public IReadOnlyList<FindingDto> Findings { get; init; } = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record FindingDto
|
public sealed record FindingDto
|
||||||
{
|
{
|
||||||
|
public required string RuleId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Identifies the finding within the rule (e.g. "database: Sales"); empty for instance-wide findings.</summary>
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
/// <summary>Info, Low, Medium, High or Critical.</summary>
|
/// <summary>Info, Low, Medium, High or Critical.</summary>
|
||||||
public required string Severity { get; init; }
|
public required string Severity { get; init; }
|
||||||
|
|
||||||
public required string Title { get; init; }
|
public required string Title { get; init; }
|
||||||
public required string Explanation { get; init; }
|
public required string Explanation { get; init; }
|
||||||
public required string Recommendation { get; init; }
|
public required string Recommendation { get; init; }
|
||||||
|
public required DateTimeOffset FirstSeenAt { get; init; }
|
||||||
public IReadOnlyList<EvidenceDto> Evidence { get; init; } = [];
|
public IReadOnlyList<EvidenceDto> Evidence { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record EvidenceDto(string Name, string Value);
|
public sealed record EvidenceDto(string Name, string Value);
|
||||||
|
|
||||||
|
/// <summary>The hub's answer.</summary>
|
||||||
|
public sealed record SubmissionReceipt(Guid SubmissionId, bool Duplicate, int ActiveFindings);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ public sealed record Finding
|
||||||
public IReadOnlyList<Reference> References { get; init; } = [];
|
public IReadOnlyList<Reference> References { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record Evidence(string Name, string Value);
|
/// <param name="Sensitive">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
public sealed record Evidence(string Name, string Value, bool Sensitive = false);
|
||||||
|
|
||||||
public sealed record Reference(string Title, Uri Url);
|
public sealed record Reference(string Title, Uri Url);
|
||||||
|
|
|
||||||
106
src/SQLInsight.Repository/InsightRepository.Hub.cs
Normal file
106
src/SQLInsight.Repository/InsightRepository.Hub.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>Queues the newest full state for an instance, replacing any older unsent state for it.</summary>
|
||||||
|
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<IReadOnlyList<OutboundSubmission>> GetDueSubmissionsAsync(DateTimeOffset now, int max = 20, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await connection.QueryAsync<OutboundSubmission>("""
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a failed attempt. With <paramref name="nextAttemptAt"/> null the submission is given up.</summary>
|
||||||
|
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<HubStatusRow?> GetHubStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<HubStatusRow>("""
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The last submission sent (or queued) per instance, so the UI can show exactly what leaves the network.</summary>
|
||||||
|
public async Task<IReadOnlyList<(string InstanceName, DateTime CreatedAt, DateTime? SentAt, string Payload)>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,7 @@ public sealed partial class InsightRepository
|
||||||
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @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.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)
|
private static async Task DeleteInBatchesAsync(Microsoft.Data.SqlClient.SqlConnection connection, string table, string predicate, DateTime cutoff)
|
||||||
|
|
|
||||||
34
src/SQLInsight.Repository/Migrations/0002_hub_sync.sql
Normal file
34
src/SQLInsight.Repository/Migrations/0002_hub_sync.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -138,7 +138,7 @@ public sealed class FailedJobRule : Rule
|
||||||
+ "Sørg for, at fejlende jobs sender en notifikation til en operator.",
|
+ "Sørg for, at fejlende jobs sender en notifikation til en operator.",
|
||||||
[
|
[
|
||||||
new Evidence("Fejlende trin", j.LastFailedStepName ?? "–"),
|
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)),
|
new Evidence("Fejl det seneste døgn", j.FailuresLastDay.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
],
|
],
|
||||||
subject: $"job: {j.Name}",
|
subject: $"job: {j.Name}",
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,14 @@ public sealed class BlockingRule : Rule
|
||||||
var idleInTransaction = head.Status == "sleeping" && head.OpenTransactionCount > 0;
|
var idleInTransaction = head.Status == "sleeping" && head.OpenTransactionCount > 0;
|
||||||
var evidence = new List<Evidence>
|
var evidence = new List<Evidence>
|
||||||
{
|
{
|
||||||
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("Åbne transaktioner", head.OpenTransactionCount.ToString(CultureInfo.InvariantCulture)),
|
||||||
new("Blokerede sessioner", victims.Count.ToString(CultureInfo.InvariantCulture)),
|
new("Blokerede sessioner", victims.Count.ToString(CultureInfo.InvariantCulture)),
|
||||||
new("Længste ventetid", $"{Format.Number(longest / 1000.0)} s"),
|
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 =>
|
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(
|
findings.Add(CreateFinding(
|
||||||
longest >= 300_000 || victims.Count >= 10 ? Severity.High : Severity.Medium,
|
longest >= 300_000 || victims.Count >= 10 ? Severity.High : Severity.Medium,
|
||||||
|
|
@ -112,7 +112,7 @@ public sealed class DeadlockRule : Rule
|
||||||
{
|
{
|
||||||
evidence.Add(new Evidence(
|
evidence.Add(new Evidence(
|
||||||
$"{deadlock.Time:yyyy-MM-dd HH:mm:ss} {(p.IsVictim ? "offer" : "vinder")} session {p.SessionId}",
|
$"{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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public abstract class PlanRule : Rule
|
||||||
|
|
||||||
protected static List<Evidence> Basics(AnalyzedStatement s) =>
|
protected static List<Evidence> Basics(AnalyzedStatement s) =>
|
||||||
[
|
[
|
||||||
new("Statement", QueryStoreText.Shorten(s.StatementText)),
|
new("Statement", QueryStoreText.Shorten(s.StatementText), Sensitive: true),
|
||||||
new("Database", s.DatabaseName ?? "?"),
|
new("Database", s.DatabaseName ?? "?"),
|
||||||
new("Udførelser", Format.Number(s.Executions)),
|
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)}"),
|
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);
|
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(
|
return CreateFinding(
|
||||||
Severity.Medium,
|
Severity.Medium,
|
||||||
$"Implicit konvertering forhindrer index seek i en tung forespørgsel ({s.DatabaseName})",
|
$"Implicit konvertering forhindrer index seek i en tung forespørgsel ({s.DatabaseName})",
|
||||||
|
|
|
||||||
|
|
@ -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). "
|
+ "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.",
|
+ "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($"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($"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)),
|
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."
|
? "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.",
|
: "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}"),
|
new Evidence("Fejl", $"{Format.Number(f.FailureCount)} gange, senest {f.Reason}"),
|
||||||
],
|
],
|
||||||
subject: $"query: {db.Name}/{f.QueryId}",
|
subject: $"query: {db.Name}/{f.QueryId}",
|
||||||
|
|
@ -170,7 +170,7 @@ public sealed class ParameterSensitivityRule : Rule
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var evidence = new List<Evidence> { new("Forespørgsel", QueryStoreText.Describe(busiest)) };
|
var evidence = new List<Evidence> { new("Forespørgsel", QueryStoreText.Describe(busiest), Sensitive: true) };
|
||||||
evidence.AddRange(plans.OrderBy(p => p.AverageDurationUs).Select(p => new Evidence(
|
evidence.AddRange(plans.OrderBy(p => p.AverageDurationUs).Select(p => new Evidence(
|
||||||
$"Plan {p.PlanId}",
|
$"Plan {p.PlanId}",
|
||||||
$"{Format.Number(p.Executions)} udførelser, gns. {QueryStoreText.Ms(p.AverageDurationUs)}, min. {QueryStoreText.Ms(p.MinDurationUs)}, maks. {QueryStoreText.Ms(p.MaxDurationUs)}")));
|
$"{Format.Number(p.Executions)} udførelser, gns. {QueryStoreText.Ms(p.AverageDurationUs)}, min. {QueryStoreText.Ms(p.MinDurationUs)}, maks. {QueryStoreText.Ms(p.MaxDurationUs)}")));
|
||||||
|
|
|
||||||
14
tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj
Normal file
14
tests/SQLInsight.Agent.Tests/SQLInsight.Agent.Tests.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\SQLInsight.Agent\SQLInsight.Agent.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
87
tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs
Normal file
87
tests/SQLInsight.Agent.Tests/SubmissionBuilderTests.cs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue