MSI installer for Agent and Web
Some checks are pending
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 / Build og unit tests (push) Waiting to run
CI / MSI-installer (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

installer/SQLInsight.wxs (WiX 3 syntax) builds on Linux with wixl
(installer/build-msi.sh) and in Forgejo CI on every v* tag. Agent and Web
are installed self-contained as Windows services under virtual service
accounts (or a gMSA), with a settings page in the wizard or MSI
properties for silent installs; the values go to sqlinsight.ini, read
after appsettings.json. The data folder is locked to SYSTEM,
Administrators and the agent account, the event log source is
registered, the web port is opened in the firewall, and the automatic
update task is an optional feature. Upgrades happen in place, keep
edited settings and the chosen features; uninstall keeps the settings.

msi-tables.py writes what wixl lacks (component and feature conditions,
Permanent/NeverOverwrite, vital services, REG_EXPAND_SZ) and
verify-msi.py checks the result (sequencing, deferred actions, ASCII
only). test-msi-wine.sh runs install, upgrade and uninstall under Wine.
Installations made with the MSI get new versions as MSI packages run by
the update task, so Windows Installer stays in charge of the files.
The example instance is removed from the shipped appsettings.json, and
the CLI writes UTF-8 to the console.

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 11:31:28 +00:00
parent 4269f6f9e8
commit d764552c2c
No known key found for this signature in database
37 changed files with 927 additions and 23 deletions

View file

@ -3,6 +3,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"]
pull_request: pull_request:
env: env:
@ -30,6 +31,33 @@ jobs:
- name: Unit tests - name: Unit tests
run: dotnet test --no-build --configuration Release --filter "Category!=Integration" run: dotnet test --no-build --configuration Release --filter "Category!=Integration"
msi:
name: MSI-installer
needs: build
runs-on: docker
container:
image: mcr.microsoft.com/dotnet/sdk:10.0
steps:
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraHeader="Authorization: token ${{ secrets.GITHUB_TOKEN }}" fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Værktøjer (wixl, msitools; nodejs til upload-artifact)
run: apt-get update -q && apt-get install -y -q --no-install-recommends wixl msitools python3 nodejs
- name: Byg og kontrollér MSI
# Et tag som v1.2.0 giver version 1.2.0; ellers versionen fra Directory.Build.props.
run: |
version="${GITHUB_REF_NAME#v}"
case "$GITHUB_REF" in refs/tags/v*) ;; *) version=$(sed -n 's|.*<Version>\(.*\)</Version>.*|\1|p' Directory.Build.props) ;; esac
installer/build-msi.sh "$version"
- name: Gem MSI
uses: https://code.forgejo.org/actions/upload-artifact@v3
with:
name: SQLInsight-Pro-msi
path: dist/*.msi
integration: integration:
name: Integration (${{ matrix.image }}) name: Integration (${{ matrix.image }})
needs: build needs: build

1
.gitignore vendored
View file

@ -483,3 +483,4 @@ $RECYCLE.BIN/
# SQLInsight license signing keys: never commit private keys # SQLInsight license signing keys: never commit private keys
*.private.pem *.private.pem
dist/

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Ini" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" /> <PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" /> <PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />

View file

@ -181,7 +181,7 @@ Hvert **finding** indeholder:
- [x] Registrering af version og CU-niveau med advarsel om manglende opdateringer (katalog pr. september 2026) - [x] Registrering af version og CU-niveau med advarsel om manglende opdateringer (katalog pr. september 2026)
- [x] Legacy-collectors til 2000/2005/2008: versionsspecifik SQL er skrevet og unit-testet, men ikke kørt mod rigtige instanser (kræver VM'er, se Fase 0) - [x] Legacy-collectors til 2000/2005/2008: versionsspecifik SQL er skrevet og unit-testet, men ikke kørt mod rigtige instanser (kræver VM'er, se Fase 0)
- [x] Installationsvejledning ([docs/installation.md](docs/installation.md)) - [x] Installationsvejledning ([docs/installation.md](docs/installation.md))
- [ ] MSI-installer (kræver Windows-byggemiljø, WiX) - [x] MSI-installer: agent og web som Windows-tjenester, indbygget .NET, installationsguide eller stille installation med parametre, opgradering, der bevarer indstillinger, og valgfri opdateringsopgave. Bygges på Linux (wixl) og i CI. Afprøvet under Wine, endnu ikke på en rigtig Windows Server
### Fase 2: Dyb analyse ### Fase 2: Dyb analyse
- [x] Query Store: planregressioner (med script til at forcere den gode plan), forcerede planer der fejler, og parameterfølsomme forespørgsler - [x] Query Store: planregressioner (med script til at forcere den gode plan), forcerede planer der fejler, og parameterfølsomme forespørgsler

View file

@ -11,10 +11,49 @@ Begge bruger en **repository-database** på en SQL Server 2016+-instans, der hel
> Installationstrinene er skrevet ud fra Microsofts dokumentation for ASP.NET Core-hosting. De er endnu ikke gennemtestet på en ren Windows Server. Meld gerne afvigelser tilbage som issue. > Installationstrinene er skrevet ud fra Microsofts dokumentation for ASP.NET Core-hosting. De er endnu ikke gennemtestet på en ren Windows Server. Meld gerne afvigelser tilbage som issue.
## Hurtigst: MSI-installeren
`SQLInsight-Pro-<version>-x64.msi` installerer agent og web som Windows-tjenester. .NET er indbygget, så serveren behøver ingen runtime. Opret først repository-databasen (trin 2) og giv rettigheder (trin 3). Kør derefter installeren som administrator:
- **Med installationsguiden:** Første side spørger om forbindelsesstrengen til repositoriet, den første SQL Server, tjenestekonti og port.
- **Stille, fx med SCCM/Intune:**
```powershell
msiexec /i SQLInsight-Pro-1.0.0-x64.msi /qn /l*v install.log `
REPOSITORY_CONNECTION="Server=sqlmon01;Database=SQLInsight;Integrated Security=true;Encrypt=true" `
MONITOR_SERVER="sql01" `
AGENT_ACCOUNT="DOMÆNE\svc-sqlinsight$" WEB_ACCOUNT="DOMÆNE\svc-sqlinsight-web$" WEB_PORT=5080 `
ADDLOCAL=ALL
```
| Egenskab | Standard | |
|---|---|---|
| `REPOSITORY_CONNECTION` | (ingen) | Forbindelsesstreng til repository-databasen. Uden den installeres tjenesterne, men startes ikke |
| `MONITOR_SERVER` | (ingen) | Første instans, der skal overvåges. Flere tilføjes i `appsettings.json` |
| `AGENT_ACCOUNT` | `NT SERVICE\SQLInsight Agent` | Tjenestekonto til agenten. Den virtuelle konto når andre servere som computerkontoen (`DOMÆNE\SERVER$`), så det er den, der skal have login og rettigheder på de overvågede instanser. En gMSA angives som `DOMÆNE\navn$` |
| `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 |
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.
- `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)).
**Opgradering:** Kør en nyere MSI; der skal ingen parametre med. `appsettings.json` og `sqlinsight.ini` bevares, og det samme gælder de valgte dele (fx opdateringsopgaven).
**Afinstallation:** Brug *Programmer og funktioner* eller `msiexec /x`. Tjenester, programfiler, firewallregel og opdateringsopgave fjernes. `appsettings.json`, `sqlinsight.ini` og `C:\ProgramData\SQLInsight` bliver liggende, så en geninstallation beholder indstillingerne.
Web kører her på Kestrel over http med Windows-login. Skal den bruge https eller køre i IIS, så se trin 5a/5b.
**Til SEOSoft:** MSI'en bygges på Linux med `installer/build-msi.sh <version>` (kræver `apt install wixl msitools`) og bygges også af CI ved hvert tag `v*`. Scriptet kontrollerer selv MSI-tabellerne. `installer/test-msi-wine.sh` afprøver installation, opgradering og afinstallation under Wine.
## 1. Forudsætninger ## 1. Forudsætninger
- Windows Server 2016 eller nyere (eller Linux til agenten) - Windows Server 2016 eller nyere (eller Linux til agenten)
- [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) på agent-serveren; **ASP.NET Core Hosting Bundle** på webserveren, hvis IIS bruges - Ved manuel installation (ikke MSI): [.NET 10 Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) på agent-serveren; **ASP.NET Core Hosting Bundle** på webserveren, hvis IIS bruges
- To tjenestekonti, gerne gMSA: én til agenten og én til web-UI'et - To tjenestekonti, gerne gMSA: én til agenten og én til web-UI'et
- Til SQL Server 2000: en Windows-agent med en ODBC-driver, der understøtter 2000 (se [legacy-versions.md](legacy-versions.md)) - Til SQL Server 2000: en Windows-agent med en ODBC-driver, der understøtter 2000 (se [legacy-versions.md](legacy-versions.md))

View file

@ -49,6 +49,8 @@ Som administrator på agentens server:
& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" update apply & "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" update apply
``` ```
Er SQLInsight Pro installeret med MSI'en, henter agenten den nye MSI, og opgaven installerer den med Windows Installer. Windows Installer stopper og starter tjenesterne og ruller selv tilbage ved fejl. Så passer MSI'ens viden om versionen altid, og en senere reparation eller opgradering kan ikke lægge ældre filer tilbage.
`update apply` henter pakken, verificerer den, stopper tjenesten, tager backup, kopierer de nye filer ind (uden at røre `appsettings*.json` og `web.config`), starter tjenesten og holder øje med den i et minut. Stopper den, rulles der automatisk tilbage. `update apply` henter pakken, verificerer den, stopper tjenesten, tager backup, kopierer de nye filer ind (uden at røre `appsettings*.json` og `web.config`), starter tjenesten og holder øje med den i et minut. Stopper den, rulles der automatisk tilbage.
### Automatisk (`Automatic`) ### Automatisk (`Automatic`)
@ -123,14 +125,19 @@ Kører web i IIS, angives `"AppOffline": true` i stedet for `Service`. Så lægg
# Regelpakke ud fra katalogfilerne i src/SQLInsight.Rules/Lifecycle # Regelpakke ud fra katalogfilerne i src/SQLInsight.Rules/Lifecycle
dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json dotnet run --project tools/SQLInsight.LicenseTool -- rulepack --version 2026.10.14 --out rulepack-2026.10.14.json
# MSI til installationer lavet med MSI'en (bygges på Linux)
installer/build-msi.sh 1.1.0
# Programpakker: én zip pr. platform med mapperne Agent/ og Web/ # Programpakker: én zip pr. platform med mapperne Agent/ og Web/
dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Agent # Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Web dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained true -p:Version=1.1.0 -o pkg/win-x64/Agent
dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained true -p:Version=1.1.0 -o pkg/win-x64/Web
# Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest) # Signér kanalens manifest (det, der ikke angives, overtages fra det eksisterende manifest)
dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \ dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \
--feed feed --channel stable --rulepack rulepack-2026.10.14.json \ --feed feed --channel stable --rulepack rulepack-2026.10.14.json \
--release 1.1.0 --asset win-x64=sqlinsight-1.1.0-win-x64.zip --asset linux-x64=sqlinsight-1.1.0-linux-x64.zip \ --release 1.1.0 --asset win-x64=sqlinsight-1.1.0-win-x64.zip --asset win-x64-msi=dist/SQLInsight-Pro-1.1.0-x64.msi \
--asset linux-x64=sqlinsight-1.1.0-linux-x64.zip \
--notes https://git.seosoft.dk/seo/sqlinsight-pro/releases --notes https://git.seosoft.dk/seo/sqlinsight-pro/releases
``` ```

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

7
installer/License.rtf Normal file
View file

@ -0,0 +1,7 @@
{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss Segoe UI;}}\f0\fs18
MIT License\par\par
Copyright (c) 2025 seosoftdk\par\par
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\par\par
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\par\par
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\par
}

208
installer/SQLInsight.wxs Normal file
View file

@ -0,0 +1,208 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SQLInsight Pro: Agent and Web as Windows services.
Built with wixl (msitools) by installer/build-msi.sh; WiX 3 syntax, so WiX 3 on Windows can build it too.
Properties (msiexec /i SQLInsight-Pro.msi PROPERTY=value ... /qn):
REPOSITORY_CONNECTION Connection string to the repository database (required for the services to start)
MONITOR_SERVER Optional first SQL Server to monitor (name, or name\instance, or name,port)
AGENT_ACCOUNT Account for SQLInsight Agent. Default the virtual account NT SERVICE\SQLInsight Agent,
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)
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.
-->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*" Name="SQLInsight Pro" Language="1033" Version="$(var.Version)" Manufacturer="SEOSoft"
UpgradeCode="91D05A67-4470-4C4F-9422-15CEF1B559BB">
<Package InstallerVersion="500" Compressed="yes" InstallScope="perMachine" Platform="x64"
Description="SQLInsight Pro $(var.Version)" Comments="SQL Server monitoring with deep expertise" Manufacturer="SEOSoft" />
<Media Id="1" Cabinet="sqlinsight.cab" EmbedCab="yes" CompressionLevel="high" />
<!-- Newer versions replace older ones in place; a downgrade is refused. -->
<Upgrade Id="91D05A67-4470-4C4F-9422-15CEF1B559BB">
<UpgradeVersion OnlyDetect="no" MigrateFeatures="yes" Property="OLDVERSIONFOUND" Minimum="0.0.0" IncludeMinimum="yes" Maximum="$(var.Version)" IncludeMaximum="no" />
<UpgradeVersion OnlyDetect="yes" Property="NEWERVERSIONFOUND" Minimum="$(var.Version)" IncludeMinimum="no" />
</Upgrade>
<Condition Message="A newer version of SQLInsight Pro is already installed.">NOT NEWERVERSIONFOUND</Condition>
<Condition Message="SQLInsight Pro requires 64-bit Windows Server 2016 / Windows 10 or later.">VersionNT64 &gt;= 603</Condition>
<Condition Message="Installing SQLInsight Pro requires administrator rights.">Privileged</Condition>
<Property Id="REPOSITORY_CONNECTION" Secure="yes" />
<Property Id="MONITOR_SERVER" Secure="yes" />
<Property Id="AGENT_ACCOUNT" Value="NT SERVICE\SQLInsight Agent" Secure="yes" />
<Property Id="WEB_ACCOUNT" Value="NT SERVICE\SQLInsight Web" Secure="yes" />
<Property Id="WEB_PORT" Value="5080" Secure="yes" />
<!-- Set when the update task was installed by an earlier version, so an upgrade keeps it (see the UpdateTask feature). -->
<Property Id="UPDATETASK_INSTALLED">
<RegistrySearch Id="UpdateTaskInstalled" Root="HKLM" Key="SOFTWARE\SEOSoft\SQLInsight" Name="UpdateTask" Type="raw" Win64="yes" />
</Property>
<Property Id="ARPURLINFOABOUT" Value="https://git.seosoft.dk/seo/sqlinsight-pro" />
<Property Id="ARPHELPLINK" Value="https://git.seosoft.dk/seo/sqlinsight-pro/src/branch/main/docs" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="SQLInsight">
<Directory Id="AGENTDIR" Name="Agent" />
<Directory Id="WEBDIR" Name="Web" />
</Directory>
</Directory>
<Directory Id="CommonAppDataFolder">
<Directory Id="DATADIR" Name="SQLInsight">
<Directory Id="RULESDIR" Name="rules" />
</Directory>
</Directory>
</Directory>
<!-- Agent: service, settings from the installer, data folder, event log source. -->
<DirectoryRef Id="AGENTDIR">
<Component Id="AgentService" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A01" Win64="yes">
<File Id="AgentExe" Source="$(var.AgentDir)/SQLInsight.Agent.exe" KeyPath="yes" />
<ServiceInstall Id="AgentServiceInstall" Name="SQLInsight Agent" DisplayName="SQLInsight Agent"
Description="Monitors SQL Server and stores findings in the SQLInsight repository."
Type="ownProcess" Start="auto" ErrorControl="normal" Account="[AGENT_ACCOUNT]" Vital="yes" />
<ServiceControl Id="AgentServiceStop" Name="SQLInsight Agent" Stop="both" Remove="uninstall" Wait="yes" />
</Component>
<Component Id="AgentSettings" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A02" Win64="yes" NeverOverwrite="yes" Permanent="yes">
<File Id="AgentAppSettings" Source="$(var.AgentDir)/appsettings.json" KeyPath="yes" />
</Component>
<Component Id="AgentIni" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A03" Win64="yes" Permanent="yes">
<Condition>REPOSITORY_CONNECTION</Condition>
<CreateFolder />
<IniFile Id="AgentIniRepository" Name="sqlinsight.ini" Directory="AGENTDIR" Action="addLine"
Section="SQLInsight:Repository" Key="ConnectionString" Value="[REPOSITORY_CONNECTION]" />
</Component>
<Component Id="AgentIniInstance" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A04" Win64="yes" Permanent="yes">
<Condition>MONITOR_SERVER</Condition>
<CreateFolder />
<IniFile Id="AgentIniInstanceName" Name="sqlinsight.ini" Directory="AGENTDIR" Action="createLine"
Section="SQLInsight:Instances:0" Key="Name" Value="[MONITOR_SERVER]" />
<IniFile Id="AgentIniInstanceServer" Name="sqlinsight.ini" Directory="AGENTDIR" Action="createLine"
Section="SQLInsight:Instances:0" Key="Server" Value="[MONITOR_SERVER]" />
</Component>
<!-- Started only when there is something to connect to, so a plain install never fails on a missing setting. -->
<Component Id="AgentServiceStart" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A05" Win64="yes">
<Condition>REPOSITORY_CONNECTION OR OLDVERSIONFOUND</Condition>
<CreateFolder />
<ServiceControl Id="AgentServiceStartControl" Name="SQLInsight Agent" Start="install" Wait="no" />
</Component>
<!-- Tells the updater that Windows Installer owns the files: new versions are then installed as MSI packages. -->
<Component Id="InstallInfo" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A09" Win64="yes">
<RegistryKey Root="HKLM" Key="SOFTWARE\SEOSoft\SQLInsight">
<RegistryValue Name="Installer" Type="string" Value="msi" KeyPath="yes" />
<RegistryValue Name="Version" Type="string" Value="[ProductVersion]" />
</RegistryKey>
</Component>
<Component Id="EventSource" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A06" Win64="yes">
<RegistryKey Root="HKLM" Key="SYSTEM\CurrentControlSet\Services\EventLog\Application\SQLInsight Pro">
<RegistryValue Name="EventMessageFile" Type="expandable" Value="%SystemRoot%\Microsoft.NET\Framework64\v4.0.30319\EventLogMessages.dll" KeyPath="yes" />
</RegistryKey>
</Component>
</DirectoryRef>
<DirectoryRef Id="INSTALLFOLDER">
<!-- Only here so the UpdateTask feature has something to install; the task itself is made by schtasks. -->
<Component Id="UpdateTaskMarker" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A08" Win64="yes">
<RegistryValue Root="HKLM" Key="SOFTWARE\SEOSoft\SQLInsight" Name="UpdateTask" Type="integer" Value="1" KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="RULESDIR">
<Component Id="DataFolder" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A07" Win64="yes" Permanent="yes">
<CreateFolder />
</Component>
</DirectoryRef>
<!-- Web: service on its own Kestrel port with Windows authentication. -->
<DirectoryRef Id="WEBDIR">
<Component Id="WebService" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A11" Win64="yes">
<File Id="WebExe" Source="$(var.WebDir)/SQLInsight.Web.exe" KeyPath="yes" />
<ServiceInstall Id="WebServiceInstall" Name="SQLInsight Web" DisplayName="SQLInsight Web"
Description="Web interface for SQLInsight Pro."
Type="ownProcess" Start="auto" ErrorControl="normal" Account="[WEB_ACCOUNT]" Vital="yes" />
<ServiceControl Id="WebServiceStop" Name="SQLInsight Web" Stop="both" Remove="uninstall" Wait="yes" />
</Component>
<Component Id="WebSettings" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A12" Win64="yes" NeverOverwrite="yes" Permanent="yes">
<File Id="WebAppSettings" Source="$(var.WebDir)/appsettings.json" KeyPath="yes" />
</Component>
<Component Id="WebIni" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A13" Win64="yes" Permanent="yes">
<Condition>REPOSITORY_CONNECTION</Condition>
<CreateFolder />
<IniFile Id="WebIniRepository" Name="sqlinsight.ini" Directory="WEBDIR" Action="addLine"
Section="SQLInsight:Repository" Key="ConnectionString" Value="[REPOSITORY_CONNECTION]" />
<IniFile Id="WebIniUrl" Name="sqlinsight.ini" Directory="WEBDIR" Action="addLine"
Section="Kestrel:Endpoints:Http" Key="Url" Value="http://*:[WEB_PORT]" />
</Component>
<Component Id="WebServiceStart" Guid="0B7E3E43-2C42-4F55-9A4C-6D1E0B6F2A14" Win64="yes">
<Condition>REPOSITORY_CONNECTION OR OLDVERSIONFOUND</Condition>
<CreateFolder />
<ServiceControl Id="WebServiceStartControl" Name="SQLInsight Web" Start="install" Wait="no" />
</Component>
</DirectoryRef>
<!-- System tools run by the deferred custom actions below (their paths are set first, as properties). -->
<CustomAction Id="SetIcaclsPath" Property="ICACLS_EXE" Value="[System64Folder]icacls.exe" />
<CustomAction Id="SetNetshPath" Property="NETSH_EXE" Value="[System64Folder]netsh.exe" />
<CustomAction Id="SetSchtasksPath" Property="SCHTASKS_EXE" Value="[System64Folder]schtasks.exe" />
<!-- The data folder: only SYSTEM, Administrators and the agent's account; nobody else may drop rules or updates there. -->
<CustomAction Id="SetDataFolderAcl" Property="ICACLS_EXE" Execute="deferred" Impersonate="no" Return="ignore"
ExeCommand="&quot;[DATADIR].&quot; /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F &quot;[AGENT_ACCOUNT]:(OI)(CI)M&quot;" />
<!-- Windows Firewall: open the web port while Web is installed. -->
<CustomAction Id="OpenFirewall" Property="NETSH_EXE" Execute="deferred" Impersonate="no" Return="ignore"
ExeCommand="advfirewall firewall add rule name=&quot;SQLInsight Web&quot; dir=in action=allow protocol=TCP localport=[WEB_PORT]" />
<CustomAction Id="ReplaceFirewallRule" Property="NETSH_EXE" Execute="deferred" Impersonate="no" Return="ignore"
ExeCommand="advfirewall firewall delete rule name=&quot;SQLInsight Web&quot;" />
<CustomAction Id="CloseFirewall" Property="NETSH_EXE" Execute="deferred" Impersonate="no" Return="ignore"
ExeCommand="advfirewall firewall delete rule name=&quot;SQLInsight Web&quot;" />
<!-- Update task: installs updates the agent has downloaded and verified, hourly, as SYSTEM (see docs/opdatering.md). -->
<CustomAction Id="CreateUpdateTask" Property="SCHTASKS_EXE" Execute="deferred" Impersonate="no" Return="ignore"
ExeCommand="/Create /F /TN &quot;SQLInsight Update&quot; /SC HOURLY /RU SYSTEM /RL HIGHEST /TR &quot;\&quot;[AGENTDIR]SQLInsight.Agent.exe\&quot; apply-update&quot;" />
<CustomAction Id="DeleteUpdateTask" Property="SCHTASKS_EXE" Execute="deferred" Impersonate="no" Return="ignore"
ExeCommand="/Delete /F /TN &quot;SQLInsight Update&quot;" />
<InstallExecuteSequence>
<RemoveExistingProducts After="InstallExecute" />
<Custom Action="SetIcaclsPath" Before="LaunchConditions">1</Custom>
<Custom Action="SetNetshPath" After="SetIcaclsPath">1</Custom>
<Custom Action="SetSchtasksPath" After="SetNetshPath">1</Custom>
<!-- Removal: not when an upgrade removes the old version, or it would undo what the new one just set up. -->
<Custom Action="DeleteUpdateTask" Sequence="3400">&amp;UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
<Custom Action="CloseFirewall" Sequence="3401">&amp;Web=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
<!-- After InstallServices: the virtual account NT SERVICE\SQLInsight Agent only exists once the service does. -->
<Custom Action="SetDataFolderAcl" Sequence="5804">&amp;Agent=3</Custom>
<Custom Action="CreateUpdateTask" Sequence="5801">&amp;UpdateTask=3</Custom>
<Custom Action="ReplaceFirewallRule" Sequence="5802">&amp;Web=3</Custom>
<Custom Action="OpenFirewall" Sequence="5803">&amp;Web=3</Custom>
</InstallExecuteSequence>
<Feature Id="Agent" Title="SQLInsight Agent" Description="Monitors the SQL Server instances." Level="1" Absent="allow">
<ComponentRef Id="AgentService" />
<ComponentRef Id="AgentSettings" />
<ComponentRef Id="AgentIni" />
<ComponentRef Id="AgentIniInstance" />
<ComponentRef Id="AgentServiceStart" />
<ComponentRef Id="EventSource" />
<ComponentRef Id="InstallInfo" />
<ComponentRef Id="DataFolder" />
<ComponentGroupRef Id="AgentFiles" />
<Feature Id="UpdateTask" Title="Automatic updates" Description="Scheduled task that installs downloaded, verified updates." Level="1000" Absent="allow">
<ComponentRef Id="UpdateTaskMarker" />
<Condition Level="1">UPDATETASK_INSTALLED</Condition>
</Feature>
</Feature>
<Feature Id="Web" Title="SQLInsight Web" Description="The web interface (Windows authentication)." Level="1" Absent="allow">
<ComponentRef Id="WebService" />
<ComponentRef Id="WebSettings" />
<ComponentRef Id="WebIni" />
<ComponentRef Id="WebServiceStart" />
<ComponentGroupRef Id="WebFiles" />
</Feature>
<Icon Id="SQLInsight.ico" SourceFile="$(var.InstallerDir)/sqlinsight.ico" />
<Property Id="ARPPRODUCTICON" Value="SQLInsight.ico" />
<UIRef Id="SQLInsightUI" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.InstallerDir)/License.rtf" />
</Product>
</Wix>

42
installer/SettingsDlg.wxs Normal file
View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- First page of an interactive install: where the repository is, what to monitor first, and which accounts to use.
Everything can also be given on the command line (see SQLInsight.wxs); the fields then show those values. ASCII only. -->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Fragment>
<UI Id="SQLInsightUI">
<UIRef Id="WixUI_Minimal" />
<Dialog Id="SettingsDlg" Width="370" Height="270" Title="[ProductName] Setup">
<Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="WixUI_Bmp_Banner" />
<Control Id="Title" Type="Text" X="15" Y="6" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="{\WixUI_Font_Title}Settings" />
<Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="Where SQLInsight stores its data, and what to monitor first." />
<Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
<Control Id="RepositoryLabel" Type="Text" X="20" Y="52" Width="330" Height="10" NoPrefix="yes" Text="Repository database (connection string, SQL Server 2016 or later):" />
<Control Id="Repository" Type="Edit" X="20" Y="64" Width="330" Height="18" Property="REPOSITORY_CONNECTION" Text="{400}" />
<Control Id="RepositoryHint" Type="Text" X="20" Y="84" Width="330" Height="18" NoPrefix="yes" Text="Example: Server=sqlmon01;Database=SQLInsight;Integrated Security=true;Encrypt=true. Leave empty to set it later in sqlinsight.ini." />
<Control Id="ServerLabel" Type="Text" X="20" Y="108" Width="330" Height="10" NoPrefix="yes" Text="First SQL Server to monitor (optional, more can be added later):" />
<Control Id="Server" Type="Edit" X="20" Y="120" Width="330" Height="18" Property="MONITOR_SERVER" Text="{200}" />
<Control Id="AgentLabel" Type="Text" X="20" Y="146" Width="160" Height="10" NoPrefix="yes" Text="Agent service account:" />
<Control Id="Agent" Type="Edit" X="20" Y="158" Width="160" Height="18" Property="AGENT_ACCOUNT" Text="{200}" />
<Control Id="WebLabel" Type="Text" X="190" Y="146" Width="110" Height="10" NoPrefix="yes" Text="Web service account:" />
<Control Id="Web" Type="Edit" X="190" Y="158" Width="110" Height="18" Property="WEB_ACCOUNT" Text="{200}" />
<Control Id="PortLabel" Type="Text" X="310" Y="146" Width="40" Height="10" NoPrefix="yes" Text="Port:" />
<Control Id="Port" Type="Edit" X="310" Y="158" Width="40" Height="18" Property="WEB_PORT" Text="{5}" />
<Control Id="AccountHint" Type="Text" X="20" Y="180" Width="330" Height="30" NoPrefix="yes" Text="NT SERVICE\... accounts reach other servers as this computer (DOMAIN\SERVER$$). For a group managed service account, enter DOMAIN\name$$. Give the account the rights from db\permissions\grant-least-privilege.sql." />
<Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />
<Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="Next">
<Publish Event="EndDialog" Value="Return" />
</Control>
<Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="Cancel">
<Publish Event="SpawnDialog" Value="CancelDlg" />
</Control>
</Dialog>
<InstallUISequence>
<Show Dialog="SettingsDlg" Sequence="1297" Condition="NOT Installed" />
</InstallUISequence>
</UI>
</Fragment>
</Wix>

BIN
installer/banner.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

51
installer/build-msi.sh Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Builds dist/SQLInsight-Pro-<version>-x64.msi on Linux with wixl (apt install wixl).
# installer/build-msi.sh 1.2.0
# The programs are published self-contained for win-x64, so the servers need no .NET runtime.
set -euo pipefail
VERSION="${1:-1.0.0}"
# Windows Installer only knows numeric versions (major.minor.build): 1.3.0-preview.1 is installed as 1.3.0.
MSI_VERSION="${VERSION%%[-+]*}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/artifacts/msi"
PUBLISH="$OUT/publish"
DIST="$ROOT/dist"
command -v wixl >/dev/null || { echo "wixl mangler: apt install wixl" >&2; exit 1; }
# wixl writes a codepage-neutral database, where only ASCII is safe: keep installer texts in English/ASCII.
if LC_ALL=C grep -n '[^ -~ ]' "$ROOT/installer/SQLInsight.wxs" "$ROOT/installer/SettingsDlg.wxs"; then
echo "installer/SQLInsight.wxs må kun indeholde ASCII (se linjerne ovenfor)" >&2; exit 1
fi
rm -rf "$OUT" && mkdir -p "$PUBLISH" "$DIST"
for app in Agent Web; do
dotnet publish "$ROOT/src/SQLInsight.$app" -c Release -r win-x64 --self-contained true \
-p:Version="$VERSION" -p:DebugType=none -p:GenerateDocumentationFile=false -o "$PUBLISH/$app" --nologo -v quiet
done
# One component per file. The exe (service key path) and appsettings.json (kept on upgrade) are authored by hand.
harvest() {
local app="$1" dir="$2" group="$3"
(cd "$PUBLISH" && find "$app" -type f \
! -name "SQLInsight.$app.exe" ! -name "appsettings.json" ! -name "appsettings.*.json" | sort) \
| wixl-heat --prefix "$app/" --directory-ref "$dir" --component-group "$group" --var "var.${app}Dir" --win64 \
| sed -E "s/Id=\"(cmp|fil|dir)/Id=\"\1${app}/g" \
> "$OUT/$app.wxs"
}
harvest Agent AGENTDIR AgentFiles
harvest Web WEBDIR WebFiles
# wixl cannot parse component conditions; they (and Permanent/NeverOverwrite) are written into the MSI afterwards.
python3 "$ROOT/installer/msi-tables.py" strip "$ROOT/installer/SQLInsight.wxs" "$OUT/SQLInsight.wxs"
MSI="$DIST/SQLInsight-Pro-$VERSION-x64.msi"
cd "$ROOT/installer"
wixl --arch x64 --ext ui \
-D Win64=yes -D Version="$MSI_VERSION" -D AgentDir="$PUBLISH/Agent" -D WebDir="$PUBLISH/Web" -D InstallerDir="$ROOT/installer" \
-o "$MSI" "$OUT/SQLInsight.wxs" "$ROOT/installer/SettingsDlg.wxs" "$OUT/Agent.wxs" "$OUT/Web.wxs" 2> >(grep -v "has no property named" >&2)
python3 "$ROOT/installer/msi-tables.py" apply "$ROOT/installer/SQLInsight.wxs" "$MSI"
# SQLInsight artwork instead of the default dialog pictures.
msibuild "$MSI" -a Binary.WixUI_Bmp_Dialog "$ROOT/installer/dialog.bmp" -a Binary.WixUI_Bmp_Banner "$ROOT/installer/banner.bmp"
python3 "$ROOT/installer/verify-msi.py" "$MSI"
echo "$MSI"

BIN
installer/dialog.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

102
installer/msi-tables.py Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Bridges what wixl does not support, so installer/SQLInsight.wxs stays the single source (WiX 3 syntax).
msi-tables.py strip <in.wxs> <out.wxs> Removes Component and Feature conditions and WixVariable, which wixl cannot parse.
msi-tables.py apply <in.wxs> <file.msi> Writes them, Permanent/NeverOverwrite and ServiceInstall/@Vital into the MSI.
"""
import os
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
NS = "{http://schemas.microsoft.com/wix/2006/wi}"
PERMANENT = 0x10
ERROR_CONTROL_VITAL = 0x8000
NEVER_OVERWRITE = 0x80
def components(wxs):
tree = ET.parse(wxs)
for component in tree.iter(NS + "Component"):
condition = component.find(NS + "Condition")
yield tree, component, (condition.text or "").strip() if condition is not None else None, condition
def strip(source, target):
tree = ET.parse(source)
ET.register_namespace("", NS[1:-1])
for feature in tree.iter(NS + "Feature"):
for condition in feature.findall(NS + "Condition"):
feature.remove(condition)
for component in tree.iter(NS + "Component"):
for condition in component.findall(NS + "Condition"):
component.remove(condition)
# WixVariable (license text for WiX 3's UI) is not known to wixl, which reads License.rtf from the working folder.
for parent in tree.iter():
for variable in parent.findall(NS + "WixVariable"):
parent.remove(variable)
tree.write(target, encoding="utf-8", xml_declaration=True)
def dump_table(msi, name):
with tempfile.TemporaryDirectory() as folder:
subprocess.run(["msidump", "-t", "-d", folder, os.path.abspath(msi)], check=True, capture_output=True, cwd=folder)
with open(os.path.join(folder, f"{name}.idt"), encoding="utf-8", errors="replace") as idt:
lines = idt.read().splitlines()
header = lines[0].split("\t")
return {row.split("\t")[0]: dict(zip(header, row.split("\t"))) for row in lines[3:] if row}
def tree_of(wxs):
return ET.parse(wxs)
def apply(source, msi):
table = dump_table(msi, "Component")
queries = []
for _, component, condition, _ in components(source):
cid = component.get("Id")
if cid not in table:
sys.exit(f"Komponenten {cid} findes ikke i {msi}")
attributes = int(table[cid]["Attributes"] or 0)
if component.get("Permanent") == "yes":
attributes |= PERMANENT
if component.get("NeverOverwrite") == "yes":
attributes |= NEVER_OVERWRITE
sets = [f"`Attributes` = {attributes}"]
if condition:
if "'" in condition:
sys.exit(f"Betingelsen for {cid} må ikke indeholde apostroffer")
sets.append(f"`Condition` = '{condition}'")
if attributes != int(table[cid]["Attributes"] or 0) or condition:
queries.append(f"UPDATE `Component` SET {', '.join(sets)} WHERE `Component` = '{cid}'")
# RegistryValue/@Type="expandable" becomes REG_EXPAND_SZ only when the stored value starts with "#%".
registry = dump_table(msi, "Registry")
for value in tree_of(source).iter(NS + "RegistryValue"):
if value.get("Type") == "expandable":
rows = [r for r in registry.values() if r["Name"] == value.get("Name") and r["Value"] == value.get("Value")]
if len(rows) != 1:
sys.exit(f"Registry-værdien {value.get('Name')} blev ikke fundet i {msi}")
queries.append(f"UPDATE `Registry` SET `Value` = '#%{value.get('Value')}' WHERE `Registry` = '{rows[0]['Registry']}'")
# Feature/Condition: the Condition table, which wixl does not write.
feature_conditions = [(f.get("Id"), c.get("Level"), (c.text or "").strip())
for f in tree_of(source).iter(NS + "Feature") for c in f.findall(NS + "Condition")]
if feature_conditions:
queries.append("CREATE TABLE `Condition` (`Feature_` CHAR(38) NOT NULL, `Level` SHORT NOT NULL, `Condition` CHAR(255) PRIMARY KEY `Feature_`, `Level`)")
for feature, level, condition in feature_conditions:
queries.append(f"INSERT INTO `Condition` (`Feature_`, `Level`, `Condition`) VALUES ('{feature}', {int(level)}, '{condition}')")
# ServiceInstall/@Vital: a service that cannot be installed (wrong account …) must fail the installation.
for service in tree_of(source).iter(NS + "ServiceInstall"):
if service.get("Vital") == "yes":
control = {"ignore": 0, "normal": 1, "critical": 3}[service.get("ErrorControl", "normal")]
queries.append(f"UPDATE `ServiceInstall` SET `ErrorControl` = {control | ERROR_CONTROL_VITAL} WHERE `ServiceInstall` = '{service.get('Id')}'")
for query in queries:
subprocess.run(["msibuild", msi, "-q", query], check=True)
print(f"{len(queries)} rækker opdateret")
if __name__ == "__main__":
{"strip": strip, "apply": apply}[sys.argv[1]](sys.argv[2], sys.argv[3])

BIN
installer/sqlinsight.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

45
installer/test-msi-wine.sh Executable file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Smoke test of the MSI under Wine: install, upgrade, uninstall. Not a replacement for a test on Windows (Wine does
# not start the services for real, and some Windows Installer details differ), but it catches most authoring mistakes.
# installer/test-msi-wine.sh dist/SQLInsight-Pro-1.0.1-x64.msi dist/SQLInsight-Pro-1.0.2-x64.msi
set -uo pipefail
OLD="$1"; NEW="$2"
export WINEPREFIX="${WINEPREFIX:-$(mktemp -d)/wine}" WINEARCH=win64 WINEDEBUG=-all
P="$WINEPREFIX/drive_c/Program Files/SQLInsight"
failed=0
check() { if eval "$2"; then echo "OK $1"; else echo "FEJL $1"; failed=1; fi; }
msi() { for attempt in 1 2 3; do timeout 600 wine msiexec "$@" 2>/dev/null; rc=$?; [ $rc = 0 ] && return 0; sleep 2; done; return $rc; }
reg() { wine reg query "$@" 2>/dev/null; }
wineboot -i >/dev/null 2>&1
wineserver -w
msi /i "$OLD" /qn REPOSITORY_CONNECTION="Server=sqlmon01;Database=SQLInsight;Integrated Security=true" MONITOR_SERVER=sql01 ADDLOCAL=ALL
check "installation" "[ $? = 0 ]"
check "sqlinsight.ini til agenten" "grep -q 'ConnectionString=Server=sqlmon01' '$P/Agent/sqlinsight.ini' && grep -q 'Server=sql01' '$P/Agent/sqlinsight.ini'"
check "sqlinsight.ini til web med port" "grep -q 'Url=http://\*:5080' '$P/Web/sqlinsight.ini'"
check "tjenesten SQLInsight Agent som virtuel konto" "reg 'HKLM\\SYSTEM\\CurrentControlSet\\Services\\SQLInsight Agent' | grep -q 'NT SERVICE\\\\SQLInsight Agent'"
check "tjenesten SQLInsight Web" "reg 'HKLM\\SYSTEM\\CurrentControlSet\\Services\\SQLInsight Web' | grep -q ImagePath"
check "hændelseslog-kilde (REG_EXPAND_SZ)" "reg 'HKLM\\SYSTEM\\CurrentControlSet\\Services\\EventLog\\Application\\SQLInsight Pro' | grep -q 'REG_EXPAND_SZ'"
check "markeret som MSI-installation (opdateringer via MSI)" "reg 'HKLM\\SOFTWARE\\SEOSoft\\SQLInsight' | grep -q 'Installer REG_SZ msi'"
check "opdateringsopgave valgt" "reg 'HKLM\\SOFTWARE\\SEOSoft\\SQLInsight' | grep -q UpdateTask"
sed -i 's/"MaxParallelInstances": 4/"MaxParallelInstances": 7/' "$P/Agent/appsettings.json"
printf '[SQLInsight:Instances:1]\nName=manuel\nServer=sql02\n' >> "$P/Agent/sqlinsight.ini"
files_before=$(find "$P" -type f | wc -l)
msi /i "$NEW" /qn
check "opgradering" "[ $? = 0 ]"
check "ændret appsettings.json er bevaret" "grep -q '\"MaxParallelInstances\": 7' '$P/Agent/appsettings.json'"
check "ændret sqlinsight.ini er bevaret" "grep -q 'Server=sql02' '$P/Agent/sqlinsight.ini'"
entries=$(reg 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' /s | grep -c 'REG_SZ SQLInsight Pro')
check "én post under Programmer ($entries)" "[ $entries = 1 ]"
check "alle filer er der stadig" "[ \$(find '$P' -type f | wc -l) -ge $files_before ]"
check "opdateringsopgaven er bevaret" "reg 'HKLM\\SOFTWARE\\SEOSoft\\SQLInsight' | grep -q UpdateTask"
msi /x "$NEW" /qn
check "afinstallation" "[ $? = 0 ]"
check "tjenesterne er fjernet" "! reg 'HKLM\\SYSTEM\\CurrentControlSet\\Services\\SQLInsight Agent' >/dev/null"
check "kun konfigurationen er tilbage" "[ \$(find '$P' -type f | grep -vcE 'appsettings.json|sqlinsight.ini') = 0 ]"
check "ingen post under Programmer" "! reg 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' /s | grep -q 'REG_SZ SQLInsight Pro'"
exit $failed

94
installer/verify-msi.py Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Checks a built MSI for the mistakes that cannot be caught without Windows (run by build-msi.sh and CI).
verify-msi.py <file.msi>
"""
import os
import subprocess
import sys
import tempfile
DEFERRED = 0x400
IN_SCRIPT_NO_IMPERSONATE = 0x800
def tables(msi):
with tempfile.TemporaryDirectory() as folder:
subprocess.run(["msidump", "-t", "-d", folder, os.path.abspath(msi)], check=True, capture_output=True, cwd=folder)
result = {}
for name in (n for n in os.listdir(folder) if n.endswith(".idt")):
with open(os.path.join(folder, name), "rb") as f:
raw = f.read()
lines = raw.decode("utf-8", errors="replace").splitlines()
header = lines[0].split("\t")
result[name[:-4]] = ([dict(zip(header, row.split("\t"))) for row in lines[3:] if row], raw)
return result
def main(msi):
t = tables(msi)
errors = []
def rows(name):
return t.get(name, ([], b""))[0]
for name, (_, raw) in t.items():
if any(b > 127 for b in raw):
errors.append(f"{name}: indeholder ikke-ASCII-tegn (databasen er codepage-neutral)")
sequence = {r["Action"]: int(r["Sequence"]) for r in rows("InstallExecuteSequence")}
actions = {r["Action"]: int(r["Type"]) for r in rows("CustomAction")}
start, end = sequence["InstallInitialize"], sequence["InstallFinalize"]
for action, kind in actions.items():
if kind & 0x3F == 50 and not kind & DEFERRED:
errors.append(f"{action}: kører et program uden at være deferred (ville køre som brugeren, uden rollback)")
if action not in sequence:
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:
errors.append(f"{action}: deferred uden NoImpersonate (kører ikke som SYSTEM)")
def after(action, standard):
if sequence.get(action, 0) <= sequence[standard]:
errors.append(f"{action} skal ligge efter {standard}")
after("SetDataFolderAcl", "InstallServices") # the virtual service account must exist
after("CreateUpdateTask", "InstallFiles")
after("OpenFirewall", "ReplaceFirewallRule")
after("RemoveExistingProducts", "InstallExecute") # upgrade in place: settings files are kept
for action in ("DeleteUpdateTask", "CloseFirewall"):
condition = next(r["Condition"] for r in rows("InstallExecuteSequence") if r["Action"] == action)
if "UPGRADINGPRODUCTCODE" not in condition:
errors.append(f"{action}: skal ikke køre, når en opgradering fjerner den gamle version")
components = {r["Component"]: r for r in rows("Component")}
for name, bits in (("AgentSettings", 0x90), ("WebSettings", 0x90), ("AgentIni", 0x10), ("WebIni", 0x10)):
if int(components[name]["Attributes"]) & bits != bits:
errors.append(f"{name}: mangler Permanent/NeverOverwrite")
if not components["AgentServiceStart"]["Condition"]:
errors.append("AgentServiceStart: mangler betingelse")
if any(int(r["Attributes"]) & 0x100 == 0 for r in components.values()):
errors.append("Komponenter uden 64-bit-attribut")
for service in rows("ServiceInstall"):
if int(service["ErrorControl"]) & 0x8000 == 0:
errors.append(f"{service['Name']}: tjenesten er ikke Vital")
if not any(r["Feature_"] == "UpdateTask" for r in rows("Condition")):
errors.append("UpdateTask: mangler betingelsen, der bevarer opgaven ved opgradering")
if not any(r["Value"].startswith("#%") for r in rows("Registry") if r["Name"] == "EventMessageFile"):
errors.append("EventMessageFile er ikke REG_EXPAND_SZ")
if not any(r["Name"] == "Installer" and r["Value"] == "msi" for r in rows("Registry")):
errors.append("Registreringen Installer=msi mangler (opdateringer ville installere zip-pakker oven i MSI'en)")
if not any(int(r["Attributes"]) & 1 for r in rows("Upgrade") if r["ActionProperty"] == "OLDVERSIONFOUND"):
errors.append("Upgrade: MigrateFeatures mangler")
for error in errors:
print("FEJL:", error)
print(f"{os.path.basename(msi)}: {len(components)} komponenter, {len(rows('File'))} filer, {len(errors)} fejl")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))

View file

@ -105,6 +105,7 @@ public static class RuleCommands
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory) .SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.json", optional: true)
.AddIniFile(Path.Combine(AppContext.BaseDirectory, InstallerSettings.FileName), optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true) .AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables() .AddEnvironmentVariables()
.Build(); .Build();

View file

@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration.Ini;
using Microsoft.Extensions.Configuration.Json;
namespace SQLInsight.Agent;
/// <summary>
/// sqlinsight.ini, written by the MSI installer from the values given at install time (connection string, port …).
/// It is read right after appsettings.json, so it overrides the examples there, while appsettings.{Environment}.json,
/// environment variables and the command line still override it.
/// </summary>
public static class InstallerSettings
{
public const string FileName = "sqlinsight.ini";
public static void Add(IConfigurationBuilder configuration, string directory)
{
ArgumentNullException.ThrowIfNull(configuration);
var source = new IniConfigurationSource { Path = Path.Combine(directory, FileName), Optional = true, ReloadOnChange = false };
source.ResolveFileProvider();
var appsettings = configuration.Sources
.Select((s, i) => (Source: s, Index: i))
.LastOrDefault(x => x.Source is JsonConfigurationSource json && string.Equals(json.Path, "appsettings.json", StringComparison.OrdinalIgnoreCase));
if (appsettings.Source is null)
{
configuration.Add(source);
}
else
{
configuration.Sources.Insert(appsettings.Index + 1, source);
}
}
}

View file

@ -8,6 +8,12 @@ using SQLInsight.Repository;
using SQLInsight.Rules; using SQLInsight.Rules;
using SQLInsight.Rules.Engine; using SQLInsight.Rules.Engine;
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args) || UpdateCommands.Handles(args))
{
// Danish letters in a Windows console, which otherwise uses the OEM code page.
Console.OutputEncoding = System.Text.Encoding.UTF8;
}
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args)) if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args))
{ {
return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out); return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out);
@ -19,6 +25,7 @@ if (UpdateCommands.Handles(args))
} }
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
InstallerSettings.Add(builder.Configuration, AppContext.BaseDirectory);
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent"); builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent");
builder.Services.AddSystemd(); builder.Services.AddSystemd();

View file

@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" /> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" /> <PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -202,6 +202,7 @@ public static class UpdateCommands
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.SetBasePath(agentFolder) .SetBasePath(agentFolder)
.AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.json", optional: true)
.AddIniFile(Path.Combine(agentFolder, InstallerSettings.FileName), optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true) .AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables() .AddEnvironmentVariables()
.Build(); .Build();

View file

@ -53,14 +53,8 @@
"ResolvedFindingDays": 365 "ResolvedFindingDays": 365
}, },
"Instances": [ "Instances": [
{ // Eksempel (se docs/installation.md):
"Name": "eksempel-sql01", // { "Name": "prod-sql01", "Server": "sql01.domæne.local", "Authentication": "Windows", "Provider": "Auto", "Encryption": "Mandatory" }
"Server": "sql01.eksempel.local",
"Authentication": "Windows",
"Provider": "Auto",
"Encryption": "Mandatory",
"TrustServerCertificate": false
}
] ]
} }
} }

View file

@ -58,9 +58,28 @@ public static class ManifestVerifier
public static class Platform public static class Platform
{ {
public const string MsiSuffix = "-msi";
/// <summary>The package platform for this machine, e.g. win-x64 or linux-arm64.</summary> /// <summary>The package platform for this machine, e.g. win-x64 or linux-arm64.</summary>
public static string Current => public static string Current =>
(OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsLinux() ? "linux" : "other") + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); (OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsLinux() ? "linux" : "other") + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
/// <summary>
/// The package this installation takes: win-x64-msi when SQLInsight Pro was installed with the MSI (so Windows
/// Installer stays in charge of the files, and a later repair or upgrade never undoes an update), otherwise <see cref="Current"/>.
/// </summary>
public static string Installed => Current + (InstalledWithMsi ? MsiSuffix : string.Empty);
/// <summary>The MSI writes HKLM\SOFTWARE\SEOSoft\SQLInsight\Installer = msi.</summary>
public static bool InstalledWithMsi => OperatingSystem.IsWindows() && ReadInstallerMarker() == "msi";
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static string? ReadInstallerMarker()
{
using var root = Microsoft.Win32.RegistryKey.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine, Microsoft.Win32.RegistryView.Registry64);
using var key = root.OpenSubKey(@"SOFTWARE\SEOSoft\SQLInsight");
return key?.GetValue("Installer") as string;
}
} }
public static class FeedPath public static class FeedPath

View file

@ -34,6 +34,12 @@ public sealed class UpdateApplierOptions
/// <summary>How long every restarted service must keep running before the update counts as good.</summary> /// <summary>How long every restarted service must keep running before the update counts as good.</summary>
public TimeSpan HealthCheckDuration { get; init; } = TimeSpan.FromMinutes(1); public TimeSpan HealthCheckDuration { get; init; } = TimeSpan.FromMinutes(1);
/// <summary>The package to take; default <see cref="Updates.Platform.Installed"/>.</summary>
public string Platform { get; init; } = Updates.Platform.Installed;
/// <summary>Runs msiexec for MSI packages (installations made with the MSI).</summary>
public IMsiInstaller Msi { get; init; } = new MsiExec();
/// <summary>Files kept from the installation, never overwritten by the package.</summary> /// <summary>Files kept from the installation, never overwritten by the package.</summary>
public IReadOnlyList<string> PreservedFiles { get; init; } = ["appsettings.json", "appsettings.*.json", "web.config"]; public IReadOnlyList<string> PreservedFiles { get; init; } = ["appsettings.json", "appsettings.*.json", "web.config"];
} }
@ -85,8 +91,8 @@ public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectorie
return Result(UpdateOutcome.NothingToDo, installed, null, $"Den ventende opdatering er ikke nyere end {installed}; den er fjernet."); return Result(UpdateOutcome.NothingToDo, installed, null, $"Den ventende opdatering er ikke nyere end {installed}; den er fjernet.");
} }
asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase)) asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, options.Platform, StringComparison.OrdinalIgnoreCase))
?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}."); ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {options.Platform}.");
// Copy first, then verify the copy: the pending folder is writable by the agent's account. // Copy first, then verify the copy: the pending folder is writable by the agent's account.
DeleteDirectory(options.PrivateRoot); DeleteDirectory(options.PrivateRoot);
@ -104,6 +110,11 @@ public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectorie
} }
var target = manifest.Release!.Version; var target = manifest.Release!.Version;
if (asset.File.Path.EndsWith(".msi", StringComparison.OrdinalIgnoreCase))
{
return await ApplyMsiAsync(installed, target, cancellationToken).ConfigureAwait(false);
}
var extracted = System.IO.Path.Combine(options.PrivateRoot, "files"); var extracted = System.IO.Path.Combine(options.PrivateRoot, "files");
ZipFile.ExtractToDirectory(PrivatePackage, extracted); ZipFile.ExtractToDirectory(PrivatePackage, extracted);
@ -170,6 +181,34 @@ public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectorie
return Result(UpdateOutcome.Applied, installed, target, $"Opdateret fra {installed} til {target}."); return Result(UpdateOutcome.Applied, installed, target, $"Opdateret fra {installed} til {target}.");
} }
/// <summary>
/// Installation made with the MSI: run the new MSI. Windows Installer stops and starts the services, keeps the
/// settings and rolls back by itself if anything fails.
/// </summary>
private async Task<UpdateResult> ApplyMsiAsync(AppVersion installed, string target, CancellationToken cancellationToken)
{
var msi = System.IO.Path.Combine(options.PrivateRoot, $"SQLInsight-Pro-{target}.msi");
File.Move(PrivatePackage, msi);
var logFile = System.IO.Path.Combine(options.PrivateRoot, "..", "msi-install.log");
log($"Installerer {System.IO.Path.GetFileName(msi)} (fra {installed}); log: {System.IO.Path.GetFullPath(logFile)}.");
var exitCode = await options.Msi.InstallAsync(msi, logFile, cancellationToken).ConfigureAwait(false);
DiscardPending();
if (exitCode is not (0 or 3010 or 1641))
{
return Result(UpdateOutcome.RolledBack, installed, target,
$"Windows Installer kunne ikke installere {target} (fejlkode {exitCode}) og har rullet tilbage til {installed}. Se {System.IO.Path.GetFullPath(logFile)}.");
}
if (await UnhealthyAsync(options.Components, cancellationToken).ConfigureAwait(false) is { } failed)
{
return Result(UpdateOutcome.Failed, installed, target,
$"{target} er installeret, men {failed} kører ikke. Se hændelsesloggen; den tidligere version kan geninstalleres med dens MSI.");
}
return Result(UpdateOutcome.Applied, installed, target, $"Opdateret fra {installed} til {target} med Windows Installer."
+ (exitCode == 0 ? string.Empty : " Windows anbefaler en genstart af serveren."));
}
private async Task StopAsync(IEnumerable<UpdateComponent> components, CancellationToken cancellationToken) private async Task StopAsync(IEnumerable<UpdateComponent> components, CancellationToken cancellationToken)
{ {
foreach (var component in components) foreach (var component in components)
@ -267,3 +306,25 @@ public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectorie
} }
} }
} }
/// <summary>Installs an MSI silently and returns msiexec's exit code.</summary>
public interface IMsiInstaller
{
Task<int> InstallAsync(string msiPath, string logPath, CancellationToken cancellationToken);
}
public sealed class MsiExec : IMsiInstaller
{
public async Task<int> InstallAsync(string msiPath, string logPath, CancellationToken cancellationToken)
{
var start = new System.Diagnostics.ProcessStartInfo(System.IO.Path.Combine(Environment.SystemDirectory, "msiexec.exe")) { UseShellExecute = false };
foreach (var arg in new[] { "/i", msiPath, "/qn", "/norestart", "/l*v", logPath })
{
start.ArgumentList.Add(arg);
}
using var process = System.Diagnostics.Process.Start(start) ?? throw new UpdateException("msiexec kunne ikke startes.");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
return process.ExitCode;
}
}

View file

@ -10,8 +10,11 @@ public sealed record CheckedManifest(UpdateManifest Manifest, string Token);
/// The unprivileged half of updating, run by the agent: reads the signed manifest, installs newer rule packs, and /// The unprivileged half of updating, run by the agent: reads the signed manifest, installs newer rule packs, and
/// downloads and stages application packages. It never replaces program files; <see cref="UpdateApplier"/> does that. /// downloads and stages application packages. It never replaces program files; <see cref="UpdateApplier"/> does that.
/// </summary> /// </summary>
public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirectories directories, string channel, TimeProvider time) /// <param name="platform">The package to take; default <see cref="Platform.Installed"/>.</param>
public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirectories directories, string channel, TimeProvider time, string? platform = null)
{ {
private readonly string _platform = platform ?? Platform.Installed;
public string Channel { get; } = channel; public string Channel { get; } = channel;
public async Task<CheckedManifest> FetchAsync(CancellationToken cancellationToken) public async Task<CheckedManifest> FetchAsync(CancellationToken cancellationToken)
@ -71,8 +74,8 @@ public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirec
return false; return false;
} }
var asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase)) var asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, _platform, StringComparison.OrdinalIgnoreCase))
?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}."); ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {_platform}.");
var package = Path.Combine(directories.Pending, "package.zip"); var package = Path.Combine(directories.Pending, "package.zip");
var manifestFile = Path.Combine(directories.Pending, UpdateFeed.ManifestFile); var manifestFile = Path.Combine(directories.Pending, UpdateFeed.ManifestFile);

View file

@ -0,0 +1,32 @@
using Microsoft.Extensions.Configuration.Ini;
using Microsoft.Extensions.Configuration.Json;
namespace SQLInsight.Web;
/// <summary>
/// sqlinsight.ini, written by the MSI installer from the values given at install time (connection string, port …).
/// It is read right after appsettings.json, so it overrides the examples there, while appsettings.{Environment}.json,
/// environment variables and the command line still override it.
/// </summary>
public static class InstallerSettings
{
public const string FileName = "sqlinsight.ini";
public static void Add(IConfigurationBuilder configuration, string directory)
{
ArgumentNullException.ThrowIfNull(configuration);
var source = new IniConfigurationSource { Path = Path.Combine(directory, FileName), Optional = true, ReloadOnChange = false };
source.ResolveFileProvider();
var appsettings = configuration.Sources
.Select((s, i) => (Source: s, Index: i))
.LastOrDefault(x => x.Source is JsonConfigurationSource json && string.Equals(json.Path, "appsettings.json", StringComparison.OrdinalIgnoreCase));
if (appsettings.Source is null)
{
configuration.Add(source);
}
else
{
configuration.Sources.Insert(appsettings.Index + 1, source);
}
}
}

View file

@ -7,6 +7,7 @@ using SQLInsight.Web.Components;
if (args.FirstOrDefault() == "new-api-token") if (args.FirstOrDefault() == "new-api-token")
{ {
Console.OutputEncoding = System.Text.Encoding.UTF8;
var (token, hash) = ApiTokenHandler.Create(); var (token, hash) = ApiTokenHandler.Create();
var name = args.ElementAtOrDefault(1) ?? "prometheus"; var name = args.ElementAtOrDefault(1) ?? "prometheus";
Console.WriteLine($"Token (vises kun nu, gem det i det system, der skal hente data): {token}"); Console.WriteLine($"Token (vises kun nu, gem det i det system, der skal hente data): {token}");
@ -15,6 +16,7 @@ if (args.FirstOrDefault() == "new-api-token")
} }
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
InstallerSettings.Add(builder.Configuration, AppContext.BaseDirectory);
// Runs as a Windows service with its own Kestrel web server, or behind IIS (ASP.NET Core Module), or // Runs as a Windows service with its own Kestrel web server, or behind IIS (ASP.NET Core Module), or
// as a systemd service on Linux. Each call is a no-op when not hosted that way. // as a systemd service on Linux. Each call is a no-op when not hosted that way.

View file

@ -0,0 +1,62 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace SQLInsight.Agent.Tests;
public sealed class InstallerSettingsTests : IDisposable
{
private readonly string _folder = Path.Combine(Path.GetTempPath(), "sqlinsight-ini-" + Guid.NewGuid().ToString("N"));
public InstallerSettingsTests()
{
Directory.CreateDirectory(_folder);
File.WriteAllText(Path.Combine(_folder, "appsettings.json"), """
{ "SQLInsight": { "Repository": { "ConnectionString": "eksempel" }, "Tick": "00:00:15", "MaxParallelInstances": 4 } }
""");
File.WriteAllText(Path.Combine(_folder, InstallerSettings.FileName), """
[SQLInsight:Repository]
ConnectionString=Server=sqlmon01;Database=SQLInsight;Integrated Security=true
[SQLInsight:Instances:0]
Name=sql01
Server=sql01
[SQLInsight]
Tick=00:00:30
""");
}
public void Dispose() => Directory.Delete(_folder, recursive: true);
private HostApplicationBuilder Builder(params string[] args)
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { ContentRootPath = _folder, Args = args, EnvironmentName = "Production" });
InstallerSettings.Add(builder.Configuration, _folder);
return builder;
}
[Fact]
public void Installer_values_override_the_examples_in_appsettings_json()
{
var options = Builder().Configuration.GetSection(AgentOptions.SectionName).Get<AgentOptions>()!;
Assert.Equal("Server=sqlmon01;Database=SQLInsight;Integrated Security=true", options.Repository.ConnectionString);
Assert.Equal("sql01", Assert.Single(options.Instances).Server);
Assert.Equal(TimeSpan.FromSeconds(30), options.Tick);
Assert.Equal(4, options.MaxParallelInstances);
}
[Fact]
public void Command_line_still_overrides_the_installer_values()
{
var configuration = Builder("--SQLInsight:Repository:ConnectionString=fra-kommandolinjen").Configuration;
Assert.Equal("fra-kommandolinjen", configuration["SQLInsight:Repository:ConnectionString"]);
}
[Fact]
public void Missing_file_is_fine()
{
File.Delete(Path.Combine(_folder, InstallerSettings.FileName));
Assert.Equal("eksempel", Builder().Configuration["SQLInsight:Repository:ConnectionString"]);
}
}

View file

@ -84,6 +84,18 @@ public sealed class TestFeed : IDisposable
}; };
} }
/// <summary>A release published as an MSI for installations made with the MSI.</summary>
public AppRelease MsiRelease(string version) => new()
{
Version = version,
Released = new DateOnly(2026, 10, 1),
Assets =
[
new ReleaseAsset("win-x64", AddFile($"releases/sqlinsight-{version}-win-x64.zip", [1, 2, 3])),
new ReleaseAsset("win-x64-msi", AddFile($"releases/SQLInsight-Pro-{version}-x64.msi", [0xD0, 0xCF, 0x11, 0xE0, 4, 5, 6])),
],
};
public void Install(string program = "v1") public void Install(string program = "v1")
{ {
Directory.CreateDirectory(AgentFolder); Directory.CreateDirectory(AgentFolder);
@ -91,7 +103,7 @@ public sealed class TestFeed : IDisposable
File.WriteAllText(Path.Combine(AgentFolder, "appsettings.json"), "{ \"from\": \"customer\" }"); File.WriteAllText(Path.Combine(AgentFolder, "appsettings.json"), "{ \"from\": \"customer\" }");
} }
public UpdateChecker Checker(string channel = "stable") => new(Feed, Keys, Directories, channel, TimeProvider.System); public UpdateChecker Checker(string channel = "stable", string? platform = null) => new(Feed, Keys, Directories, channel, TimeProvider.System, platform ?? Platform.Current);
public void Dispose() public void Dispose()
{ {
@ -142,3 +154,14 @@ public sealed class FakeServices(string programFile) : IServiceControl
public Task<bool> IsRunningAsync(string service, CancellationToken cancellationToken) => public Task<bool> IsRunningAsync(string service, CancellationToken cancellationToken) =>
Task.FromResult(File.ReadAllText(programFile) != "broken"); Task.FromResult(File.ReadAllText(programFile) != "broken");
} }
public sealed class FakeMsi(int exitCode) : IMsiInstaller
{
public List<(string Msi, byte[] Content)> Installed { get; } = [];
public Task<int> InstallAsync(string msiPath, string logPath, CancellationToken cancellationToken)
{
Installed.Add((msiPath, File.ReadAllBytes(msiPath)));
return Task.FromResult(exitCode);
}
}

View file

@ -10,10 +10,12 @@ public sealed class UpdateTests : IDisposable
public void Dispose() => _feed.Dispose(); public void Dispose() => _feed.Dispose();
private UpdateApplier Applier(FakeServices services) => new( private UpdateApplier Applier(FakeServices services, string? platform = null, IMsiInstaller? msi = null) => new(
new UpdateApplierOptions new UpdateApplierOptions
{ {
Channel = "stable", Channel = "stable",
Platform = platform ?? Platform.Current,
Msi = msi ?? new FakeMsi(0),
PrivateRoot = Path.Combine(_feed.Root, "private"), PrivateRoot = Path.Combine(_feed.Root, "private"),
Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "SQLInsight Agent" }], Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "SQLInsight Agent" }],
HealthCheckDuration = TimeSpan.FromMilliseconds(50), HealthCheckDuration = TimeSpan.FromMilliseconds(50),
@ -226,7 +228,7 @@ public sealed class UpdateTests : IDisposable
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None); await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll")); var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
var applier = new UpdateApplier( var applier = new UpdateApplier(
new UpdateApplierOptions { Channel = "stable", PrivateRoot = Path.Combine(_feed.Root, "private"), Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "a" }] }, new UpdateApplierOptions { Channel = "stable", Platform = Platform.Current, PrivateRoot = Path.Combine(_feed.Root, "private"), Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "a" }] },
_feed.Directories, new TrustedKeys([]), services, TimeProvider.System, _ => { }); _feed.Directories, new TrustedKeys([]), services, TimeProvider.System, _ => { });
var result = await applier.ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None); var result = await applier.ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
@ -256,4 +258,43 @@ public sealed class UpdateTests : IDisposable
Assert.Contains(Platform.Current, error.Message, StringComparison.Ordinal); Assert.Contains(Platform.Current, error.Message, StringComparison.Ordinal);
} }
[Theory]
[InlineData(0, UpdateOutcome.Applied)]
[InlineData(3010, UpdateOutcome.Applied)]
[InlineData(1603, UpdateOutcome.RolledBack)]
public async Task Msi_installations_are_updated_with_the_new_msi(int exitCode, UpdateOutcome expected)
{
_feed.Install();
_feed.Publish(release: _feed.MsiRelease("1.1.0"));
var checker = _feed.Checker(platform: "win-x64-msi");
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
var msi = new FakeMsi(exitCode);
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
var result = await Applier(services, "win-x64-msi", msi).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
Assert.Equal(expected, result.Outcome);
var (path, content) = Assert.Single(msi.Installed);
Assert.EndsWith("SQLInsight-Pro-1.1.0.msi", path, StringComparison.Ordinal);
Assert.Equal([0xD0, 0xCF, 0x11, 0xE0, 4, 5, 6], content);
Assert.Empty(services.Calls); // Windows Installer stops and starts the services itself
Assert.Equal("v1", AgentFile("SQLInsight.Agent.dll"));
Assert.False(Directory.Exists(_feed.Directories.Pending));
}
[Fact]
public async Task Msi_package_changed_after_staging_is_not_installed()
{
_feed.Publish(release: _feed.MsiRelease("1.1.0"));
var checker = _feed.Checker(platform: "win-x64-msi");
await checker.StageAsync(await checker.FetchAsync(CancellationToken.None), AppVersion.Parse("1.0.0"), CancellationToken.None);
File.AppendAllText(Path.Combine(_feed.Directories.Pending, "package.zip"), "x");
var msi = new FakeMsi(0);
var result = await Applier(new FakeServices(Path.Combine(_feed.Root, "none")), "win-x64-msi", msi).ApplyAsync(AppVersion.Parse("1.0.0"), CancellationToken.None);
Assert.Equal(UpdateOutcome.Failed, result.Outcome);
Assert.Empty(msi.Installed);
}
} }

View file

@ -30,7 +30,7 @@ int Usage()
Byg regelpakke af katalogfilerne Byg regelpakke af katalogfilerne
publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90] publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90]
[--rulepack <pack.json>] [--rulepack <pack.json>]
[--release <version> --asset win-x64=<zip> [--asset linux-x64=<zip>] [--notes <url>] [--security]] [--release <version> --asset win-x64=<zip> [--asset win-x64-msi=<msi>] [--asset linux-x64=<zip>] [--notes <url>] [--security]]
Signér kanalens manifest; det, der ikke angives, Signér kanalens manifest; det, der ikke angives,
overtages fra det eksisterende manifest overtages fra det eksisterende manifest
"""); """);
@ -132,7 +132,7 @@ int Publish()
{ {
if (args[i] == "--asset" && args[i + 1].Split('=', 2) is [var platform, var file]) if (args[i] == "--asset" && args[i + 1].Split('=', 2) is [var platform, var file])
{ {
assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform}.zip", File.ReadAllBytes(file)))); assets.Add(new ReleaseAsset(platform, AddFile(feed, "releases", $"sqlinsight-{version}-{platform}{Path.GetExtension(file).ToLowerInvariant()}", File.ReadAllBytes(file))));
} }
} }