From edb7410f7655707a5dd0d52c0399ac3c48b14334 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:23:40 +0000 Subject: [PATCH] Fase 2: Azure SQL Managed Instance, docs and rule catalog test - Managed Instance reports 12.0.2000.8 but is evergreen: treated as the newest engine with all capabilities; new SelfManagedInfrastructure capability so backup, CHECKDB, files, tempdb, memory, patch level and lifecycle rules report 'handled by Azure' instead of false findings - Microsoft Entra ID login (managed identity, service principal, default credential chain) via SqlConnection.AccessTokenCallback - Least-privilege script recognizes Managed Instance; deadlock collector falls back to the ring buffer when event files cannot be read - Web UI shows the right release for Managed Instance - README and installation docs: notifications and Managed Instance - Catalog test: 61 rules with unique, well-formed ids Integration suite green on SQL Server 2017 and 2022. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- Directory.Packages.props | 1 + README.md | 16 +++---- db/permissions/grant-least-privilege.sql | 3 ++ docs/installation.md | 24 ++++++++++ src/SQLInsight.Agent/AgentOptions.cs | 4 ++ .../Concurrency/DeadlockCollector.cs | 5 +++ .../ConnectionStrings.cs | 8 +++- .../ConnectionTarget.cs | 16 ++++++- .../EntraCredentials.cs | 26 +++++++++++ .../SQLInsight.Connectivity.csproj | 1 + .../SqlConnectionFactory.cs | 20 +++++++-- src/SQLInsight.Core/Instances/Capability.cs | 6 +++ .../Instances/InstanceCapabilities.cs | 9 ++++ src/SQLInsight.Core/Instances/InstanceInfo.cs | 8 +++- .../Instances/SqlServerRelease.cs | 4 ++ .../InsightRepository.Read.cs | 3 +- .../Configuration/ConfigurationRules.cs | 21 ++++++--- .../Databases/DatabaseRules.cs | 9 +++- src/SQLInsight.Rules/Engine/RuleEngine.cs | 4 +- .../Lifecycle/PatchLevelRule.cs | 1 + .../Lifecycle/SupportLifecycleRule.cs | 2 +- .../Performance/ResourceRules.cs | 5 ++- .../Components/Pages/Home.razor | 2 +- .../Components/Pages/Instance.razor | 2 +- src/SQLInsight.Web/Display.cs | 5 ++- .../ConnectionStringsTests.cs | 33 ++++++++++++++ .../InstanceCapabilitiesTests.cs | 18 +++++++- .../ManagedInstanceTests.cs | 45 +++++++++++++++++++ .../PatchLevelRuleTests.cs | 2 +- .../RuleCatalogTests.cs | 23 ++++++++++ .../SupportLifecycleRuleTests.cs | 7 +-- 31 files changed, 297 insertions(+), 36 deletions(-) create mode 100644 src/SQLInsight.Connectivity/EntraCredentials.cs create mode 100644 tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs create mode 100644 tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 827ed48..7dd8fd2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/README.md b/README.md index b37040b..cf18ff6 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 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). +> **Status:** Fase 1 og 2 er implementeret: agent, 61 regler (herunder Query Store, plananalyse, indexes, Always On og anomalier), notifikationer, lokalt repository og web-UI. Se [installation](docs/installation.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). --- @@ -183,13 +183,13 @@ Hvert **finding** indeholder: - [ ] MSI-installer (kræver Windows-byggemiljø, WiX) ### Fase 2: Dyb analyse -- [ ] Integration med Query Store: regressioner, analyse af planændringer og parameter sniffing -- [ ] Plan cache- og showplan-analyse (implicitte konverteringer, spills, lookups) -- [ ] Index- og statistikanalyse -- [ ] Always On AG, backup/CHECKDB og SQL Agent -- [ ] Baselines og anomali-detektion (hvad er "normalt" for netop denne server?) -- [ ] Notifikationer via e-mail, Teams og webhook -- [ ] Linux-agent (systemd) og understøttelse af Azure SQL Managed Instance +- [x] Query Store: planregressioner (med script til at forcere den gode plan), forcerede planer der fejler, og parameterfølsomme forespørgsler +- [x] Plananalyse af de tungeste statements: implicitte konverteringer, joins uden betingelse, skalare UDF'er, spills og overdimensionerede memory grants +- [x] Indexes og statistik: manglende indexes (vurderet mod eksisterende), ubrugte, duplikerede, heaps med forwarded records, forældede statistikker og slået-fra auto-statistik +- [x] Always On availability groups (sundhed, datatab, redo-tid) og SQL Agent-jobs (fejl med trinbesked, Agent stoppet) +- [x] Baselines pr. time på ugen og anomalier (CPU, trafik der stiger eller falder bort, page life expectancy) +- [x] Notifikationer via e-mail, Microsoft Teams (Workflows) og signeret webhook +- [x] Linux-agent (systemd) og Azure SQL Managed Instance (evergreen, Entra ID-login, Azure-håndterede områder vises som sådan). MI er ikke testet mod en rigtig instans ### Fase 3: Kommerciel platform - [ ] Licensmodel: signerede licensnøgler, der kan verificeres offline diff --git a/db/permissions/grant-least-privilege.sql b/db/permissions/grant-least-privilege.sql index 2a9c39d..c7be386 100644 --- a/db/permissions/grant-least-privilege.sql +++ b/db/permissions/grant-least-privilege.sql @@ -9,6 +9,7 @@ Forudsætning: login'et findes allerede, fx CREATE LOGIN [DOMÆNE\svc-sqlinsight$] FROM WINDOWS; -- gMSA (anbefalet) + CREATE LOGIN [sqlinsight-agent] FROM EXTERNAL PROVIDER; -- Azure SQL Managed Instance (Entra ID) Hvad der gives, og hvorfor: Server @@ -31,6 +32,8 @@ DECLARE @Major int; DECLARE @Sql nvarchar(max); DECLARE @Quoted nvarchar(300); SET @Major = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int); +/* Azure SQL Managed Instance reports 12.x but runs the newest engine. Use the broad, universally supported permissions. */ +IF CAST(SERVERPROPERTY('EngineEdition') AS int) = 8 SET @Major = 15; SET @Quoted = QUOTENAME(@LoginName); IF @Major < 9 diff --git a/docs/installation.md b/docs/installation.md index 02b4f13..6709e8c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -99,6 +99,30 @@ sc.exe start "SQLInsight Web" Åbn porten i Windows Firewall for de brugere, der skal se UI'et. +## Notifikationer + +Agenten kan give besked om nye og løste fund, og når en instans ikke kan nås. Det sættes op under `SQLInsight:Notifications` i agentens `appsettings.json`: + +| Kanal | Opsætning | +|---|---| +| E-mail | `Email:Enabled`, `Host`, `Port`, `Security` (`StartTls`, `SslOnConnect` eller `None`), `From`, `To`, eventuelt `UserName`/`Password` | +| Microsoft Teams | Opret i Teams et workflow "Post to a channel when a webhook request is received", og sæt URL'en i `Teams:WebhookUrl` | +| Webhook | `Webhook:Url` og eventuelt `Secret`. Så har hver request headeren `X-SQLInsight-Signature: sha256=` | + +Hver kanal har sin egen `MinSeverity` (standard: `High` til e-mail og Teams, `Medium` til webhook). En instans, der ikke kan nås, meldes altid. Sæt `WebUrl` til adressen på SQLInsight Web, så beskederne linker direkte til fundet. `MaxMessagesPerHour` begrænser antallet af beskeder pr. kanal. + +## Azure SQL Managed Instance + +Tilføj instansen som en almindelig instans. Log ind med Microsoft Entra ID: + +```json +{ "Name": "mi-prod", "Server": "mi-prod.abc123.database.windows.net", "Authentication": "EntraManagedIdentity" } +``` + +Mulighederne er `EntraManagedIdentity` (agentens managed identity; `UserName` kan være client id for en user-assigned identity), `EntraServicePrincipal` (`UserName` = client id, `Password` = secret, `TenantId`) og `EntraDefault`. Opret login'et med `CREATE LOGIN [navn] FROM EXTERNAL PROVIDER`, og kør derefter `grant-least-privilege.sql`. + +På Managed Instance håndterer Azure backup, patching, filer, tempdb og hukommelse. De regler vises derfor som "Håndteres af Azure" og ikke som fund. MI-understøttelsen er bygget ud fra Microsofts dokumentation, men er endnu ikke testet mod en rigtig Managed Instance. + ## Linux (systemd) Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux kræver en Kerberos keytab; se Microsofts dokumentation for Negotiate-autentificering på Linux. diff --git a/src/SQLInsight.Agent/AgentOptions.cs b/src/SQLInsight.Agent/AgentOptions.cs index 91fa309..2746b87 100644 --- a/src/SQLInsight.Agent/AgentOptions.cs +++ b/src/SQLInsight.Agent/AgentOptions.cs @@ -46,6 +46,9 @@ public sealed class MonitoredInstance public bool TrustServerCertificate { get; set; } public string OdbcDriver { get; set; } = "ODBC Driver 18 for SQL Server"; + /// Entra tenant id, for EntraServicePrincipal. + public string? TenantId { get; set; } + public ConnectionTarget ToTarget() => new() { Server = Server, @@ -56,5 +59,6 @@ public sealed class MonitoredInstance Encryption = Encryption, TrustServerCertificate = TrustServerCertificate, OdbcDriver = OdbcDriver, + TenantId = TenantId, }; } diff --git a/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs b/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs index dbbf2aa..79dbab5 100644 --- a/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs +++ b/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs @@ -38,6 +38,11 @@ public sealed class DeadlockCollector : Collector // The file we stopped in has rolled over and been deleted. Read everything once more. return await ReadFilesAsync(context, since, null, null).ConfigureAwait(false); } + catch (DbException) + { + // No readable event files (e.g. Azure SQL Managed Instance keeps them elsewhere): use the ring buffer. + return await ReadRingBufferAsync(context, since).ConfigureAwait(false); + } } internal static string BuildFileSql(string? lastFile, long? lastOffset) diff --git a/src/SQLInsight.Connectivity/ConnectionStrings.cs b/src/SQLInsight.Connectivity/ConnectionStrings.cs index 8fb8ffc..35fef53 100644 --- a/src/SQLInsight.Connectivity/ConnectionStrings.cs +++ b/src/SQLInsight.Connectivity/ConnectionStrings.cs @@ -25,17 +25,23 @@ internal static class ConnectionStrings { builder.IntegratedSecurity = true; } - else + else if (target.Authentication == AuthenticationMode.SqlLogin) { builder.UserID = RequireUserName(target); builder.Password = target.Password ?? string.Empty; } + // Entra: no credentials in the string; the token comes from the access token callback. return builder.ConnectionString; } public static string ForOdbc(ConnectionTarget target) { + if (target.UsesEntra) + { + throw new NotSupportedException("Microsoft Entra ID understøttes kun med SqlClient, ikke med legacy ODBC-forbindelsen."); + } + var builder = new OdbcConnectionStringBuilder { Driver = target.OdbcDriver, diff --git a/src/SQLInsight.Connectivity/ConnectionTarget.cs b/src/SQLInsight.Connectivity/ConnectionTarget.cs index 416952c..0064302 100644 --- a/src/SQLInsight.Connectivity/ConnectionTarget.cs +++ b/src/SQLInsight.Connectivity/ConnectionTarget.cs @@ -31,13 +31,27 @@ public sealed record ConnectionTarget public string OdbcDriver { get; init; } = "ODBC Driver 18 for SQL Server"; public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15); + + /// Entra tenant id, for . + public string? TenantId { get; init; } + + public bool UsesEntra => Authentication is AuthenticationMode.EntraManagedIdentity or AuthenticationMode.EntraServicePrincipal or AuthenticationMode.EntraDefault; } public enum AuthenticationMode { - /// Integrated security. Preferred; run the agent as a gMSA. + /// Integrated security. Preferred on-prem; run the agent as a gMSA. Windows, SqlLogin, + + /// Microsoft Entra ID with the agent's managed identity (Azure VM). may hold a user-assigned identity's client id. + EntraManagedIdentity, + + /// Microsoft Entra ID service principal: = client id, = secret. + EntraServicePrincipal, + + /// Microsoft Entra ID via the default credential chain (environment, managed identity, Azure CLI …). + EntraDefault, } public enum ConnectionProvider diff --git a/src/SQLInsight.Connectivity/EntraCredentials.cs b/src/SQLInsight.Connectivity/EntraCredentials.cs new file mode 100644 index 0000000..101d9c8 --- /dev/null +++ b/src/SQLInsight.Connectivity/EntraCredentials.cs @@ -0,0 +1,26 @@ +using Azure.Core; +using Azure.Identity; + +namespace SQLInsight.Connectivity; + +/// +/// Microsoft Entra ID credentials for Azure SQL Managed Instance, used through SqlConnection.AccessTokenCallback as +/// documented for Microsoft.Data.SqlClient.Extensions.Azure. +/// +internal static class EntraCredentials +{ + public const string Scope = "https://database.windows.net/.default"; + + public static TokenCredential For(ConnectionTarget target) => target.Authentication switch + { + AuthenticationMode.EntraManagedIdentity => string.IsNullOrEmpty(target.UserName) + ? new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned) + : new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(target.UserName)), + AuthenticationMode.EntraServicePrincipal => new ClientSecretCredential( + target.TenantId ?? throw new ArgumentException("TenantId kræves til service principal-login.", nameof(target)), + target.UserName ?? throw new ArgumentException("UserName (client id) kræves til service principal-login.", nameof(target)), + target.Password ?? throw new ArgumentException("Password (client secret) kræves til service principal-login.", nameof(target))), + AuthenticationMode.EntraDefault => new DefaultAzureCredential(), + _ => throw new ArgumentOutOfRangeException(nameof(target), target.Authentication, "Ikke en Entra-login."), + }; +} diff --git a/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj b/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj index 92f6ef8..f946439 100644 --- a/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj +++ b/src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj @@ -7,6 +7,7 @@ + diff --git a/src/SQLInsight.Connectivity/SqlConnectionFactory.cs b/src/SQLInsight.Connectivity/SqlConnectionFactory.cs index daa1f5f..da27bf6 100644 --- a/src/SQLInsight.Connectivity/SqlConnectionFactory.cs +++ b/src/SQLInsight.Connectivity/SqlConnectionFactory.cs @@ -18,12 +18,26 @@ public sealed class SqlConnectionFactory : ISqlConnectionFactory private readonly Func _createOdbc; public SqlConnectionFactory() - : this( - t => new SqlConnection(ConnectionStrings.ForSqlClient(t)), - t => new OdbcConnection(ConnectionStrings.ForOdbc(t))) + : this(CreateSqlClient, t => new OdbcConnection(ConnectionStrings.ForOdbc(t))) { } + private static SqlConnection CreateSqlClient(ConnectionTarget target) + { + var connection = new SqlConnection(ConnectionStrings.ForSqlClient(target)); + if (target.UsesEntra) + { + var credential = EntraCredentials.For(target); + connection.AccessTokenCallback = async (_, cancellationToken) => + { + var token = await credential.GetTokenAsync(new Azure.Core.TokenRequestContext([EntraCredentials.Scope]), cancellationToken).ConfigureAwait(false); + return new SqlAuthenticationToken(token.Token, token.ExpiresOn); + }; + } + + return connection; + } + internal SqlConnectionFactory( Func createSqlClient, Func createOdbc) diff --git a/src/SQLInsight.Core/Instances/Capability.cs b/src/SQLInsight.Core/Instances/Capability.cs index 8d114c2..dacbce7 100644 --- a/src/SQLInsight.Core/Instances/Capability.cs +++ b/src/SQLInsight.Core/Instances/Capability.cs @@ -5,6 +5,12 @@ namespace SQLInsight.Core.Instances; /// public enum Capability { + /// + /// The customer runs the server: backups, patching, files, tempdb and host configuration are theirs to manage. + /// Absent on Azure SQL Managed Instance, where Azure does it. + /// + SelfManagedInfrastructure, + /// Dynamic management views (sys.dm_*). SQL Server 2005+. DynamicManagementViews, diff --git a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs index d249726..b520283 100644 --- a/src/SQLInsight.Core/Instances/InstanceCapabilities.cs +++ b/src/SQLInsight.Core/Instances/InstanceCapabilities.cs @@ -36,6 +36,15 @@ public sealed class InstanceCapabilities ArgumentNullException.ThrowIfNull(instance); var set = new HashSet(); + if (instance.IsManagedInstance) + { + // Evergreen engine with every feature; Azure manages the infrastructure and availability. + set.UnionWith(MinimumVersion.Keys); + set.Add(Capability.QueryStatsMemoryColumns); + return new InstanceCapabilities(set); + } + + set.Add(Capability.SelfManagedInfrastructure); foreach (var (capability, minimum) in MinimumVersion) { if (instance.Version >= minimum) diff --git a/src/SQLInsight.Core/Instances/InstanceInfo.cs b/src/SQLInsight.Core/Instances/InstanceInfo.cs index 466f44f..db57a47 100644 --- a/src/SQLInsight.Core/Instances/InstanceInfo.cs +++ b/src/SQLInsight.Core/Instances/InstanceInfo.cs @@ -22,5 +22,11 @@ public sealed record InstanceInfo /// Whether Always On availability groups are enabled (2012 and later). public bool? IsHadrEnabled { get; init; } - public SqlServerRelease Release => Version.Release; + /// + /// Azure SQL Managed Instance reports ProductVersion 12.0.2000.8 but runs the newest engine, and Azure manages + /// backups, patching, files and the host. + /// + public bool IsManagedInstance => EngineEdition == EngineEdition.AzureSqlManagedInstance; + + public SqlServerRelease Release => IsManagedInstance ? SqlServerRelease.AzureSqlManagedInstance : Version.Release; } diff --git a/src/SQLInsight.Core/Instances/SqlServerRelease.cs b/src/SQLInsight.Core/Instances/SqlServerRelease.cs index dcdf40f..c39526a 100644 --- a/src/SQLInsight.Core/Instances/SqlServerRelease.cs +++ b/src/SQLInsight.Core/Instances/SqlServerRelease.cs @@ -17,6 +17,9 @@ public enum SqlServerRelease SqlServer2019 = 2019, SqlServer2022 = 2022, SqlServer2025 = 2025, + + /// Evergreen: always the newest engine, whatever ProductVersion says. + AzureSqlManagedInstance = 9000, } public static class SqlServerReleaseExtensions @@ -34,6 +37,7 @@ public static class SqlServerReleaseExtensions SqlServerRelease.SqlServer2019 => "SQL Server 2019", SqlServerRelease.SqlServer2022 => "SQL Server 2022", SqlServerRelease.SqlServer2025 => "SQL Server 2025", + SqlServerRelease.AzureSqlManagedInstance => "Azure SQL Managed Instance", _ => "Ukendt SQL Server-version", }; } diff --git a/src/SQLInsight.Repository/InsightRepository.Read.cs b/src/SQLInsight.Repository/InsightRepository.Read.cs index 09fe712..44abb5e 100644 --- a/src/SQLInsight.Repository/InsightRepository.Read.cs +++ b/src/SQLInsight.Repository/InsightRepository.Read.cs @@ -15,6 +15,7 @@ public sealed record InstanceOverview public string? ProductLevel { get; init; } public string? ProductUpdateLevel { get; init; } public string? Edition { get; init; } + public int? EngineEdition { get; init; } public string? ConnectionProvider { get; init; } public bool UsedLegacyFallback { get; init; } public DateTime? LastSeenAt { get; init; } @@ -78,7 +79,7 @@ public sealed partial class InsightRepository { await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); var rows = await connection.QueryAsync(""" - SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.ConnectionProvider, + SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.EngineEdition, i.ConnectionProvider, i.UsedLegacyFallback, i.LastSeenAt, i.LastErrorAt, i.LastError, ISNULL(f.Critical, 0) AS Critical, ISNULL(f.High, 0) AS High, ISNULL(f.Medium, 0) AS Medium, ISNULL(f.Low, 0) AS Low, ISNULL(f.Info, 0) AS Info, ISNULL(r.NotMeasurable, 0) AS NotMeasurable diff --git a/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs b/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs index d050a61..dc7b5e6 100644 --- a/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs +++ b/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs @@ -20,10 +20,12 @@ public sealed class ConfigurationOptionRule( string recommendation, long? recommendedValue, Reference reference, - Func? appliesTo = null) : Rule + Func? appliesTo = null, + IReadOnlyCollection? requires = null) : Rule { public override string Id => id; public override string Title => title; + public override IReadOnlyCollection RequiredCapabilities => requires ?? []; public override RuleEvaluation Evaluate(RuleContext context) { @@ -78,7 +80,8 @@ public static class SimpleConfigurationRules + "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")), + 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, @@ -86,7 +89,8 @@ public static class SimpleConfigurationRules "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")), + 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, @@ -94,7 +98,8 @@ public static class SimpleConfigurationRules "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")), + 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, @@ -104,7 +109,8 @@ public static class SimpleConfigurationRules "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), + c => c.Instance.EngineEdition != EngineEdition.Express, + [Capability.SelfManagedInfrastructure]), new ConfigurationOptionRule( "SQLI-CFG-010", "remote admin connections", v => v == 0, Severity.Low, @@ -112,7 +118,8 @@ public static class SimpleConfigurationRules "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")), + 1, Docs.Learn("remote admin connections", "database-engine/configure-windows/remote-admin-connections-server-configuration-option"), + requires: [Capability.SelfManagedInfrastructure]), ]; } @@ -125,6 +132,7 @@ public sealed class MaxServerMemoryDefaultRule : Rule public override string Id => "SQLI-CFG-001"; public override string Title => "max server memory er ikke sat"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -165,6 +173,7 @@ 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 RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { diff --git a/src/SQLInsight.Rules/Databases/DatabaseRules.cs b/src/SQLInsight.Rules/Databases/DatabaseRules.cs index edcfcae..1cc2a18 100644 --- a/src/SQLInsight.Rules/Databases/DatabaseRules.cs +++ b/src/SQLInsight.Rules/Databases/DatabaseRules.cs @@ -160,6 +160,7 @@ public sealed class FullBackupRule : Rule public override string Id => "SQLI-DB-007"; public override string Title => "Database mangler en nylig fuld backup"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -212,6 +213,7 @@ public sealed class LogBackupRule : Rule public override string Id => "SQLI-DB-008"; public override string Title => "Database i FULL recovery uden log-backups"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -246,7 +248,7 @@ public sealed class CheckDbRule : Rule public override string Id => "SQLI-DB-009"; public override string Title => "DBCC CHECKDB er ikke kørt for nylig"; - public override IReadOnlyCollection RequiredCapabilities => [Capability.LastGoodCheckDbTime]; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LastGoodCheckDbTime]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -319,7 +321,7 @@ public sealed class VirtualLogFileRule : Rule public override string Id => "SQLI-DB-011"; public override string Title => "Transaktionsloggen har for mange VLF'er"; - public override IReadOnlyCollection RequiredCapabilities => [Capability.LogInfoDmv]; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LogInfoDmv]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -346,6 +348,7 @@ public sealed class FileGrowthRule : Rule { public override string Id => "SQLI-DB-012"; public override string Title => "Uhensigtsmæssig autogrowth"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -399,6 +402,7 @@ public sealed class FileMaxSizeRule : Rule public override string Id => "SQLI-DB-013"; public override string Title => "Databasefil nærmer sig sin maksimale størrelse"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { @@ -459,6 +463,7 @@ public sealed class TempdbFilesRule : Rule { public override string Id => "SQLI-DB-015"; public override string Title => "tempdb har for få eller ulige store datafiler"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { diff --git a/src/SQLInsight.Rules/Engine/RuleEngine.cs b/src/SQLInsight.Rules/Engine/RuleEngine.cs index 65e6dbc..42de919 100644 --- a/src/SQLInsight.Rules/Engine/RuleEngine.cs +++ b/src/SQLInsight.Rules/Engine/RuleEngine.cs @@ -40,7 +40,9 @@ public sealed class RuleEngine RuleTitle = rule.Title, Outcome = RuleOutcome.NotMeasurable, MissingCapabilities = missing, - Reason = $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.", + Reason = missing.Contains(Capability.SelfManagedInfrastructure) + ? $"Håndteres af Azure på {context.Instance.Release.DisplayName()}." + : $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.", }; } diff --git a/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs b/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs index 6ed15e6..5708731 100644 --- a/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs +++ b/src/SQLInsight.Rules/Lifecycle/PatchLevelRule.cs @@ -15,6 +15,7 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog public override string Id => "SQLI-VER-002"; public override string Title => "Instansen mangler opdateringer"; + public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { diff --git a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs index 9de9908..185bc25 100644 --- a/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs +++ b/src/SQLInsight.Rules/Lifecycle/SupportLifecycleRule.cs @@ -21,7 +21,7 @@ public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRul public string Id => RuleId; public string Title => "SQL Server-versionens supportstatus"; - public IReadOnlyCollection RequiredCapabilities => []; + public IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public RuleEvaluation Evaluate(RuleContext context) { diff --git a/src/SQLInsight.Rules/Performance/ResourceRules.cs b/src/SQLInsight.Rules/Performance/ResourceRules.cs index f3407ef..598d5cb 100644 --- a/src/SQLInsight.Rules/Performance/ResourceRules.cs +++ b/src/SQLInsight.Rules/Performance/ResourceRules.cs @@ -99,13 +99,14 @@ public sealed class CpuRule : Rule var cpu = context.Data.Get(); var sql = samples.Average(s => s.SqlServerPercent); - var other = cpu.OtherProcessesMeasurable ? samples.Average(s => s.OtherProcessesPercent) : 0; + var otherMeasurable = cpu.OtherProcessesMeasurable && !context.Instance.IsManagedInstance; + var other = otherMeasurable ? samples.Average(s => s.OtherProcessesPercent) : 0; var findings = new List(); var evidence = new[] { new Evidence("Periode", $"{samples.Count} min"), new Evidence("SQL Server gns./maks.", $"{Format.Number(sql)} % / {samples.Max(s => s.SqlServerPercent)} %"), - new Evidence("Andre processer gns.", cpu.OtherProcessesMeasurable ? $"{Format.Number(other)} %" : "kan ikke måles på Linux"), + new Evidence("Andre processer gns.", otherMeasurable ? $"{Format.Number(other)} %" : "kan ikke måles her"), }; if (sql >= 75) diff --git a/src/SQLInsight.Web/Components/Pages/Home.razor b/src/SQLInsight.Web/Components/Pages/Home.razor index a2d464d..3a1d18a 100644 --- a/src/SQLInsight.Web/Components/Pages/Home.razor +++ b/src/SQLInsight.Web/Components/Pages/Home.razor @@ -44,7 +44,7 @@ else } @i.Name
@i.ServerName
- @(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion))
@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel
+ @(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion, i.EngineEdition))
@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel
@if (i.Critical > 0) { } @if (i.High > 0) { } diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index 644e84b..c150f26 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -15,7 +15,7 @@

Instanser / @_instance.Name

@_instance.Name

- @_instance.ServerName · @Display.Release(_instance.ProductVersion) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition + @_instance.ServerName · @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition · sidst set @Display.Ago(_instance.LastSeenAt)

diff --git a/src/SQLInsight.Web/Display.cs b/src/SQLInsight.Web/Display.cs index d2395e8..bc394be 100644 --- a/src/SQLInsight.Web/Display.cs +++ b/src/SQLInsight.Web/Display.cs @@ -55,6 +55,7 @@ public sealed class Display(IOptions options) _ => "sev-ok", }; - public static string Release(string? productVersion) => - SqlServerVersion.TryParse(productVersion, out var version) ? version.Release.DisplayName() : "Ukendt version"; + public static string Release(string? productVersion, int? engineEdition = null) => + engineEdition == (int)EngineEdition.AzureSqlManagedInstance ? SqlServerRelease.AzureSqlManagedInstance.DisplayName() + : SqlServerVersion.TryParse(productVersion, out var version) ? version.Release.DisplayName() : "Ukendt version"; } diff --git a/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs b/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs index bfc434b..dbcf235 100644 --- a/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs +++ b/tests/SQLInsight.Connectivity.Tests/ConnectionStringsTests.cs @@ -40,3 +40,36 @@ public class ConnectionStringsTests Assert.Throws(() => ConnectionStrings.ForSqlClient(target)); } } + +public class EntraTests +{ + [Fact] + public void Entra_connection_string_carries_no_credentials() + { + var cs = ConnectionStrings.ForSqlClient(new ConnectionTarget { Server = "mi.abc.database.windows.net", Authentication = AuthenticationMode.EntraManagedIdentity }); + + Assert.DoesNotContain("Integrated Security=True", cs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("User ID", cs, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Password", cs, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Entra_is_not_offered_over_odbc() + { + Assert.Throws(() => ConnectionStrings.ForOdbc(new ConnectionTarget { Server = "x", Authentication = AuthenticationMode.EntraDefault })); + } + + [Theory] + [InlineData(AuthenticationMode.EntraManagedIdentity, "Azure.Identity.ManagedIdentityCredential")] + [InlineData(AuthenticationMode.EntraDefault, "Azure.Identity.DefaultAzureCredential")] + public void Credential_type_follows_the_mode(AuthenticationMode mode, string type) + { + Assert.Equal(type, EntraCredentials.For(new ConnectionTarget { Server = "x", Authentication = mode }).GetType().FullName); + } + + [Fact] + public void Service_principal_needs_tenant_client_and_secret() + { + Assert.Throws(() => EntraCredentials.For(new ConnectionTarget { Server = "x", Authentication = AuthenticationMode.EntraServicePrincipal, UserName = "client" })); + } +} diff --git a/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs b/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs index 900289a..1f4fb12 100644 --- a/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs +++ b/tests/SQLInsight.Core.Tests/InstanceCapabilitiesTests.cs @@ -18,7 +18,23 @@ public class InstanceCapabilitiesTests { var capabilities = InstanceCapabilities.From(Instance("8.00.2039")); - Assert.Empty(capabilities.All); + Assert.Equal([Capability.SelfManagedInfrastructure], capabilities.All); + } + + [Fact] + public void Managed_instance_is_evergreen_and_not_self_managed() + { + // Azure SQL Managed Instance always reports 12.0.2000.8. + var mi = Instance("12.0.2000.8") with { EngineEdition = EngineEdition.AzureSqlManagedInstance, IsHadrEnabled = true }; + + var capabilities = InstanceCapabilities.From(mi); + + Assert.Equal(SqlServerRelease.AzureSqlManagedInstance, mi.Release); + Assert.True(capabilities.Has(Capability.QueryStore)); + Assert.True(capabilities.Has(Capability.GranularServerPermissions)); + Assert.True(capabilities.Has(Capability.QueryStatsMemoryColumns)); + Assert.False(capabilities.Has(Capability.SelfManagedInfrastructure)); + Assert.False(capabilities.Has(Capability.HadrEnabled)); // Azure runs the availability of MI } [Fact] diff --git a/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs b/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs new file mode 100644 index 0000000..7ac4ec2 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs @@ -0,0 +1,45 @@ +using SQLInsight.Core.Instances; +using SQLInsight.Rules.Engine; +using static SQLInsight.Rules.Tests.TestInstances; + +namespace SQLInsight.Rules.Tests; + +public class ManagedInstanceTests +{ + private static RuleContext ManagedInstance() + { + var info = new InstanceInfo + { + ServerName = "mi-prod.abc123.database.windows.net", + Version = SqlServerVersion.Parse("12.0.2000.8"), + Edition = "SQL Azure", + EngineEdition = EngineEdition.AzureSqlManagedInstance, + }; + var context = new RuleContext(info, InstanceCapabilities.From(info), Now); + context.Data.Set(Config(("max server memory (MB)", 2147483647), ("cost threshold for parallelism", 5), ("max degree of parallelism", 0), ("xp_cmdshell", 0))); + context.Data.Set(Server(cpus: 8)); + context.Data.Set(DatabaseList(Database(change: d => d with { LastFullBackup = null, LastGoodCheckDb = new DateTime(1900, 1, 1) }))); + return context; + } + + [Fact] + public void Azure_managed_areas_are_reported_as_handled_by_azure_not_as_findings() + { + var results = new RuleEngine(BuiltInRules.Create()).Evaluate(ManagedInstance()); + + foreach (var id in new[] { "SQLI-VER-001", "SQLI-VER-002", "SQLI-CFG-001", "SQLI-DB-007", "SQLI-DB-009", "SQLI-DB-015" }) + { + var result = results.Single(r => r.RuleId == id); + Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Equal("Håndteres af Azure på Azure SQL Managed Instance.", result.Reason); + } + } + + [Fact] + public void Workload_rules_still_apply() + { + var results = new RuleEngine(BuiltInRules.Create()).Evaluate(ManagedInstance()); + + Assert.Equal(RuleOutcome.Findings, results.Single(r => r.RuleId == "SQLI-CFG-003").Outcome); // cost threshold 5 + } +} diff --git a/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs b/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs index 915d53d..5b5a6fb 100644 --- a/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/PatchLevelRuleTests.cs @@ -15,7 +15,7 @@ public class PatchLevelRuleTests { var catalog = BuildCatalog.LoadEmbedded(); - foreach (var release in Enum.GetValues().Where(r => r != SqlServerRelease.Unknown)) + foreach (var release in Enum.GetValues().Where(r => r is not (SqlServerRelease.Unknown or SqlServerRelease.AzureSqlManagedInstance))) { var latest = catalog.Latest(release); Assert.NotNull(latest); diff --git a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs new file mode 100644 index 0000000..f68ef64 --- /dev/null +++ b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs @@ -0,0 +1,23 @@ +using SQLInsight.Rules.Engine; + +namespace SQLInsight.Rules.Tests; + +public class RuleCatalogTests +{ + [Fact] + public void Every_rule_has_a_unique_well_formed_id() + { + var rules = BuiltInRules.Create(); + + Assert.Equal(rules.Count, rules.Select(r => r.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(rules, r => Assert.Matches("^SQLI-[A-Z]+-[0-9]{3}$", r.Id)); + Assert.All(rules, r => Assert.False(string.IsNullOrWhiteSpace(r.Title))); + } + + /// Keeps the rule count in the README honest. + [Fact] + public void Rule_count() + { + Assert.Equal(61, BuiltInRules.Create().Count); + } +} diff --git a/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs b/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs index 3ed9541..fa7e2d3 100644 --- a/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/SupportLifecycleRuleTests.cs @@ -85,9 +85,10 @@ public class SupportLifecycleRuleTests } [Fact] - public void Runs_on_sql2000_because_it_needs_no_capabilities() + public void Runs_on_sql2000_but_not_on_managed_instance() { - Assert.Empty(Rule.RequiredCapabilities); + Assert.Equal([Capability.SelfManagedInfrastructure], Rule.RequiredCapabilities); + Assert.Equal(RuleOutcome.Findings, new RuleEngine([Rule]).Evaluate(TestInstances.Context("8.00.2039")).Single().Outcome); } [Fact] @@ -95,7 +96,7 @@ public class SupportLifecycleRuleTests { var catalog = SupportLifecycleCatalog.LoadEmbedded(); - foreach (var release in Enum.GetValues().Where(r => r != SqlServerRelease.Unknown)) + foreach (var release in Enum.GetValues().Where(r => r is not (SqlServerRelease.Unknown or SqlServerRelease.AzureSqlManagedInstance))) { var lifecycle = catalog.Find(release); Assert.NotNull(lifecycle);