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
|
|
@ -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
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
BIN
installer/Binary/Binary.WixUI_Bmp_Banner
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
installer/Binary/Binary.WixUI_Bmp_Dialog
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
installer/Binary/Binary.WixUI_Bmp_New
Normal file
|
After Width: | Height: | Size: 318 B |
BIN
installer/Binary/Binary.WixUI_Bmp_Up
Normal file
|
After Width: | Height: | Size: 318 B |
BIN
installer/Binary/Binary.WixUI_Ico_Exclam
Normal file
|
After Width: | Height: | Size: 766 B |
BIN
installer/Binary/Binary.WixUI_Ico_Info
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
7
installer/License.rtf
Normal 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
|
|
@ -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 >= 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=""[DATADIR]." /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F "[AGENT_ACCOUNT]:(OI)(CI)M"" />
|
||||||
|
|
||||||
|
<!-- 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="SQLInsight Web" 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="SQLInsight Web"" />
|
||||||
|
<CustomAction Id="CloseFirewall" Property="NETSH_EXE" Execute="deferred" Impersonate="no" Return="ignore"
|
||||||
|
ExeCommand="advfirewall firewall delete rule name="SQLInsight Web"" />
|
||||||
|
|
||||||
|
<!-- 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 "SQLInsight Update" /SC HOURLY /RU SYSTEM /RL HIGHEST /TR "\"[AGENTDIR]SQLInsight.Agent.exe\" apply-update"" />
|
||||||
|
<CustomAction Id="DeleteUpdateTask" Property="SCHTASKS_EXE" Execute="deferred" Impersonate="no" Return="ignore"
|
||||||
|
ExeCommand="/Delete /F /TN "SQLInsight Update"" />
|
||||||
|
|
||||||
|
<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">&UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE</Custom>
|
||||||
|
<Custom Action="CloseFirewall" Sequence="3401">&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">&Agent=3</Custom>
|
||||||
|
<Custom Action="CreateUpdateTask" Sequence="5801">&UpdateTask=3</Custom>
|
||||||
|
<Custom Action="ReplaceFirewallRule" Sequence="5802">&Web=3</Custom>
|
||||||
|
<Custom Action="OpenFirewall" Sequence="5803">&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
|
|
@ -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
|
After Width: | Height: | Size: 84 KiB |
51
installer/build-msi.sh
Executable 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
|
After Width: | Height: | Size: 451 KiB |
102
installer/msi-tables.py
Executable 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
|
After Width: | Height: | Size: 30 KiB |
45
installer/test-msi-wine.sh
Executable 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
|
|
@ -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]))
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
32
src/SQLInsight.Agent/InstallerSettings.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
32
src/SQLInsight.Web/InstallerSettings.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
62
tests/SQLInsight.Agent.Tests/InstallerSettingsTests.cs
Normal 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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||