diff --git a/README.md b/README.md index efa297c..b37040b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA. -> **Status:** Fase 0 (fundament) er i gang. Se [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md) for at bygge og teste. +> **Status:** Fase 1 (Community MVP) er implementeret: agent, 42 regler, lokalt repository og web-UI. Se [installation](docs/installation.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). --- @@ -172,14 +172,15 @@ Hvert **finding** indeholder: - [x] Bidragsguide, code of conduct og issue templates ### Fase 1: Community MVP -- [ ] Agent som Windows Service med forbindelse til en eller flere SQL Server-instanser -- [ ] Collectors: wait stats, blocking, deadlocks (XE), CPU/memory, I/O-latens og konfiguration -- [ ] Regelmotor med de første ~30 regler, som hver har forklaring og anbefaling -- [ ] Lokalt repository med retention-politik -- [ ] Web-UI: serveroversigt, liste over fund, detaljevisning og simple trends -- [ ] Registrering af version og CU-niveau med advarsel om manglende opdateringer -- [ ] Legacy-collectors til 2000/2005/2008: blocking, waits, konfiguration, backup og version -- [ ] MSI-installer og dokumentation +- [x] Agent som Windows Service (og systemd) med forbindelse til en eller flere SQL Server-instanser, parallelt og isoleret +- [x] Collectors: wait stats, blocking, deadlocks (system_health), CPU, hukommelse, I/O-latens, konfiguration, databaser, backups, CHECKDB, VLF'er, Query Store, tempdb, trace flags, suspect pages og plan cache +- [x] Regelmotor med 42 regler, som hver har forklaring, evidens, anbefaling og eventuelt et fix-script +- [x] Lokalt repository med migrationer, sporing af fund (set første gang, senest, løst), timeaggregering og retention +- [x] Web-UI: oversigt, fund, detaljer, trends, ventetider, I/O, blocking og deadlocks (.xdl). Kan køre på IIS eller som Windows-tjeneste, med Windows-login +- [x] Registrering af version og CU-niveau med advarsel om manglende opdateringer (katalog pr. september 2026) +- [x] Legacy-collectors til 2000/2005/2008: versionsspecifik SQL er skrevet og unit-testet, men ikke kørt mod rigtige instanser (kræver VM'er, se Fase 0) +- [x] Installationsvejledning ([docs/installation.md](docs/installation.md)) +- [ ] MSI-installer (kræver Windows-byggemiljø, WiX) ### Fase 2: Dyb analyse - [ ] Integration med Query Store: regressioner, analyse af planændringer og parameter sniffing diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index f60c927..1fb148a 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -13,6 +13,7 @@ public static class BuiltInRules [ // Version new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()), + new PatchLevelRule(BuildCatalog.LoadEmbedded(), SupportLifecycleCatalog.LoadEmbedded()), // Configuration new MaxServerMemoryDefaultRule(), diff --git a/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs b/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs new file mode 100644 index 0000000..0891e6d --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/BuildCatalog.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Rules.Lifecycle; + +public sealed record KnownBuild(SqlServerRelease Release, SqlServerVersion Build, string Name, string Kb, DateOnly Released, bool Security); + +/// +/// The newest known build per release. Shipped as data with the rule pack; says how current it is. +/// +public sealed class BuildCatalog(DateOnly asOf, IEnumerable builds) +{ + private const string ResourceName = "SQLInsight.Rules.build-catalog.json"; + + private readonly Dictionary _builds = builds.ToDictionary(b => b.Release); + + public DateOnly AsOf { get; } = asOf; + + public KnownBuild? Latest(SqlServerRelease release) => _builds.GetValueOrDefault(release); + + public static BuildCatalog LoadEmbedded() + { + using var stream = typeof(BuildCatalog).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing."); + var document = JsonSerializer.Deserialize(stream, JsonOptions) + ?? throw new InvalidOperationException("The build catalog is empty."); + return new BuildCatalog(document.AsOf, document.Releases.Select(r => + new KnownBuild(r.Release, SqlServerVersion.Parse(r.Build), r.Name, r.Kb, r.Released, r.Security))); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + private sealed record Document(DateOnly AsOf, IReadOnlyList Releases); + + private sealed record Entry(SqlServerRelease Release, string Build, string Name, string Kb, DateOnly Released, bool Security); +} diff --git a/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs b/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs new file mode 100644 index 0000000..6ed15e6 --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Lifecycle; + +/// +/// Instance behind the newest known cumulative update, security update or service pack. +/// +public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog lifecycle) : Rule +{ + /// After this long the catalog itself is flagged as possibly outdated. + public static readonly TimeSpan CatalogMaxAge = TimeSpan.FromDays(120); + + public override string Id => "SQLI-VER-002"; + public override string Title => "Instansen mangler opdateringer"; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var instance = context.Instance; + var latest = catalog.Latest(instance.Release) + ?? throw new Core.Collection.DataUnavailableException($"Regelpakken kender ikke seneste build for {instance.Release.DisplayName()}. Opdater SQLInsight Pro."); + + if (instance.Version >= latest.Build) + { + return RuleEvaluation.Passed(); + } + + var today = DateOnly.FromDateTime(context.Now.UtcDateTime); + var supported = lifecycle.Find(instance.Release)?.ExtendedEnd is { } end && today <= end; + var catalogAge = today.ToDateTime(TimeOnly.MinValue) - catalog.AsOf.ToDateTime(TimeOnly.MinValue); + var current = $"{instance.Version}" + (instance.ProductLevel is { } level ? $" ({level}{(instance.ProductUpdateLevel is { } cu ? " " + cu : string.Empty)})" : string.Empty); + + var evidence = new List + { + new("Installeret build", current), + new("Seneste kendte build", $"{latest.Build} ({latest.Name}, {latest.Kb}, {latest.Released.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)})"), + new("Katalog pr.", catalog.AsOf.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + + (catalogAge > CatalogMaxAge ? " (forældet: opdater SQLInsight Pro for at se nyere opdateringer)" : string.Empty)), + }; + + return RuleEvaluation.Found(CreateFinding( + supported ? Severity.High : Severity.Medium, + $"{instance.Release.DisplayName()} kører {instance.Version}; seneste er {latest.Name} ({latest.Build})", + supported + ? "Instansen mangler opdateringer, som Microsoft har udgivet. Cumulative updates indeholder rettelser af fejl, " + + "performanceproblemer og sikkerhedshuller" + (latest.Security ? ", og den seneste er en sikkerhedsopdatering." : ".") + + " Microsoft anbefaler at installere CU'er proaktivt." + : "Versionen er uden for Microsofts support, men instansen mangler også den seneste service pack, som i det mindste " + + "retter de kendte fejl fra versionens levetid.", + (supported + ? $"Installér {latest.Name} ({latest.Kb}). Test først i et testmiljø, og tag en fuld backup inden. " + + "Står instansen på GDR-sporet (kun sikkerhedsopdateringer), kan den flyttes til CU-sporet ved at installere seneste CU." + : $"Installér {latest.Name} ({latest.Kb}) som et første skridt, og planlæg opgradering til en supporteret version (se SQLI-VER-001).") + + " Ved Always On: opdatér sekundære replikaer først, lav failover, og opdatér derefter den tidligere primære.", + evidence, + references: new Reference("Seneste opdateringer til SQL Server", new Uri("https://learn.microsoft.com/troubleshoot/sql/releases/download-and-install-latest-updates")))); + } +} diff --git a/src/SQLInsight.Rules/Lifecycle/build-catalog.json b/src/SQLInsight.Rules/Lifecycle/build-catalog.json new file mode 100644 index 0000000..12bf66c --- /dev/null +++ b/src/SQLInsight.Rules/Lifecycle/build-catalog.json @@ -0,0 +1,17 @@ +{ + "asOf": "2026-09-29", + "comment": "Newest known build per release. Supported releases: latest CU or security update. Older releases: final service pack (later security-only builds may exist). Update with the rule pack.", + "releases": [ + { "release": "SqlServer2025", "build": "17.0.5005.3", "name": "CU9", "kb": "KB5122048", "released": "2026-09-15", "security": false }, + { "release": "SqlServer2022", "build": "16.0.4295.3", "name": "CU27", "kb": "KB5104824", "released": "2026-09-15", "security": false }, + { "release": "SqlServer2019", "build": "15.0.4490.9", "name": "CU32 + sikkerhedsopdatering sept. 2026", "kb": "KB5122772", "released": "2026-09-08", "security": true }, + { "release": "SqlServer2017", "build": "14.0.3550.4", "name": "CU31 + sikkerhedsopdatering sept. 2026", "kb": "KB5122774", "released": "2026-09-08", "security": true }, + { "release": "SqlServer2016", "build": "13.0.6300.2", "name": "SP3", "kb": "KB5003279", "released": "2021-09-15", "security": false }, + { "release": "SqlServer2014", "build": "12.0.6024.0", "name": "SP3", "kb": "KB4022619", "released": "2018-10-30", "security": false }, + { "release": "SqlServer2012", "build": "11.0.7001.0", "name": "SP4", "kb": "KB4018073", "released": "2017-10-05", "security": false }, + { "release": "SqlServer2008R2", "build": "10.50.6000.34", "name": "SP3", "kb": "KB2979597", "released": "2014-09-26", "security": false }, + { "release": "SqlServer2008", "build": "10.0.6000.29", "name": "SP4", "kb": "KB2979596", "released": "2014-09-30", "security": false }, + { "release": "SqlServer2005", "build": "9.0.5000.0", "name": "SP4", "kb": "KB2463332", "released": "2010-12-17", "security": false }, + { "release": "SqlServer2000", "build": "8.0.2039.0", "name": "SP4", "kb": "KB884525", "released": "2005-05-06", "security": false } + ] +} diff --git a/src/SQLInsight.Rules/SQLInsight.Rules.csproj b/src/SQLInsight.Rules/SQLInsight.Rules.csproj index bfae138..f5a67f1 100644 --- a/src/SQLInsight.Rules/SQLInsight.Rules.csproj +++ b/src/SQLInsight.Rules/SQLInsight.Rules.csproj @@ -10,5 +10,6 @@ + diff --git a/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs b/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs new file mode 100644 index 0000000..915d53d --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs @@ -0,0 +1,59 @@ +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; +using SQLInsight.Rules.Lifecycle; +using static SQLInsight.Rules.Tests.TestInstances; + +namespace SQLInsight.Rules.Tests; + +public class PatchLevelRuleTests +{ + private static readonly PatchLevelRule Rule = new(BuildCatalog.LoadEmbedded(), SupportLifecycleCatalog.LoadEmbedded()); + + [Fact] + public void Catalog_has_a_build_for_every_known_release() + { + var catalog = BuildCatalog.LoadEmbedded(); + + foreach (var release in Enum.GetValues().Where(r => r != SqlServerRelease.Unknown)) + { + var latest = catalog.Latest(release); + Assert.NotNull(latest); + Assert.Equal(release, latest.Build.Release); + } + } + + [Fact] + public void Current_build_passes() + { + Assert.Equal(RuleOutcome.Passed, Rule.Evaluate(Context("16.0.4295.3")).Outcome); + Assert.Equal(RuleOutcome.Passed, Rule.Evaluate(Context("16.0.4300.1")).Outcome); // newer than the catalog + } + + [Fact] + public void Old_cu_on_supported_release_is_high() + { + var finding = Assert.Single(Rule.Evaluate(Context("15.0.4430.1")).Findings); // 2019 CU32 without 2026 security updates + + Assert.Equal(Severity.High, finding.Severity); + Assert.Contains("KB5122772", finding.Recommendation, StringComparison.Ordinal); + Assert.Contains("sikkerhedsopdatering", finding.Explanation, StringComparison.Ordinal); + } + + [Fact] + public void Missing_service_pack_on_unsupported_release_is_medium() + { + var finding = Assert.Single(Rule.Evaluate(Context("10.50.4000.0")).Findings); // 2008 R2 SP2 + + Assert.Equal(Severity.Medium, finding.Severity); + Assert.Contains("SP3", finding.Title, StringComparison.Ordinal); + } + + [Fact] + public void Old_catalog_is_called_out() + { + var finding = Assert.Single(Rule.Evaluate(Context("15.0.4430.1", new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero))).Findings); + + Assert.Contains(finding.Evidence, e => e.Value.Contains("forældet", StringComparison.Ordinal)); + } +}