diff --git a/Directory.Build.props b/Directory.Build.props
index ebc3a09..270e9ef 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
SQLInsight Pro
Copyright (c) SEOSoft
- 1.0.0
+ 1.0.1
diff --git a/docs/installation.md b/docs/installation.md
index fa27e83..4264f46 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -13,7 +13,7 @@ Begge bruger en **repository-database** på en SQL Server 2016+-instans, der hel
## Hurtigst: MSI-installeren
-`SQLInsight-Pro--x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Hent den nyeste under [Releases](https://git.seosoft.dk/seo/sqlinsight-pro/releases), eller med `installer\get-latest-msi.ps1`. Opret først repository-databasen (trin 2) og giv rettigheder (trin 3). Kør derefter installeren som administrator:
+`SQLInsight-Pro--x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Hent den nyeste under [Releases](https://git.seosoft.dk/seo/sqlinsight-pro/releases), eller med `installer\get-latest-msi.ps1`. Kør installeren som administrator, logget ind som en bruger, der er **sysadmin** på den SQL Server, repositoriet skal ligge på (eller har `dbcreator` og `securityadmin`). Installeren opretter selv repository-databasen, logins og rettigheder (trin 2 og 3), så de ikke skal laves i hånden:
- **Med installationsguiden:** Første side spørger om forbindelsesstrengen til repositoriet, den første SQL Server, tjenestekonti og port.
- **Stille, fx med SCCM/Intune:**
@@ -34,11 +34,19 @@ msiexec /i SQLInsight-Pro-1.0.0-x64.msi /qn /l*v install.log `
| `WEB_ACCOUNT` | `NT SERVICE\SQLInsight Web` | Tjenestekonto til web. Skal have `db_datareader` på repositoriet |
| `WEB_PORT` | `5080` | Port til webbrugerfladen. Installeren åbner den i Windows Firewall |
| `ADDLOCAL` | `Agent,Web` | `Agent`, `Web` og `UpdateTask` (planlagt opgave, der installerer hentede opdateringer som SYSTEM, se [opdatering.md](opdatering.md)). `ALL` = alle tre |
+| `SKIP_REPOSITORY_SETUP` | (ingen) | `1` = installeren opretter ikke database, logins og rettigheder. Brug det, når en DBA gør det (trin 2 og 3), eller når installationen kører som SYSTEM (SCCM/Intune) uden rettigheder på SQL Server |
Installeren gør følgende:
- Programmerne lægges i `C:\Program Files\SQLInsight\Agent` og `\Web`, og tjenesterne `SQLInsight Agent` og `SQLInsight Web` oprettes med automatisk start.
- Værdierne fra installationen skrives i `sqlinsight.ini` ved siden af programmerne. Den læses efter `appsettings.json`, så begge filer kan redigeres bagefter. Genstart tjenesten efter en ændring.
+- **Repositoriet sættes op med din Windows-konto** (ikke tjenesternes), før tjenesterne starter:
+ - databasen fra `Database=…` oprettes, hvis den mangler;
+ - tjenestekontiene får login og bruger: agenten `db_owner`, web `db_datareader`. Ligger SQL Server på en anden computer, kommer `NT SERVICE\…`-kontiene dertil som computerkontoen (`DOMÆNE\COMPUTER$`), og den får login'et i stedet;
+ - skemaet oprettes;
+ - på `MONITOR_SERVER` oprettes agentens login, og den får rettighederne fra `grant-least-privilege.sql`. Lykkes det ikke (fx ingen sysadmin dér), fortsætter installationen, og scriptet kan køres senere.
+
+ Fejler opsætningen af repositoriet, stopper installationen og rulles tilbage. Årsagen står i hændelsesloggen (*Application*, kilden `SQLInsight Pro`). Opsætningen kan også køres igen bagefter, fx efter en ændret forbindelsesstreng: `"C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" setup-repository --grant-monitored` (med `--agent-account`/`--web-account`, hvis der ikke bruges standardkonti).
- `C:\ProgramData\SQLInsight` oprettes med adgang for kun SYSTEM, Administrators og agentens konto. Mappen indeholder lokale regler, regelpakker og ventende opdateringer.
- Kilden `SQLInsight Pro` registreres i hændelsesloggen (se [integration.md](integration.md#scom-og-windows-hændelseslog)).
@@ -59,6 +67,8 @@ Web kører her på Kestrel over http med Windows-login. Skal den bruge https ell
## 2. Repository-databasen
+MSI-installeren gør dette selv (medmindre `SKIP_REPOSITORY_SETUP=1`). Ved manuel installation, eller når en DBA skal gøre det:
+
```sql
CREATE DATABASE SQLInsight;
GO
diff --git a/installer/SQLInsight.wxs b/installer/SQLInsight.wxs
index 772257a..f423021 100644
--- a/installer/SQLInsight.wxs
+++ b/installer/SQLInsight.wxs
@@ -10,6 +10,8 @@
which reaches other servers as the computer account (DOMAIN\SERVER$). A gMSA: DOMAIN\svc-sqlinsight$
WEB_ACCOUNT Account for SQLInsight Web. Default NT SERVICE\SQLInsight Web
WEB_PORT Port for the web UI (default 5080)
+ SKIP_REPOSITORY_SETUP 1 = do not create the repository database, logins and rights (the admin does it, see
+ docs/installation.md). Otherwise the installing user's Windows account does it.
Features: Agent, Web (both by default) and UpdateTask under Agent (ADDLOCAL=ALL: installs downloaded updates hourly as SYSTEM).
An upgrade keeps the features that were installed.
-->
@@ -34,6 +36,7 @@
+
@@ -143,6 +146,17 @@
+
+
+
+
+
1
1
1
+ 1
&UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE
&Web=2 AND NOT UPGRADINGPRODUCTCODE
@@ -175,6 +190,8 @@
&UpdateTask=3
&Web=3
&Web=3
+
+ &Agent=3 AND REPOSITORY_CONNECTION AND NOT SKIP_REPOSITORY_SETUP
diff --git a/installer/verify-msi.py b/installer/verify-msi.py
index 6b9f792..a4f40a6 100755
--- a/installer/verify-msi.py
+++ b/installer/verify-msi.py
@@ -10,6 +10,9 @@ import tempfile
DEFERRED = 0x400
IN_SCRIPT_NO_IMPERSONATE = 0x800
+CONTINUE_ON_ERROR = 0x40
+# Deferred actions that deliberately run as the installing user: SQL Server must see a person with rights there.
+AS_INSTALLING_USER = {"SetupRepository"}
def tables(msi):
@@ -46,13 +49,28 @@ def main(msi):
errors.append(f"{action}: er ikke i InstallExecuteSequence")
elif kind & DEFERRED and not start < sequence[action] < end:
errors.append(f"{action}: deferred, men ligger uden for InstallInitialize..InstallFinalize ({sequence[action]})")
- elif kind & DEFERRED and not kind & IN_SCRIPT_NO_IMPERSONATE:
+ elif kind & DEFERRED and not kind & IN_SCRIPT_NO_IMPERSONATE and action not in AS_INSTALLING_USER:
errors.append(f"{action}: deferred uden NoImpersonate (kører ikke som SYSTEM)")
+ elif action in AS_INSTALLING_USER and kind & IN_SCRIPT_NO_IMPERSONATE:
+ errors.append(f"{action}: skal køre som den installerende bruger, ikke SYSTEM")
def after(action, standard):
if sequence.get(action, 0) <= sequence[standard]:
errors.append(f"{action} skal ligge efter {standard}")
+ # Repository setup: needs the services' accounts and the written sqlinsight.ini, and must finish before the
+ # services start; a failure must stop the installation, and an admin must be able to skip it.
+ after("SetupRepository", "InstallServices")
+ after("SetupRepository", "WriteIniValues")
+ if sequence.get("SetupRepository", 0) >= sequence["StartServices"]:
+ errors.append("SetupRepository skal ligge før StartServices")
+ if actions.get("SetupRepository", 0) & CONTINUE_ON_ERROR:
+ errors.append("SetupRepository: fejl skal stoppe installationen (Return=check)")
+ condition = next((r["Condition"] for r in rows("InstallExecuteSequence") if r["Action"] == "SetupRepository"), "")
+ if "SKIP_REPOSITORY_SETUP" not in condition or "REPOSITORY_CONNECTION" not in condition:
+ errors.append("SetupRepository: betingelsen skal kræve REPOSITORY_CONNECTION og respektere SKIP_REPOSITORY_SETUP")
+ after("SetAgentExePath", "CostFinalize") # AGENTDIR is only resolved there
+
after("SetDataFolderAcl", "InstallServices") # the virtual service account must exist
after("CreateUpdateTask", "InstallFiles")
after("OpenFirewall", "ReplaceFirewallRule")
diff --git a/src/SQLInsight.Agent/CustomRules/RuleCommands.cs b/src/SQLInsight.Agent/CustomRules/RuleCommands.cs
index d5e924a..2648426 100644
--- a/src/SQLInsight.Agent/CustomRules/RuleCommands.cs
+++ b/src/SQLInsight.Agent/CustomRules/RuleCommands.cs
@@ -99,7 +99,7 @@ public static class RuleCommands
? CustomRuleLoader.LoadDirectory(path, origin: null)
: CustomRuleLoader.Parse(File.ReadAllText(path), Path.GetFileName(path), origin: null);
- private static AgentOptions LoadAgentOptions()
+ internal static AgentOptions LoadAgentOptions()
{
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
var configuration = new ConfigurationBuilder()
diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs
index d1296a5..937b0a9 100644
--- a/src/SQLInsight.Agent/Program.cs
+++ b/src/SQLInsight.Agent/Program.cs
@@ -8,12 +8,17 @@ using SQLInsight.Repository;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
-if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args))
+if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args) || SQLInsight.Agent.Setup.RepositorySetup.Handles(args))
{
// Danish letters in a Windows console, which otherwise uses the OEM code page.
Console.OutputEncoding = System.Text.Encoding.UTF8;
}
+if (SQLInsight.Agent.Setup.RepositorySetup.Handles(args))
+{
+ return await SQLInsight.Agent.Setup.RepositorySetup.RunAsync(args, Console.Out);
+}
+
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args))
{
return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out);
diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj
index 82ea502..4ffc2fb 100644
--- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj
+++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj
@@ -22,4 +22,8 @@
+
+
+
+
diff --git a/src/SQLInsight.Agent/Setup/RepositorySetup.cs b/src/SQLInsight.Agent/Setup/RepositorySetup.cs
new file mode 100644
index 0000000..78e2c1d
--- /dev/null
+++ b/src/SQLInsight.Agent/Setup/RepositorySetup.cs
@@ -0,0 +1,307 @@
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using Microsoft.Data.SqlClient;
+using SQLInsight.Connectivity;
+using SQLInsight.Repository;
+
+namespace SQLInsight.Agent.Setup;
+
+///
+/// setup-repository [--agent-account <konto>] [--web-account <konto>] [--grant-monitored]
+///
+/// Prepares everything the services need, with the rights of whoever runs it (the MSI runs it as the installing user,
+/// right after the services are created): the repository database if it is missing, logins and users for the agent
+/// (db_owner) and web (db_datareader) accounts, the schema, and with --grant-monitored the least-privilege rights
+/// on the configured instances (db/permissions/grant-least-privilege.sql). Safe to run again.
+///
+/// The connection string and instances are read from appsettings.json and sqlinsight.ini, like the service does.
+///
+public static partial class RepositorySetup
+{
+ public const string Command = "setup-repository";
+ private const string EventSource = "SQLInsight Pro";
+
+ public static bool Handles(string[] args) => args.FirstOrDefault() == Command;
+
+ public static async Task RunAsync(string[] args, TextWriter output)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+ ArgumentNullException.ThrowIfNull(output);
+ var log = new StringWriter();
+ var both = new TeeWriter(output, log);
+ var agentAccount = Option(args, "--agent-account") ?? @"NT SERVICE\SQLInsight Agent";
+ var webAccount = Option(args, "--web-account") ?? @"NT SERVICE\SQLInsight Web";
+ var options = CustomRules.RuleCommands.LoadAgentOptions();
+
+ try
+ {
+ await PrepareRepositoryAsync(options.Repository.ConnectionString, agentAccount, webAccount, both).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is SqlException or InvalidOperationException or ArgumentException)
+ {
+ both.WriteLine($"FEJL: {ex.Message}");
+ both.WriteLine(Hint(ex));
+ Report(log.ToString(), Outcome.Error);
+ return 1;
+ }
+
+ // The monitored instances are a convenience: an instance that cannot be reached now (or where the installing
+ // user is not sysadmin) is reported, but does not stop the installation; grant-least-privilege.sql can be run later.
+ var warnings = 0;
+ if (args.Contains("--grant-monitored"))
+ {
+ foreach (var instance in options.Instances.Where(i => !string.IsNullOrWhiteSpace(i.Server)))
+ {
+ try
+ {
+ await GrantMonitoredAsync(instance, agentAccount, both).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is SqlException or InvalidOperationException)
+ {
+ warnings++;
+ both.WriteLine($"ADVARSEL: rettigheder på {instance.Server} kunne ikke gives: {ex.Message}");
+ both.WriteLine(" Kør db/permissions/grant-least-privilege.sql på instansen som sysadmin.");
+ }
+ }
+ }
+
+ Report(log.ToString(), warnings > 0 ? Outcome.Warning : Outcome.Ok);
+ return 0;
+ }
+
+ private static async Task PrepareRepositoryAsync(string? connectionString, string agentAccount, string webAccount, TextWriter output)
+ {
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new InvalidOperationException("SQLInsight:Repository:ConnectionString er ikke sat (sqlinsight.ini eller appsettings.json).");
+ }
+
+ var repository = new SqlConnectionStringBuilder(connectionString) { Pooling = false };
+ var database = repository.InitialCatalog;
+ if (string.IsNullOrWhiteSpace(database))
+ {
+ throw new ArgumentException("Forbindelsesstrengen mangler Database=…, fx Database=SQLInsight.");
+ }
+
+ var master = new SqlConnectionStringBuilder(repository.ConnectionString) { InitialCatalog = "master" };
+ await using (var connection = new SqlConnection(master.ConnectionString))
+ {
+ await connection.OpenAsync().ConfigureAwait(false);
+ output.WriteLine($"Forbundet til {repository.DataSource} som {await ScalarAsync(connection, "SELECT SUSER_SNAME()").ConfigureAwait(false)}.");
+ var created = await ScalarAsync(connection, """
+ IF DB_ID(@db) IS NULL
+ BEGIN
+ DECLARE @sql nvarchar(max) = N'CREATE DATABASE ' + QUOTENAME(@db);
+ EXEC (@sql);
+ SELECT N'oprettet';
+ END
+ ELSE SELECT N'findes';
+ """, ("@db", database)).ConfigureAwait(false);
+ output.WriteLine($"Database {database}: {created}.");
+
+ // A SQL login in the connection string is what the services use too; there are no Windows accounts to add.
+ if (repository.IntegratedSecurity)
+ {
+ foreach (var (login, _) in RepositoryLogins(agentAccount, webAccount, repository.DataSource))
+ {
+ output.WriteLine($"Login {login}: {await EnsureLoginAsync(connection, login).ConfigureAwait(false)}.");
+ }
+ }
+ }
+
+ if (repository.IntegratedSecurity)
+ {
+ await using var connection = new SqlConnection(repository.ConnectionString);
+ await connection.OpenAsync().ConfigureAwait(false);
+ foreach (var (login, role) in RepositoryLogins(agentAccount, webAccount, repository.DataSource))
+ {
+ var user = await ScalarAsync(connection, """
+ DECLARE @user sysname = (SELECT name FROM sys.database_principals WHERE sid = SUSER_SID(@login));
+ IF @user IS NULL
+ BEGIN
+ SET @user = @login;
+ DECLARE @create nvarchar(max) = N'CREATE USER ' + QUOTENAME(@user) + N' FOR LOGIN ' + QUOTENAME(@login);
+ EXEC (@create);
+ END
+ IF @user <> N'dbo' AND IS_ROLEMEMBER(@role, @user) <> 1
+ BEGIN
+ DECLARE @add nvarchar(max) = N'ALTER ROLE ' + QUOTENAME(@role) + N' ADD MEMBER ' + QUOTENAME(@user);
+ EXEC (@add);
+ END
+ SELECT @user;
+ """, ("@login", login), ("@role", role)).ConfigureAwait(false);
+ output.WriteLine($"Bruger {user} i {database}: {role}.");
+ }
+ }
+
+ var applied = await SchemaMigrator.MigrateAsync(repository.ConnectionString).ConfigureAwait(false);
+ output.WriteLine(applied > 0 ? $"Skema: {applied} migreringer kørt." : "Skema: opdateret.");
+ }
+
+ private static async Task GrantMonitoredAsync(MonitoredInstance instance, string agentAccount, TextWriter output)
+ {
+ if (instance.Authentication != AuthenticationMode.Windows)
+ {
+ output.WriteLine($"{instance.Server}: springes over ({instance.Authentication}); giv rettigheder med grant-least-privilege.sql.");
+ return;
+ }
+
+ var login = LoginFor(agentAccount, instance.Server, Environment.MachineName, Environment.UserDomainName);
+ var target = new SqlConnectionStringBuilder
+ {
+ DataSource = instance.Server,
+ InitialCatalog = "master",
+ IntegratedSecurity = true,
+ Encrypt = instance.Encryption == EncryptionMode.Mandatory ? SqlConnectionEncryptOption.Mandatory : SqlConnectionEncryptOption.Optional,
+ TrustServerCertificate = instance.TrustServerCertificate,
+ ApplicationName = "SQLInsight Pro setup",
+ Pooling = false,
+ };
+ await using var connection = new SqlConnection(target.ConnectionString);
+ connection.InfoMessage += (_, e) => output.WriteLine($" {e.Message}");
+ await connection.OpenAsync().ConfigureAwait(false);
+ output.WriteLine($"Login {login} på {instance.Server}: {await EnsureLoginAsync(connection, login).ConfigureAwait(false)}.");
+
+ await using var command = new SqlCommand(GrantScript(), connection) { CommandTimeout = 300 };
+ command.Parameters.AddWithValue("@login", login);
+ await command.ExecuteNonQueryAsync().ConfigureAwait(false);
+ output.WriteLine($"Rettigheder givet på {instance.Server}.");
+ }
+
+ ///
+ /// The logins the services connect as, with their role in the repository. Both services on another computer than
+ /// SQL Server arrive as the computer account, which then gets db_owner (it covers reading).
+ ///
+ public static IReadOnlyList<(string Login, string Role)> RepositoryLogins(string agentAccount, string webAccount, string sqlServer)
+ {
+ var agent = LoginFor(agentAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
+ var web = LoginFor(webAccount, sqlServer, Environment.MachineName, Environment.UserDomainName);
+ return string.Equals(agent, web, StringComparison.OrdinalIgnoreCase)
+ ? [(agent, "db_owner")]
+ : [(agent, "db_owner"), (web, "db_datareader")];
+ }
+
+ ///
+ /// The Windows login a service account has on . Virtual and built-in machine accounts
+ /// (NT SERVICE\…, LocalSystem, NETWORK SERVICE) only exist locally; on another server they arrive as the computer
+ /// account DOMAIN\COMPUTER$. Domain accounts and gMSAs are the same everywhere.
+ ///
+ public static string LoginFor(string account, string sqlServer, string machineName, string domain)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(account);
+ var normalized = account.Trim();
+ if (normalized.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase))
+ {
+ normalized = @"NT AUTHORITY\SYSTEM";
+ }
+
+ var machineIdentity = normalized.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase)
+ || normalized.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase);
+ return machineIdentity && !IsLocal(sqlServer, machineName) ? $@"{domain}\{machineName}$" : normalized;
+ }
+
+ /// Whether a SQL Server data source (server, server\instance, tcp:server,port …) is this computer.
+ public static bool IsLocal(string sqlServer, string machineName)
+ {
+ ArgumentNullException.ThrowIfNull(sqlServer);
+ var host = ProtocolPrefix().Replace(sqlServer.Trim(), string.Empty).Split('\\', ',')[0].Trim();
+ return host is "" or "." or "(local)" or "(localdb)" or "127.0.0.1" or "::1"
+ || host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
+ || host.Equals(machineName, StringComparison.OrdinalIgnoreCase)
+ || host.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static async Task EnsureLoginAsync(SqlConnection connection, string login) =>
+ await ScalarAsync(connection, """
+ IF SUSER_ID(@login) IS NULL
+ BEGIN
+ DECLARE @sql nvarchar(max) = N'CREATE LOGIN ' + QUOTENAME(@login) + N' FROM WINDOWS';
+ EXEC (@sql);
+ SELECT N'oprettet';
+ END
+ ELSE SELECT N'findes';
+ """, ("@login", login)).ConfigureAwait(false);
+
+ private static async Task ScalarAsync(SqlConnection connection, string sql, params (string Name, string Value)[] parameters)
+ {
+ await using var command = new SqlCommand(sql, connection) { CommandTimeout = 300 };
+ foreach (var (name, value) in parameters)
+ {
+ command.Parameters.AddWithValue(name, value);
+ }
+
+ return Convert.ToString(await command.ExecuteScalarAsync().ConfigureAwait(false), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
+ }
+
+ /// db/permissions/grant-least-privilege.sql with the login as the parameter @login instead of the placeholder.
+ public static string GrantScript()
+ {
+ using var stream = typeof(RepositorySetup).Assembly.GetManifestResourceStream("grant-least-privilege.sql")
+ ?? throw new InvalidOperationException("grant-least-privilege.sql mangler i programmet.");
+ using var reader = new StreamReader(stream);
+ var script = LoginPlaceholder().Replace(reader.ReadToEnd(), "SET @LoginName = @login;", 1);
+ return script.Contains("SET @LoginName = @login;", StringComparison.Ordinal)
+ ? script
+ : throw new InvalidOperationException("grant-least-privilege.sql: linjen SET @LoginName = N'…' blev ikke fundet.");
+ }
+
+ private static string Hint(Exception ex) => ex switch
+ {
+ SqlException { Number: 18456 } => "Din Windows-konto kan ikke logge ind på SQL Server. Installér som en bruger, der er sysadmin på instansen.",
+ SqlException { Number: 262 or 1802 or 15247 } => "Din konto mangler rettighed til at oprette databaser eller logins (dbcreator og securityadmin, eller sysadmin).",
+ SqlException { Number: 15401 } => "Windows-kontoen findes ikke på SQL Server-maskinen. Ligger SQL Server på en anden computer, skal computeren være i samme domæne.",
+ SqlException { Number: -2146893019 } or SqlException { InnerException: System.Security.Authentication.AuthenticationException } =>
+ "SQL Server bruger et certifikat, der ikke er tillid til. Tilføj TrustServerCertificate=true i forbindelsesstrengen.",
+ SqlException { Number: 2 or 53 or -1 } => "SQL Server kunne ikke nås. Tjek servernavnet, at instansen kører, og firewallen (TCP 1433).",
+ _ => "Installér med SKIP_REPOSITORY_SETUP=1 og opret databasen selv (docs/installation.md, trin 2), hvis det ikke kan løses.",
+ };
+
+ private enum Outcome { Ok, Warning, Error }
+
+ private static void Report(string text, Outcome outcome)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ try
+ {
+ var type = outcome switch { Outcome.Error => EventLogEntryType.Error, Outcome.Warning => EventLogEntryType.Warning, _ => EventLogEntryType.Information };
+ EventLog.WriteEntry(EventSource, "Opsætning af repository (setup-repository):" + Environment.NewLine + text, type);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or System.Security.SecurityException or System.ComponentModel.Win32Exception)
+ {
+ // The event source is registered by the installer; outside it the console output is enough.
+ }
+ }
+
+ private static string? Option(string[] args, string name)
+ {
+ var i = Array.IndexOf(args, name);
+ return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
+ }
+
+ [GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)]
+ private static partial Regex ProtocolPrefix();
+
+ [GeneratedRegex(@"SET @LoginName = N'[^']*';[^\r\n]*")]
+ private static partial Regex LoginPlaceholder();
+
+ private sealed class TeeWriter(TextWriter first, TextWriter second) : TextWriter
+ {
+ public override System.Text.Encoding Encoding => first.Encoding;
+
+ public override void Write(char value)
+ {
+ first.Write(value);
+ second.Write(value);
+ }
+
+ public override void WriteLine(string? value)
+ {
+ first.WriteLine(value);
+ second.WriteLine(value);
+ }
+ }
+}
diff --git a/tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs b/tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs
new file mode 100644
index 0000000..6a4e346
--- /dev/null
+++ b/tests/SQLInsight.Agent.Tests/RepositorySetupTests.cs
@@ -0,0 +1,49 @@
+using SQLInsight.Agent.Setup;
+
+namespace SQLInsight.Agent.Tests;
+
+public sealed class RepositorySetupTests
+{
+ [Theory]
+ [InlineData(".")]
+ [InlineData(@".\SQLEXPRESS")]
+ [InlineData("(local)")]
+ [InlineData("localhost,1433")]
+ [InlineData(@"tcp:localhost\SQLEXPRESS")]
+ [InlineData("SQLMON01")]
+ [InlineData(@"sqlmon01\SQLEXPRESS")]
+ [InlineData("sqlmon01.kunde.local,1433")]
+ public void Recognises_this_computer(string server) => Assert.True(RepositorySetup.IsLocal(server, "SQLMON01"));
+
+ [Theory]
+ [InlineData("sql02")]
+ [InlineData(@"sql02\PROD")]
+ [InlineData("sqlmon011")]
+ [InlineData("tcp:10.0.0.5,1433")]
+ public void Recognises_other_computers(string server) => Assert.False(RepositorySetup.IsLocal(server, "SQLMON01"));
+
+ [Fact]
+ public void Virtual_account_keeps_its_name_on_the_same_computer() =>
+ Assert.Equal(@"NT SERVICE\SQLInsight Agent", RepositorySetup.LoginFor(@"NT SERVICE\SQLInsight Agent", @".\SQLEXPRESS", "SQLMON01", "KUNDE"));
+
+ [Theory]
+ [InlineData(@"NT SERVICE\SQLInsight Agent")]
+ [InlineData(@"NT AUTHORITY\NETWORK SERVICE")]
+ [InlineData("LocalSystem")]
+ public void Machine_accounts_arrive_as_the_computer_account_on_another_server(string account) =>
+ Assert.Equal(@"KUNDE\SQLMON01$", RepositorySetup.LoginFor(account, "sql02", "SQLMON01", "KUNDE"));
+
+ [Fact]
+ public void Domain_accounts_and_gmsas_are_the_same_everywhere() =>
+ Assert.Equal(@"KUNDE\svc-sqlinsight$", RepositorySetup.LoginFor(@"KUNDE\svc-sqlinsight$", "sql02", "SQLMON01", "KUNDE"));
+
+ [Fact]
+ public void Grant_script_takes_the_login_as_a_parameter()
+ {
+ var script = RepositorySetup.GrantScript();
+
+ Assert.Contains("SET @LoginName = @login;", script, StringComparison.Ordinal);
+ Assert.DoesNotContain("SET @LoginName = N'", script, StringComparison.Ordinal);
+ Assert.Contains("VIEW ANY DEFINITION", script, StringComparison.Ordinal);
+ }
+}