Fase 2: Azure SQL Managed Instance, docs and rule catalog test
Some checks are pending
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
CI / Build og unit tests (push) Waiting to run

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 08:23:40 +00:00
parent eac799a1f1
commit edb7410f76
No known key found for this signature in database
31 changed files with 297 additions and 36 deletions

View file

@ -5,6 +5,7 @@
<ItemGroup>
<!-- Runtime -->
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.0" />
<PackageVersion Include="Microsoft.Data.SqlClient.Extensions.Azure" Version="7.1.0" />
<PackageVersion Include="System.Data.Odbc" Version="10.0.12" />
<PackageVersion Include="Dapper" Version="2.1.89" />
<PackageVersion Include="MailKit" Version="4.18.1" />

View file

@ -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

View file

@ -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

View file

@ -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=<HMAC-SHA256 af body>` |
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.

View file

@ -46,6 +46,9 @@ public sealed class MonitoredInstance
public bool TrustServerCertificate { get; set; }
public string OdbcDriver { get; set; } = "ODBC Driver 18 for SQL Server";
/// <summary>Entra tenant id, for EntraServicePrincipal.</summary>
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,
};
}

View file

@ -38,6 +38,11 @@ public sealed class DeadlockCollector : Collector<DeadlockSnapshot>
// 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)

View file

@ -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,

View file

@ -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);
/// <summary>Entra tenant id, for <see cref="AuthenticationMode.EntraServicePrincipal"/>.</summary>
public string? TenantId { get; init; }
public bool UsesEntra => Authentication is AuthenticationMode.EntraManagedIdentity or AuthenticationMode.EntraServicePrincipal or AuthenticationMode.EntraDefault;
}
public enum AuthenticationMode
{
/// <summary>Integrated security. Preferred; run the agent as a gMSA.</summary>
/// <summary>Integrated security. Preferred on-prem; run the agent as a gMSA.</summary>
Windows,
SqlLogin,
/// <summary>Microsoft Entra ID with the agent's managed identity (Azure VM). <see cref="ConnectionTarget.UserName"/> may hold a user-assigned identity's client id.</summary>
EntraManagedIdentity,
/// <summary>Microsoft Entra ID service principal: <see cref="ConnectionTarget.UserName"/> = client id, <see cref="ConnectionTarget.Password"/> = secret.</summary>
EntraServicePrincipal,
/// <summary>Microsoft Entra ID via the default credential chain (environment, managed identity, Azure CLI …).</summary>
EntraDefault,
}
public enum ConnectionProvider

View file

@ -0,0 +1,26 @@
using Azure.Core;
using Azure.Identity;
namespace SQLInsight.Connectivity;
/// <summary>
/// Microsoft Entra ID credentials for Azure SQL Managed Instance, used through SqlConnection.AccessTokenCallback as
/// documented for Microsoft.Data.SqlClient.Extensions.Azure.
/// </summary>
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."),
};
}

View file

@ -7,6 +7,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Data.SqlClient.Extensions.Azure" />
<PackageReference Include="System.Data.Odbc" />
</ItemGroup>
<ItemGroup>

View file

@ -18,12 +18,26 @@ public sealed class SqlConnectionFactory : ISqlConnectionFactory
private readonly Func<ConnectionTarget, DbConnection> _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<ConnectionTarget, DbConnection> createSqlClient,
Func<ConnectionTarget, DbConnection> createOdbc)

View file

@ -5,6 +5,12 @@ namespace SQLInsight.Core.Instances;
/// </summary>
public enum Capability
{
/// <summary>
/// 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.
/// </summary>
SelfManagedInfrastructure,
/// <summary>Dynamic management views (<c>sys.dm_*</c>). SQL Server 2005+.</summary>
DynamicManagementViews,

View file

@ -36,6 +36,15 @@ public sealed class InstanceCapabilities
ArgumentNullException.ThrowIfNull(instance);
var set = new HashSet<Capability>();
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)

View file

@ -22,5 +22,11 @@ public sealed record InstanceInfo
/// <summary>Whether Always On availability groups are enabled (2012 and later).</summary>
public bool? IsHadrEnabled { get; init; }
public SqlServerRelease Release => Version.Release;
/// <summary>
/// Azure SQL Managed Instance reports ProductVersion 12.0.2000.8 but runs the newest engine, and Azure manages
/// backups, patching, files and the host.
/// </summary>
public bool IsManagedInstance => EngineEdition == EngineEdition.AzureSqlManagedInstance;
public SqlServerRelease Release => IsManagedInstance ? SqlServerRelease.AzureSqlManagedInstance : Version.Release;
}

View file

@ -17,6 +17,9 @@ public enum SqlServerRelease
SqlServer2019 = 2019,
SqlServer2022 = 2022,
SqlServer2025 = 2025,
/// <summary>Evergreen: always the newest engine, whatever ProductVersion says.</summary>
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",
};
}

View file

@ -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<InstanceOverview>("""
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

View file

@ -20,10 +20,12 @@ public sealed class ConfigurationOptionRule(
string recommendation,
long? recommendedValue,
Reference reference,
Func<RuleContext, bool>? appliesTo = null) : Rule
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)
{
@ -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<Capability> 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<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
public override RuleEvaluation Evaluate(RuleContext context)
{

View file

@ -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<Capability> 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<Capability> 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<Capability> RequiredCapabilities => [Capability.LastGoodCheckDbTime];
public override IReadOnlyCollection<Capability> 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<Capability> RequiredCapabilities => [Capability.LogInfoDmv];
public override IReadOnlyCollection<Capability> 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<Capability> 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<Capability> 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<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
public override RuleEvaluation Evaluate(RuleContext context)
{

View file

@ -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)}.",
};
}

View file

@ -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<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
public override RuleEvaluation Evaluate(RuleContext context)
{

View file

@ -21,7 +21,7 @@ public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRul
public string Id => RuleId;
public string Title => "SQL Server-versionens supportstatus";
public IReadOnlyCollection<Capability> RequiredCapabilities => [];
public IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
public RuleEvaluation Evaluate(RuleContext context)
{

View file

@ -99,13 +99,14 @@ public sealed class CpuRule : Rule
var cpu = context.Data.Get<CpuUtilization>();
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<Finding>();
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)

View file

@ -44,7 +44,7 @@ else
}
</td>
<td><a href="/instanser/@Uri.EscapeDataString(i.Name)"><strong>@i.Name</strong></a><div class="muted">@i.ServerName</div></td>
<td>@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion))<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel</div></td>
<td>@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion, i.EngineEdition))<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel</div></td>
<td class="counts">
@if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> }
@if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> }

View file

@ -15,7 +15,7 @@
<p class="crumbs"><a href="/">Instanser</a> / @_instance.Name</p>
<h1>@_instance.Name</h1>
<p class="lead">
@_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)
</p>

View file

@ -55,6 +55,7 @@ public sealed class Display(IOptions<WebOptions> 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";
}

View file

@ -40,3 +40,36 @@ public class ConnectionStringsTests
Assert.Throws<ArgumentException>(() => 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<NotSupportedException>(() => 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<ArgumentException>(() => EntraCredentials.For(new ConnectionTarget { Server = "x", Authentication = AuthenticationMode.EntraServicePrincipal, UserName = "client" }));
}
}

View file

@ -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]

View file

@ -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
}
}

View file

@ -15,7 +15,7 @@ public class PatchLevelRuleTests
{
var catalog = BuildCatalog.LoadEmbedded();
foreach (var release in Enum.GetValues<SqlServerRelease>().Where(r => r != SqlServerRelease.Unknown))
foreach (var release in Enum.GetValues<SqlServerRelease>().Where(r => r is not (SqlServerRelease.Unknown or SqlServerRelease.AzureSqlManagedInstance)))
{
var latest = catalog.Latest(release);
Assert.NotNull(latest);

View file

@ -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)));
}
/// <summary>Keeps the rule count in the README honest.</summary>
[Fact]
public void Rule_count()
{
Assert.Equal(61, BuiltInRules.Create().Count);
}
}

View file

@ -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<SqlServerRelease>().Where(r => r != SqlServerRelease.Unknown))
foreach (var release in Enum.GetValues<SqlServerRelease>().Where(r => r is not (SqlServerRelease.Unknown or SqlServerRelease.AzureSqlManagedInstance)))
{
var lifecycle = catalog.Find(release);
Assert.NotNull(lifecycle);