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
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:
parent
eac799a1f1
commit
edb7410f76
31 changed files with 297 additions and 36 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Runtime -->
|
<!-- Runtime -->
|
||||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.0" />
|
<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="System.Data.Odbc" Version="10.0.12" />
|
||||||
<PackageVersion Include="Dapper" Version="2.1.89" />
|
<PackageVersion Include="Dapper" Version="2.1.89" />
|
||||||
<PackageVersion Include="MailKit" Version="4.18.1" />
|
<PackageVersion Include="MailKit" Version="4.18.1" />
|
||||||
|
|
|
||||||
16
README.md
16
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.
|
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)
|
- [ ] MSI-installer (kræver Windows-byggemiljø, WiX)
|
||||||
|
|
||||||
### Fase 2: Dyb analyse
|
### Fase 2: Dyb analyse
|
||||||
- [ ] Integration med Query Store: regressioner, analyse af planændringer og parameter sniffing
|
- [x] Query Store: planregressioner (med script til at forcere den gode plan), forcerede planer der fejler, og parameterfølsomme forespørgsler
|
||||||
- [ ] Plan cache- og showplan-analyse (implicitte konverteringer, spills, lookups)
|
- [x] Plananalyse af de tungeste statements: implicitte konverteringer, joins uden betingelse, skalare UDF'er, spills og overdimensionerede memory grants
|
||||||
- [ ] Index- og statistikanalyse
|
- [x] Indexes og statistik: manglende indexes (vurderet mod eksisterende), ubrugte, duplikerede, heaps med forwarded records, forældede statistikker og slået-fra auto-statistik
|
||||||
- [ ] Always On AG, backup/CHECKDB og SQL Agent
|
- [x] Always On availability groups (sundhed, datatab, redo-tid) og SQL Agent-jobs (fejl med trinbesked, Agent stoppet)
|
||||||
- [ ] Baselines og anomali-detektion (hvad er "normalt" for netop denne server?)
|
- [x] Baselines pr. time på ugen og anomalier (CPU, trafik der stiger eller falder bort, page life expectancy)
|
||||||
- [ ] Notifikationer via e-mail, Teams og webhook
|
- [x] Notifikationer via e-mail, Microsoft Teams (Workflows) og signeret webhook
|
||||||
- [ ] Linux-agent (systemd) og understøttelse af Azure SQL Managed Instance
|
- [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
|
### Fase 3: Kommerciel platform
|
||||||
- [ ] Licensmodel: signerede licensnøgler, der kan verificeres offline
|
- [ ] Licensmodel: signerede licensnøgler, der kan verificeres offline
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
Forudsætning: login'et findes allerede, fx
|
Forudsætning: login'et findes allerede, fx
|
||||||
CREATE LOGIN [DOMÆNE\svc-sqlinsight$] FROM WINDOWS; -- gMSA (anbefalet)
|
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:
|
Hvad der gives, og hvorfor:
|
||||||
Server
|
Server
|
||||||
|
|
@ -31,6 +32,8 @@ DECLARE @Major int;
|
||||||
DECLARE @Sql nvarchar(max);
|
DECLARE @Sql nvarchar(max);
|
||||||
DECLARE @Quoted nvarchar(300);
|
DECLARE @Quoted nvarchar(300);
|
||||||
SET @Major = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int);
|
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);
|
SET @Quoted = QUOTENAME(@LoginName);
|
||||||
|
|
||||||
IF @Major < 9
|
IF @Major < 9
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,30 @@ sc.exe start "SQLInsight Web"
|
||||||
|
|
||||||
Åbn porten i Windows Firewall for de brugere, der skal se UI'et.
|
Å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)
|
## 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.
|
Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux kræver en Kerberos keytab; se Microsofts dokumentation for Negotiate-autentificering på Linux.
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ public sealed class MonitoredInstance
|
||||||
public bool TrustServerCertificate { get; set; }
|
public bool TrustServerCertificate { get; set; }
|
||||||
public string OdbcDriver { get; set; } = "ODBC Driver 18 for SQL Server";
|
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()
|
public ConnectionTarget ToTarget() => new()
|
||||||
{
|
{
|
||||||
Server = Server,
|
Server = Server,
|
||||||
|
|
@ -56,5 +59,6 @@ public sealed class MonitoredInstance
|
||||||
Encryption = Encryption,
|
Encryption = Encryption,
|
||||||
TrustServerCertificate = TrustServerCertificate,
|
TrustServerCertificate = TrustServerCertificate,
|
||||||
OdbcDriver = OdbcDriver,
|
OdbcDriver = OdbcDriver,
|
||||||
|
TenantId = TenantId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// The file we stopped in has rolled over and been deleted. Read everything once more.
|
||||||
return await ReadFilesAsync(context, since, null, null).ConfigureAwait(false);
|
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)
|
internal static string BuildFileSql(string? lastFile, long? lastOffset)
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,23 @@ internal static class ConnectionStrings
|
||||||
{
|
{
|
||||||
builder.IntegratedSecurity = true;
|
builder.IntegratedSecurity = true;
|
||||||
}
|
}
|
||||||
else
|
else if (target.Authentication == AuthenticationMode.SqlLogin)
|
||||||
{
|
{
|
||||||
builder.UserID = RequireUserName(target);
|
builder.UserID = RequireUserName(target);
|
||||||
builder.Password = target.Password ?? string.Empty;
|
builder.Password = target.Password ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entra: no credentials in the string; the token comes from the access token callback.
|
||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string ForOdbc(ConnectionTarget target)
|
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
|
var builder = new OdbcConnectionStringBuilder
|
||||||
{
|
{
|
||||||
Driver = target.OdbcDriver,
|
Driver = target.OdbcDriver,
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,27 @@ public sealed record ConnectionTarget
|
||||||
public string OdbcDriver { get; init; } = "ODBC Driver 18 for SQL Server";
|
public string OdbcDriver { get; init; } = "ODBC Driver 18 for SQL Server";
|
||||||
|
|
||||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
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
|
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,
|
Windows,
|
||||||
SqlLogin,
|
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
|
public enum ConnectionProvider
|
||||||
|
|
|
||||||
26
src/SQLInsight.Connectivity/EntraCredentials.cs
Normal file
26
src/SQLInsight.Connectivity/EntraCredentials.cs
Normal 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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient.Extensions.Azure" />
|
||||||
<PackageReference Include="System.Data.Odbc" />
|
<PackageReference Include="System.Data.Odbc" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,26 @@ public sealed class SqlConnectionFactory : ISqlConnectionFactory
|
||||||
private readonly Func<ConnectionTarget, DbConnection> _createOdbc;
|
private readonly Func<ConnectionTarget, DbConnection> _createOdbc;
|
||||||
|
|
||||||
public SqlConnectionFactory()
|
public SqlConnectionFactory()
|
||||||
: this(
|
: this(CreateSqlClient, t => new OdbcConnection(ConnectionStrings.ForOdbc(t)))
|
||||||
t => new SqlConnection(ConnectionStrings.ForSqlClient(t)),
|
|
||||||
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(
|
internal SqlConnectionFactory(
|
||||||
Func<ConnectionTarget, DbConnection> createSqlClient,
|
Func<ConnectionTarget, DbConnection> createSqlClient,
|
||||||
Func<ConnectionTarget, DbConnection> createOdbc)
|
Func<ConnectionTarget, DbConnection> createOdbc)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ namespace SQLInsight.Core.Instances;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum Capability
|
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>
|
/// <summary>Dynamic management views (<c>sys.dm_*</c>). SQL Server 2005+.</summary>
|
||||||
DynamicManagementViews,
|
DynamicManagementViews,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,15 @@ public sealed class InstanceCapabilities
|
||||||
ArgumentNullException.ThrowIfNull(instance);
|
ArgumentNullException.ThrowIfNull(instance);
|
||||||
|
|
||||||
var set = new HashSet<Capability>();
|
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)
|
foreach (var (capability, minimum) in MinimumVersion)
|
||||||
{
|
{
|
||||||
if (instance.Version >= minimum)
|
if (instance.Version >= minimum)
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,11 @@ public sealed record InstanceInfo
|
||||||
/// <summary>Whether Always On availability groups are enabled (2012 and later).</summary>
|
/// <summary>Whether Always On availability groups are enabled (2012 and later).</summary>
|
||||||
public bool? IsHadrEnabled { get; init; }
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ public enum SqlServerRelease
|
||||||
SqlServer2019 = 2019,
|
SqlServer2019 = 2019,
|
||||||
SqlServer2022 = 2022,
|
SqlServer2022 = 2022,
|
||||||
SqlServer2025 = 2025,
|
SqlServer2025 = 2025,
|
||||||
|
|
||||||
|
/// <summary>Evergreen: always the newest engine, whatever ProductVersion says.</summary>
|
||||||
|
AzureSqlManagedInstance = 9000,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SqlServerReleaseExtensions
|
public static class SqlServerReleaseExtensions
|
||||||
|
|
@ -34,6 +37,7 @@ public static class SqlServerReleaseExtensions
|
||||||
SqlServerRelease.SqlServer2019 => "SQL Server 2019",
|
SqlServerRelease.SqlServer2019 => "SQL Server 2019",
|
||||||
SqlServerRelease.SqlServer2022 => "SQL Server 2022",
|
SqlServerRelease.SqlServer2022 => "SQL Server 2022",
|
||||||
SqlServerRelease.SqlServer2025 => "SQL Server 2025",
|
SqlServerRelease.SqlServer2025 => "SQL Server 2025",
|
||||||
|
SqlServerRelease.AzureSqlManagedInstance => "Azure SQL Managed Instance",
|
||||||
_ => "Ukendt SQL Server-version",
|
_ => "Ukendt SQL Server-version",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ public sealed record InstanceOverview
|
||||||
public string? ProductLevel { get; init; }
|
public string? ProductLevel { get; init; }
|
||||||
public string? ProductUpdateLevel { get; init; }
|
public string? ProductUpdateLevel { get; init; }
|
||||||
public string? Edition { get; init; }
|
public string? Edition { get; init; }
|
||||||
|
public int? EngineEdition { get; init; }
|
||||||
public string? ConnectionProvider { get; init; }
|
public string? ConnectionProvider { get; init; }
|
||||||
public bool UsedLegacyFallback { get; init; }
|
public bool UsedLegacyFallback { get; init; }
|
||||||
public DateTime? LastSeenAt { 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);
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var rows = await connection.QueryAsync<InstanceOverview>("""
|
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,
|
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(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
|
ISNULL(r.NotMeasurable, 0) AS NotMeasurable
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ public sealed class ConfigurationOptionRule(
|
||||||
string recommendation,
|
string recommendation,
|
||||||
long? recommendedValue,
|
long? recommendedValue,
|
||||||
Reference reference,
|
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 Id => id;
|
||||||
public override string Title => title;
|
public override string Title => title;
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => requires ?? [];
|
||||||
|
|
||||||
public override RuleEvaluation Evaluate(RuleContext context)
|
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. "
|
+ "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.",
|
+ "Microsoft har markeret indstillingen som forældet.",
|
||||||
"Slå priority boost fra. Ændringen kræver genstart af SQL Server-tjenesten.",
|
"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(
|
new ConfigurationOptionRule(
|
||||||
"SQLI-CFG-007", "lightweight pooling", v => v != 0, Severity.High,
|
"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, "
|
"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.",
|
+ "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.",
|
"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(
|
new ConfigurationOptionRule(
|
||||||
"SQLI-CFG-008", "xp_cmdshell", v => v != 0, Severity.Medium,
|
"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. "
|
"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.",
|
+ "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.",
|
"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(
|
new ConfigurationOptionRule(
|
||||||
"SQLI-CFG-009", "backup compression default", v => v == 0, Severity.Low,
|
"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 "
|
"Slå backup compression default til. Undlad kun, hvis databaserne allerede er krypteret med TDE før 2016 "
|
||||||
+ "eller data i forvejen er komprimeret.",
|
+ "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"),
|
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(
|
new ConfigurationOptionRule(
|
||||||
"SQLI-CFG-010", "remote admin connections", v => v == 0, Severity.Low,
|
"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. "
|
"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.",
|
+ "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.",
|
"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 Id => "SQLI-CFG-001";
|
||||||
public override string Title => "max server memory er ikke sat";
|
public override string Title => "max server memory er ikke sat";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
||||||
|
|
||||||
public override RuleEvaluation Evaluate(RuleContext context)
|
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 Id => "SQLI-CFG-002";
|
||||||
public override string Title => "max server memory efterlader for lidt til Windows";
|
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)
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ public sealed class FullBackupRule : Rule
|
||||||
|
|
||||||
public override string Id => "SQLI-DB-007";
|
public override string Id => "SQLI-DB-007";
|
||||||
public override string Title => "Database mangler en nylig fuld backup";
|
public override string Title => "Database mangler en nylig fuld backup";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
||||||
|
|
||||||
public override RuleEvaluation Evaluate(RuleContext context)
|
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 Id => "SQLI-DB-008";
|
||||||
public override string Title => "Database i FULL recovery uden log-backups";
|
public override string Title => "Database i FULL recovery uden log-backups";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
||||||
|
|
||||||
public override RuleEvaluation Evaluate(RuleContext context)
|
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 Id => "SQLI-DB-009";
|
||||||
public override string Title => "DBCC CHECKDB er ikke kørt for nylig";
|
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)
|
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 Id => "SQLI-DB-011";
|
||||||
public override string Title => "Transaktionsloggen har for mange VLF'er";
|
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)
|
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 Id => "SQLI-DB-012";
|
||||||
public override string Title => "Uhensigtsmæssig autogrowth";
|
public override string Title => "Uhensigtsmæssig autogrowth";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
||||||
|
|
||||||
public override RuleEvaluation Evaluate(RuleContext context)
|
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 Id => "SQLI-DB-013";
|
||||||
public override string Title => "Databasefil nærmer sig sin maksimale størrelse";
|
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)
|
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 Id => "SQLI-DB-015";
|
||||||
public override string Title => "tempdb har for få eller ulige store datafiler";
|
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)
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ public sealed class RuleEngine
|
||||||
RuleTitle = rule.Title,
|
RuleTitle = rule.Title,
|
||||||
Outcome = RuleOutcome.NotMeasurable,
|
Outcome = RuleOutcome.NotMeasurable,
|
||||||
MissingCapabilities = missing,
|
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)}.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ public sealed class PatchLevelRule(BuildCatalog catalog, SupportLifecycleCatalog
|
||||||
|
|
||||||
public override string Id => "SQLI-VER-002";
|
public override string Id => "SQLI-VER-002";
|
||||||
public override string Title => "Instansen mangler opdateringer";
|
public override string Title => "Instansen mangler opdateringer";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
||||||
|
|
||||||
public override RuleEvaluation Evaluate(RuleContext context)
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRul
|
||||||
|
|
||||||
public string Id => RuleId;
|
public string Id => RuleId;
|
||||||
public string Title => "SQL Server-versionens supportstatus";
|
public string Title => "SQL Server-versionens supportstatus";
|
||||||
public IReadOnlyCollection<Capability> RequiredCapabilities => [];
|
public IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure];
|
||||||
|
|
||||||
public RuleEvaluation Evaluate(RuleContext context)
|
public RuleEvaluation Evaluate(RuleContext context)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -99,13 +99,14 @@ public sealed class CpuRule : Rule
|
||||||
|
|
||||||
var cpu = context.Data.Get<CpuUtilization>();
|
var cpu = context.Data.Get<CpuUtilization>();
|
||||||
var sql = samples.Average(s => s.SqlServerPercent);
|
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 findings = new List<Finding>();
|
||||||
var evidence = new[]
|
var evidence = new[]
|
||||||
{
|
{
|
||||||
new Evidence("Periode", $"{samples.Count} min"),
|
new Evidence("Periode", $"{samples.Count} min"),
|
||||||
new Evidence("SQL Server gns./maks.", $"{Format.Number(sql)} % / {samples.Max(s => s.SqlServerPercent)} %"),
|
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)
|
if (sql >= 75)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ else
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td><a href="/instanser/@Uri.EscapeDataString(i.Name)"><strong>@i.Name</strong></a><div class="muted">@i.ServerName</div></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">
|
<td class="counts">
|
||||||
@if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> }
|
@if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> }
|
||||||
@if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> }
|
@if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<p class="crumbs"><a href="/">Instanser</a> / @_instance.Name</p>
|
<p class="crumbs"><a href="/">Instanser</a> / @_instance.Name</p>
|
||||||
<h1>@_instance.Name</h1>
|
<h1>@_instance.Name</h1>
|
||||||
<p class="lead">
|
<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)
|
· sidst set @Display.Ago(_instance.LastSeenAt)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ public sealed class Display(IOptions<WebOptions> options)
|
||||||
_ => "sev-ok",
|
_ => "sev-ok",
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string Release(string? productVersion) =>
|
public static string Release(string? productVersion, int? engineEdition = null) =>
|
||||||
SqlServerVersion.TryParse(productVersion, out var version) ? version.Release.DisplayName() : "Ukendt version";
|
engineEdition == (int)EngineEdition.AzureSqlManagedInstance ? SqlServerRelease.AzureSqlManagedInstance.DisplayName()
|
||||||
|
: SqlServerVersion.TryParse(productVersion, out var version) ? version.Release.DisplayName() : "Ukendt version";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,36 @@ public class ConnectionStringsTests
|
||||||
Assert.Throws<ArgumentException>(() => ConnectionStrings.ForSqlClient(target));
|
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" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,23 @@ public class InstanceCapabilitiesTests
|
||||||
{
|
{
|
||||||
var capabilities = InstanceCapabilities.From(Instance("8.00.2039"));
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
45
tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs
Normal file
45
tests/SQLInsight.Rules.Tests/ManagedInstanceTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ public class PatchLevelRuleTests
|
||||||
{
|
{
|
||||||
var catalog = BuildCatalog.LoadEmbedded();
|
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);
|
var latest = catalog.Latest(release);
|
||||||
Assert.NotNull(latest);
|
Assert.NotNull(latest);
|
||||||
|
|
|
||||||
23
tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs
Normal file
23
tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,9 +85,10 @@ public class SupportLifecycleRuleTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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]
|
[Fact]
|
||||||
|
|
@ -95,7 +96,7 @@ public class SupportLifecycleRuleTests
|
||||||
{
|
{
|
||||||
var catalog = SupportLifecycleCatalog.LoadEmbedded();
|
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);
|
var lifecycle = catalog.Find(release);
|
||||||
Assert.NotNull(lifecycle);
|
Assert.NotNull(lifecycle);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue