sqlinsight-pro/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs
Søren Eggert Lundsteen Olsen ab8cfcad97 Configuration page with change log
- new collector instance-config: collation, edition, version, services and
  accounts (sys.dm_server_services, 2008 R2 SP1+), Instant File
  Initialization and Lock Pages in Memory (where the build has the
  columns), tempdb files, default paths; plus a fingerprint of the whole
  setup (sp_configure, trace flags, services, version, tempdb files)
- changes are computed against the previous collection, which the agent
  restores from the repository after a restart; migration 0008 keeps them
  in insight.ConfigurationChanges (as long as resolved findings)
- SQLI-CFG-013 "Konfiguration ændret": information for a day when
  sp_configure, a trace flag or a service account changes (account names
  are sensitive evidence)
- page /instanser/{navn}/konfiguration: instance, CPU and memory, services,
  tempdb, trace flags, non-default sp_configure (default vs. in use vs.
  configured) and the change log; linked from the instance page
- the management report lists configuration changes in the period
- trace flag reading shared by trace-flags and instance-config

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 08:12:26 +02:00

444 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Configuration;
/// <summary>
/// A server option with a value that is a known problem. Declarative, so each such piece of expertise is a few lines.
/// </summary>
public sealed class ConfigurationOptionRule(
string id,
string option,
Func<long, bool> isProblem,
Severity severity,
string title,
string explanation,
string recommendation,
long? recommendedValue,
Reference reference,
Func<RuleContext, bool>? appliesTo = null,
IReadOnlyCollection<Capability>? requires = null) : Rule
{
public override string Id => id;
public override string Title => title;
public override IReadOnlyCollection<Capability> RequiredCapabilities => requires ?? [];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (appliesTo is not null && !appliesTo(context))
{
return RuleEvaluation.Passed();
}
var value = ConfigurationValues.Get(context, option);
if (!isProblem(value.ValueInUse))
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
severity,
title,
explanation,
recommendation,
[new Evidence(option, value.ValueInUse.ToString(CultureInfo.InvariantCulture))],
fixScript: recommendedValue is { } v ? ConfigurationValues.FixScript(option, v) : null,
references: reference));
}
}
internal static class ConfigurationValues
{
public static ConfigurationOption Get(RuleContext context, string option) =>
context.Data.Get<ConfigurationSnapshot>().Find(option)
?? throw new DataUnavailableException($"Indstillingen '{option}' findes ikke på {context.Instance.Release.DisplayName()}.");
public static string FixScript(string option, long value) => $"""
EXEC sys.sp_configure N'show advanced options', 1;
RECONFIGURE;
EXEC sys.sp_configure N'{option}', {value.ToString(CultureInfo.InvariantCulture)};
RECONFIGURE;
""";
}
/// <summary>
/// The configuration rules that need no more than one option value.
/// </summary>
public static class SimpleConfigurationRules
{
public static IEnumerable<IRule> Create() =>
[
new ConfigurationOptionRule(
"SQLI-CFG-006", "priority boost", v => v != 0, Severity.High,
"Priority boost er slået til",
"Priority boost hæver SQL Servers trådprioritet over resten af Windows. Det kan udsulte operativsystemet, "
+ "netværk og cluster-tjenester, så serveren bliver ustabil eller en failover-cluster tror, noden er død. "
+ "Microsoft har markeret indstillingen som forældet.",
"Slå priority boost fra. Ændringen kræver genstart af SQL Server-tjenesten.",
0, Docs.Learn("priority boost", "database-engine/configure-windows/configure-the-priority-boost-server-configuration-option"),
requires: [Capability.SelfManagedInfrastructure]),
new ConfigurationOptionRule(
"SQLI-CFG-007", "lightweight pooling", v => v != 0, Severity.High,
"Lightweight pooling (fiber mode) er slået til",
"Fiber mode får SQL Server til at planlægge fibre i stedet for tråde. Det understøttes ikke af blandt andet CLR, "
+ "distribuerede transaktioner og mange komponenter, og det giver sjældent en målbar gevinst på moderne hardware.",
"Slå lightweight pooling fra, medmindre Microsoft Support har anbefalet det til netop denne workload.",
0, Docs.Learn("lightweight pooling", "database-engine/configure-windows/lightweight-pooling-server-configuration-option"),
requires: [Capability.SelfManagedInfrastructure]),
new ConfigurationOptionRule(
"SQLI-CFG-008", "xp_cmdshell", v => v != 0, Severity.Medium,
"xp_cmdshell er slået til",
"xp_cmdshell lader T-SQL køre vilkårlige kommandoer i Windows med SQL Server-tjenestekontoens rettigheder. "
+ "Et SQL injection-hul eller en kompromitteret sysadmin-konto bliver dermed til adgang til hele serveren.",
"Slå xp_cmdshell fra. Opgaver der kræver kommandolinjen, bør køre som SQL Agent-jobtrin med en proxy-konto.",
0, Docs.Learn("xp_cmdshell", "database-engine/configure-windows/xp-cmdshell-server-configuration-option"),
requires: [Capability.SelfManagedInfrastructure]),
new ConfigurationOptionRule(
"SQLI-CFG-009", "backup compression default", v => v == 0, Severity.Low,
"Backup-komprimering er ikke standard",
"Komprimerede backups er typisk 3-5 gange mindre og hurtigere, fordi flaskehalsen oftest er I/O og ikke CPU. "
+ "Uden standardindstillingen skal hvert backup-job selv huske WITH COMPRESSION.",
"Slå backup compression default til. Undlad kun, hvis databaserne allerede er krypteret med TDE før 2016 "
+ "eller data i forvejen er komprimeret.",
1, Docs.Learn("backup compression default", "database-engine/configure-windows/view-or-configure-the-backup-compression-default-server-configuration-option"),
c => c.Instance.EngineEdition != EngineEdition.Express,
[Capability.SelfManagedInfrastructure]),
new ConfigurationOptionRule(
"SQLI-CFG-010", "remote admin connections", v => v == 0, Severity.Low,
"Dedicated Admin Connection kan kun bruges lokalt",
"Når SQL Server hænger (fx ved worker thread exhaustion), er Dedicated Admin Connection (DAC) ofte eneste vej ind. "
+ "Som standard virker den kun fra selve serveren, som man måske ikke kan logge på i den situation.",
"Slå remote admin connections til, så DAC kan bruges med ADMIN: fra en anden maskine.",
1, Docs.Learn("remote admin connections", "database-engine/configure-windows/remote-admin-connections-server-configuration-option"),
requires: [Capability.SelfManagedInfrastructure]),
];
}
/// <summary>
/// max server memory left at the default: SQL Server may take all memory and starve Windows.
/// </summary>
public sealed class MaxServerMemoryDefaultRule : Rule
{
public const string Option = "max server memory (MB)";
public override string Id => "SQLI-CFG-001";
public override string Title => "max server memory er ikke sat";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
if (value < int.MaxValue)
{
return RuleEvaluation.Passed();
}
var physical = context.Data.TryGet<ServerSnapshot>(out var server) ? server.PhysicalMemoryMb : null;
var recommended = physical is { } p ? MemoryGuidance.Recommended(p) : (long?)null;
var evidence = new List<Evidence> { new(Option, value.ToString(CultureInfo.InvariantCulture)) };
if (physical is { } mb)
{
evidence.Add(new Evidence("Fysisk hukommelse (MB)", Format.Number(mb)));
}
return RuleEvaluation.Found(CreateFinding(
Severity.High,
Title,
"Med standardværdien må SQL Server bruge al hukommelse på serveren. Buffer pool vokser, til Windows begynder at "
+ "presse den tilbage; resultatet er paging, lange pauser og i værste fald at SQL Servers hukommelse bliver trimmet.",
recommended is { } r
? $"Sæt max server memory til ca. {Format.Number(r)} MB (fysisk hukommelse minus det største af 4 GB og 10 %), "
+ "og træk mere fra, hvis andre tjenester (SSIS, SSAS, antivirus) kører på serveren."
: "Sæt max server memory til fysisk hukommelse minus det største af 4 GB og 10 %, og træk mere fra for andre tjenester.",
evidence,
fixScript: recommended is { } fix ? ConfigurationValues.FixScript(Option, fix) : null,
references: Docs.Learn("Server memory configuration", "database-engine/configure-windows/server-memory-server-configuration-options")));
}
}
/// <summary>
/// max server memory set so high that the operating system has too little left.
/// </summary>
public sealed class MaxServerMemoryTooHighRule : Rule
{
public override string Id => "SQLI-CFG-002";
public override string Title => "max server memory efterlader for lidt til Windows";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, MaxServerMemoryDefaultRule.Option).ValueInUse;
var physical = context.Data.Get<ServerSnapshot>().PhysicalMemoryMb
?? throw new DataUnavailableException("Fysisk hukommelse kan ikke aflæses på denne version.");
// The default is reported by SQLI-CFG-001.
if (value >= int.MaxValue || physical - value >= MemoryGuidance.Reserve(physical) / 2)
{
return RuleEvaluation.Passed();
}
var recommended = MemoryGuidance.Recommended(physical);
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
Title,
$"max server memory er sat til {Format.Number(value)} MB på en server med {Format.Number(physical)} MB. "
+ "Der er ikke plads nok til Windows, tråd-stacks, CLR og andre allokeringer uden for buffer pool, så serveren risikerer paging.",
$"Sænk max server memory til ca. {Format.Number(recommended)} MB.",
[new Evidence(MaxServerMemoryDefaultRule.Option, Format.Number(value)), new Evidence("Fysisk hukommelse (MB)", Format.Number(physical))],
fixScript: ConfigurationValues.FixScript(MaxServerMemoryDefaultRule.Option, recommended),
references: Docs.Learn("Server memory configuration", "database-engine/configure-windows/server-memory-server-configuration-options")));
}
}
internal static class MemoryGuidance
{
/// <summary>Memory to leave for the OS: the larger of 4 GB and 10 %.</summary>
public static long Reserve(long physicalMb) => Math.Max(4096, physicalMb / 10);
public static long Recommended(long physicalMb) => Math.Max(physicalMb / 2, physicalMb - Reserve(physicalMb));
}
/// <summary>
/// Cost threshold for parallelism at the 1990s default of 5.
/// </summary>
public sealed class CostThresholdRule : Rule
{
public const string Option = "cost threshold for parallelism";
public override string Id => "SQLI-CFG-003";
public override string Title => "cost threshold for parallelism står på standardværdien 5";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
var maxdop = ConfigurationValues.Get(context, "max degree of parallelism").ValueInUse;
var cpus = context.Data.Get<ServerSnapshot>().LogicalCpuCount;
if (value != 5 || maxdop == 1 || cpus <= 1)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
Title,
"Standardværdien 5 blev fastlagt i 1990'erne. I dag betyder den, at selv små forespørgsler kører parallelt, "
+ "hvilket koster CPU og worker threads og ofte giver CXPACKET-ventetider uden at gøre noget hurtigere.",
"Sæt cost threshold for parallelism til 50 som udgangspunkt, og justér ud fra estimerede omkostninger i plan cache "
+ "eller Query Store. Planer, der hidtil kørte parallelt, kan ændre sig, så følg med i Query Store bagefter.",
[new Evidence(Option, "5"), new Evidence("max degree of parallelism", maxdop.ToString(CultureInfo.InvariantCulture)),
new Evidence("Logiske CPU'er", cpus.ToString(CultureInfo.InvariantCulture))],
fixScript: ConfigurationValues.FixScript(Option, 50),
references: Docs.Learn("cost threshold for parallelism", "database-engine/configure-windows/configure-the-cost-threshold-for-parallelism-server-configuration-option")));
}
}
/// <summary>
/// MAXDOP not following Microsoft's guidance for the NUMA layout.
/// </summary>
public sealed class MaxDopRule : Rule
{
public const string Option = "max degree of parallelism";
public override string Id => "SQLI-CFG-004";
public override string Title => "MAXDOP passer ikke til serverens NUMA-layout";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
var server = context.Data.Get<ServerSnapshot>();
var schedulers = server.VisibleOnlineSchedulers ?? server.LogicalCpuCount;
var nodes = Math.Max(1, server.NumaNodeCount ?? 1);
var perNode = Math.Max(1, schedulers / nodes);
var recommended = Recommended(nodes, perNode);
var tooWide = value == 0 ? recommended < schedulers : value > recommended;
if (!tooWide || schedulers <= 1)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
Title,
value == 0
? $"MAXDOP 0 lader én forespørgsel bruge alle {schedulers} schedulers. Store parallelle planer på tværs af NUMA-noder "
+ "giver fjern-hukommelsesadgang, koordineringsoverhead og kan tømme serveren for worker threads."
: $"MAXDOP {value} er højere end Microsofts vejledning for {nodes} NUMA-node(r) med {perNode} schedulers hver.",
$"Sæt MAXDOP til {recommended} efter Microsofts vejledning. På SQL Server 2016 og nyere kan det også sættes pr. database "
+ "med ALTER DATABASE SCOPED CONFIGURATION.",
[new Evidence(Option, value.ToString(CultureInfo.InvariantCulture)),
new Evidence("Schedulers", schedulers.ToString(CultureInfo.InvariantCulture)),
new Evidence("NUMA-noder", nodes.ToString(CultureInfo.InvariantCulture))],
fixScript: ConfigurationValues.FixScript(Option, recommended),
references: Docs.Learn("max degree of parallelism", "database-engine/configure-windows/configure-the-max-degree-of-parallelism-server-configuration-option")));
}
/// <summary>
/// Microsoft's guidance (SQL Server 2016+): one NUMA node → up to 8; several nodes → the schedulers per node
/// up to 16, and with more than 16 per node half of them, at most 16.
/// </summary>
internal static int Recommended(int nodes, int perNode) => nodes <= 1
? Math.Min(8, perNode)
: perNode <= 16 ? perNode : Math.Min(16, perNode / 2);
}
/// <summary>
/// Plan cache full of single-use ad hoc plans without optimize for ad hoc workloads.
/// </summary>
public sealed class OptimizeForAdHocRule : Rule
{
public const string Option = "optimize for ad hoc workloads";
public override string Id => "SQLI-CFG-005";
public override string Title => "Plan cache er fyldt med engangsplaner";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var value = ConfigurationValues.Get(context, Option).ValueInUse;
var cache = context.Data.Get<PlanCacheSnapshot>();
var share = cache.TotalMb > 0 ? cache.SingleUseAdhocMb / cache.TotalMb : 0;
if (value != 0 || share < 0.25 || cache.SingleUseAdhocMb < 100)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
Title,
$"{Format.Number(cache.SingleUseAdhocMb)} MB ({Format.Number(share * 100)} %) af plan cache er ad hoc-planer, der kun er brugt én gang. "
+ "Den hukommelse mangler i buffer pool, og applikationen sender sandsynligvis uparametriseret SQL.",
"Slå optimize for ad hoc workloads til, så en plan først gemmes helt ved anden brug. Den egentlige løsning er at "
+ "parametrisere forespørgslerne i applikationen.",
[new Evidence("Engangsplaner", Format.Number(cache.SingleUseAdhocPlans)),
new Evidence("Engangsplaner (MB)", Format.Number(cache.SingleUseAdhocMb)),
new Evidence("Plan cache i alt (MB)", Format.Number(cache.TotalMb))],
fixScript: ConfigurationValues.FixScript(Option, 1),
references: Docs.Learn("optimize for ad hoc workloads", "database-engine/configure-windows/optimize-for-ad-hoc-workloads-server-configuration-option")));
}
}
/// <summary>
/// Options changed with sp_configure but not applied.
/// </summary>
public sealed class PendingConfigurationRule : Rule
{
// Configured 0 while SQL Server reports the effective minimum; not a pending change.
private static readonly HashSet<string> Ignored = new(StringComparer.OrdinalIgnoreCase) { "min server memory (MB)" };
public override string Id => "SQLI-CFG-011";
public override string Title => "Konfigurationsændringer venter på RECONFIGURE eller genstart";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var pending = context.Data.Get<ConfigurationSnapshot>().Options
.Where(o => o.Value != o.ValueInUse && !Ignored.Contains(o.Name))
.ToList();
if (pending.Count == 0)
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
Title,
"Disse indstillinger er ændret med sp_configure, men den kørende værdi er en anden. Ændringen træder i kraft ved "
+ "næste RECONFIGURE eller genstart, måske uventet, fx når nogen ændrer en helt anden indstilling.",
"Afgør for hver indstilling, om ændringen er ønsket. Kør RECONFIGURE (eller genstart for statiske indstillinger), "
+ "eller sæt værdien tilbage.",
pending.Select(o => new Evidence(o.Name, $"konfigureret {o.Value}, i brug {o.ValueInUse}")),
references: Docs.Learn("Server configuration options", "database-engine/configure-windows/server-configuration-options-sql-server")));
}
}
/// <summary>
/// No way to see deadlocks on versions without system_health.
/// </summary>
public sealed class DeadlockTraceFlagRule : Rule
{
public override string Id => "SQLI-CFG-012";
public override string Title => "Deadlocks bliver ikke registreret";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Capabilities.Has(Capability.SystemHealthSession))
{
return RuleEvaluation.Passed();
}
var flags = context.Data.Get<TraceFlagSnapshot>();
if (flags.IsEnabled(1222) || flags.IsEnabled(1204))
{
return RuleEvaluation.Passed();
}
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
Title,
$"{context.Instance.Release.DisplayName()} har ikke system_health-sessionen, og hverken trace flag 1222 eller 1204 er slået til. "
+ "Deadlocks kan derfor ikke analyseres bagefter; applikationen ser kun fejl 1205.",
"Slå trace flag 1222 til globalt (DBCC TRACEON(1222, -1)), og tilføj -T1222 som startparameter, så det overlever genstart. "
+ "Deadlock-grafen skrives så til error log.",
[new Evidence("Globale trace flags", flags.GlobalTraceFlags.Count == 0 ? "ingen" : string.Join(", ", flags.GlobalTraceFlags))],
fixScript: "DBCC TRACEON(1222, -1);",
references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide")));
}
}
/// <summary>
/// A setting someone changed: sp_configure, a trace flag or a service account. Information, kept open for a day, so the
/// change is visible to whoever looks (and in the change log on the configuration page for good).
/// </summary>
public sealed class ConfigurationChangedRule : Rule
{
public override string Id => "SQLI-CFG-013";
public override string Title => "Konfiguration ændret";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<InstanceConfigurationSnapshot>();
return Result(snapshot.RecentChanges
.Where(c => ConfigurationFingerprint.IsNotable(c.Area))
.Select(c =>
{
var account = c.Area == ConfigurationFingerprint.ServiceAccount;
var what = c.Area == ConfigurationFingerprint.TraceFlag ? $"Trace flag {c.Name}" : c.Name;
var title = (c.OldValue, c.NewValue) switch
{
(null, _) when c.Area == ConfigurationFingerprint.TraceFlag => $"{what} er slået til",
(_, null) when c.Area == ConfigurationFingerprint.TraceFlag => $"{what} er slået fra",
_ when account => $"Tjenestekontoen for {what} er ændret",
_ => $"{what} er ændret fra {c.OldValue ?? "–"} til {c.NewValue ?? "–"}",
};
return CreateFinding(
Severity.Info,
title,
"En indstilling på instansen er ændret. Det er ikke nødvendigvis et problem, men ændringer af serverindstillinger, "
+ "trace flags og tjenestekonti bør være planlagte og kendte, fordi de påvirker hele instansen.",
"Bekræft, at ændringen var planlagt, og at den er dokumenteret. Hele historikken står under Konfiguration på instansens side.",
[
new Evidence("Område", c.Area), new Evidence("Indstilling", c.Name),
new Evidence("Fra", c.OldValue ?? "–", Sensitive: account), new Evidence("Til", c.NewValue ?? "–", Sensitive: account),
new Evidence("Opdaget", c.DetectedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)),
],
subject: $"config: {c.Area}/{c.Name}/{c.DetectedAt.UtcDateTime:yyyyMMddHHmm}");
}));
}
}