diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 43fae84..6126a96 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -3,6 +3,7 @@ name: CI
on:
push:
branches: [main]
+ tags: ["v*"]
pull_request:
env:
@@ -30,6 +31,33 @@ jobs:
- name: Unit tests
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|.*\(.*\).*|\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:
name: Integration (${{ matrix.image }})
needs: build
diff --git a/.gitignore b/.gitignore
index 9d07fa0..72b5a8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -483,3 +483,4 @@ $RECYCLE.BIN/
# SQLInsight license signing keys: never commit private keys
*.private.pem
+dist/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b9a197c..2332d02 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,6 +18,7 @@
+
diff --git a/README.md b/README.md
index e23636c..60501e3 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,7 @@ Hvert **finding** indeholder:
- [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] 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
- [x] Query Store: planregressioner (med script til at forcere den gode plan), forcerede planer der fejler, og parameterfølsomme forespørgsler
diff --git a/docs/installation.md b/docs/installation.md
index 460c193..edce41b 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -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.
+## Hurtigst: MSI-installeren
+
+`SQLInsight-Pro--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 ` (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
- 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
- Til SQL Server 2000: en Windows-agent med en ODBC-driver, der understøtter 2000 (se [legacy-versions.md](legacy-versions.md))
diff --git a/docs/opdatering.md b/docs/opdatering.md
index 7aa0772..8da7cee 100644
--- a/docs/opdatering.md
+++ b/docs/opdatering.md
@@ -49,6 +49,8 @@ Som administrator på agentens server:
& "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.
### 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
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/
-dotnet publish src/SQLInsight.Agent -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Agent
-dotnet publish src/SQLInsight.Web -c Release -r win-x64 --self-contained false -p:Version=1.1.0 -o pkg/win-x64/Web
+# Samme opsætning som MSI'en (indbygget .NET), så en opdatering ikke kræver en runtime på serveren
+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)
dotnet run --project tools/SQLInsight.LicenseTool -- publish --key sqlinsight-k20260929.private.pem --kid k20260929 \
--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
```
diff --git a/installer/Binary/Binary.WixUI_Bmp_Banner b/installer/Binary/Binary.WixUI_Bmp_Banner
new file mode 100644
index 0000000..5e42f74
Binary files /dev/null and b/installer/Binary/Binary.WixUI_Bmp_Banner differ
diff --git a/installer/Binary/Binary.WixUI_Bmp_Dialog b/installer/Binary/Binary.WixUI_Bmp_Dialog
new file mode 100644
index 0000000..7401db1
Binary files /dev/null and b/installer/Binary/Binary.WixUI_Bmp_Dialog differ
diff --git a/installer/Binary/Binary.WixUI_Bmp_New b/installer/Binary/Binary.WixUI_Bmp_New
new file mode 100644
index 0000000..27881df
Binary files /dev/null and b/installer/Binary/Binary.WixUI_Bmp_New differ
diff --git a/installer/Binary/Binary.WixUI_Bmp_Up b/installer/Binary/Binary.WixUI_Bmp_Up
new file mode 100644
index 0000000..86f6b5a
Binary files /dev/null and b/installer/Binary/Binary.WixUI_Bmp_Up differ
diff --git a/installer/Binary/Binary.WixUI_Ico_Exclam b/installer/Binary/Binary.WixUI_Ico_Exclam
new file mode 100644
index 0000000..906ce32
Binary files /dev/null and b/installer/Binary/Binary.WixUI_Ico_Exclam differ
diff --git a/installer/Binary/Binary.WixUI_Ico_Info b/installer/Binary/Binary.WixUI_Ico_Info
new file mode 100644
index 0000000..7e0ff7f
Binary files /dev/null and b/installer/Binary/Binary.WixUI_Ico_Info differ
diff --git a/installer/License.rtf b/installer/License.rtf
new file mode 100644
index 0000000..2999055
--- /dev/null
+++ b/installer/License.rtf
@@ -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
+}
diff --git a/installer/SQLInsight.wxs b/installer/SQLInsight.wxs
new file mode 100644
index 0000000..772257a
--- /dev/null
+++ b/installer/SQLInsight.wxs
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ NOT NEWERVERSIONFOUND
+ VersionNT64 >= 603
+ Privileged
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ REPOSITORY_CONNECTION
+
+
+
+
+ MONITOR_SERVER
+
+
+
+
+
+
+ REPOSITORY_CONNECTION OR OLDVERSIONFOUND
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ REPOSITORY_CONNECTION
+
+
+
+
+
+ REPOSITORY_CONNECTION OR OLDVERSIONFOUND
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ 1
+ 1
+
+ &UpdateTask=2 AND NOT UPGRADINGPRODUCTCODE
+ &Web=2 AND NOT UPGRADINGPRODUCTCODE
+
+ &Agent=3
+ &UpdateTask=3
+ &Web=3
+ &Web=3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UPDATETASK_INSTALLED
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/SettingsDlg.wxs b/installer/SettingsDlg.wxs
new file mode 100644
index 0000000..f3a7572
--- /dev/null
+++ b/installer/SettingsDlg.wxs
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/installer/banner.bmp b/installer/banner.bmp
new file mode 100644
index 0000000..5e42f74
Binary files /dev/null and b/installer/banner.bmp differ
diff --git a/installer/build-msi.sh b/installer/build-msi.sh
new file mode 100755
index 0000000..56aef2b
--- /dev/null
+++ b/installer/build-msi.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Builds dist/SQLInsight-Pro--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"
diff --git a/installer/dialog.bmp b/installer/dialog.bmp
new file mode 100644
index 0000000..7401db1
Binary files /dev/null and b/installer/dialog.bmp differ
diff --git a/installer/msi-tables.py b/installer/msi-tables.py
new file mode 100755
index 0000000..84fd75f
--- /dev/null
+++ b/installer/msi-tables.py
@@ -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 Removes Component and Feature conditions and WixVariable, which wixl cannot parse.
+ msi-tables.py apply 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])
diff --git a/installer/sqlinsight.ico b/installer/sqlinsight.ico
new file mode 100644
index 0000000..4134cc6
Binary files /dev/null and b/installer/sqlinsight.ico differ
diff --git a/installer/test-msi-wine.sh b/installer/test-msi-wine.sh
new file mode 100755
index 0000000..1371d88
--- /dev/null
+++ b/installer/test-msi-wine.sh
@@ -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
diff --git a/installer/verify-msi.py b/installer/verify-msi.py
new file mode 100755
index 0000000..6b9f792
--- /dev/null
+++ b/installer/verify-msi.py
@@ -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
+"""
+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]))
diff --git a/src/SQLInsight.Agent/CustomRules/RuleCommands.cs b/src/SQLInsight.Agent/CustomRules/RuleCommands.cs
index 1fb392d..d5e924a 100644
--- a/src/SQLInsight.Agent/CustomRules/RuleCommands.cs
+++ b/src/SQLInsight.Agent/CustomRules/RuleCommands.cs
@@ -105,6 +105,7 @@ public static class RuleCommands
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
+ .AddIniFile(Path.Combine(AppContext.BaseDirectory, InstallerSettings.FileName), optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
diff --git a/src/SQLInsight.Agent/InstallerSettings.cs b/src/SQLInsight.Agent/InstallerSettings.cs
new file mode 100644
index 0000000..35a4c91
--- /dev/null
+++ b/src/SQLInsight.Agent/InstallerSettings.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Configuration.Ini;
+using Microsoft.Extensions.Configuration.Json;
+
+namespace SQLInsight.Agent;
+
+///
+/// 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.
+///
+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);
+ }
+ }
+}
diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs
index 4fc6b3a..d1296a5 100644
--- a/src/SQLInsight.Agent/Program.cs
+++ b/src/SQLInsight.Agent/Program.cs
@@ -8,6 +8,12 @@ using SQLInsight.Repository;
using SQLInsight.Rules;
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))
{
return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out);
@@ -19,6 +25,7 @@ if (UpdateCommands.Handles(args))
}
var builder = Host.CreateApplicationBuilder(args);
+InstallerSettings.Add(builder.Configuration, AppContext.BaseDirectory);
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent");
builder.Services.AddSystemd();
diff --git a/src/SQLInsight.Agent/SQLInsight.Agent.csproj b/src/SQLInsight.Agent/SQLInsight.Agent.csproj
index 34fe7f7..82ea502 100644
--- a/src/SQLInsight.Agent/SQLInsight.Agent.csproj
+++ b/src/SQLInsight.Agent/SQLInsight.Agent.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/SQLInsight.Agent/Updates/UpdateCommands.cs b/src/SQLInsight.Agent/Updates/UpdateCommands.cs
index 2d1e7fc..ca106d2 100644
--- a/src/SQLInsight.Agent/Updates/UpdateCommands.cs
+++ b/src/SQLInsight.Agent/Updates/UpdateCommands.cs
@@ -202,6 +202,7 @@ public static class UpdateCommands
var configuration = new ConfigurationBuilder()
.SetBasePath(agentFolder)
.AddJsonFile("appsettings.json", optional: true)
+ .AddIniFile(Path.Combine(agentFolder, InstallerSettings.FileName), optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.AddEnvironmentVariables()
.Build();
diff --git a/src/SQLInsight.Agent/appsettings.json b/src/SQLInsight.Agent/appsettings.json
index b939261..8af2b29 100644
--- a/src/SQLInsight.Agent/appsettings.json
+++ b/src/SQLInsight.Agent/appsettings.json
@@ -53,14 +53,8 @@
"ResolvedFindingDays": 365
},
"Instances": [
- {
- "Name": "eksempel-sql01",
- "Server": "sql01.eksempel.local",
- "Authentication": "Windows",
- "Provider": "Auto",
- "Encryption": "Mandatory",
- "TrustServerCertificate": false
- }
+ // Eksempel (se docs/installation.md):
+ // { "Name": "prod-sql01", "Server": "sql01.domæne.local", "Authentication": "Windows", "Provider": "Auto", "Encryption": "Mandatory" }
]
}
}
diff --git a/src/SQLInsight.Updates/ManifestVerifier.cs b/src/SQLInsight.Updates/ManifestVerifier.cs
index d0bf1c0..268bf13 100644
--- a/src/SQLInsight.Updates/ManifestVerifier.cs
+++ b/src/SQLInsight.Updates/ManifestVerifier.cs
@@ -58,9 +58,28 @@ public static class ManifestVerifier
public static class Platform
{
+ public const string MsiSuffix = "-msi";
+
/// The package platform for this machine, e.g. win-x64 or linux-arm64.
public static string Current =>
(OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsLinux() ? "linux" : "other") + "-" + RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
+
+ ///
+ /// 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 .
+ ///
+ public static string Installed => Current + (InstalledWithMsi ? MsiSuffix : string.Empty);
+
+ /// The MSI writes HKLM\SOFTWARE\SEOSoft\SQLInsight\Installer = msi.
+ 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
diff --git a/src/SQLInsight.Updates/UpdateApplier.cs b/src/SQLInsight.Updates/UpdateApplier.cs
index 642a914..7b7e336 100644
--- a/src/SQLInsight.Updates/UpdateApplier.cs
+++ b/src/SQLInsight.Updates/UpdateApplier.cs
@@ -34,6 +34,12 @@ public sealed class UpdateApplierOptions
/// How long every restarted service must keep running before the update counts as good.
public TimeSpan HealthCheckDuration { get; init; } = TimeSpan.FromMinutes(1);
+ /// The package to take; default .
+ public string Platform { get; init; } = Updates.Platform.Installed;
+
+ /// Runs msiexec for MSI packages (installations made with the MSI).
+ public IMsiInstaller Msi { get; init; } = new MsiExec();
+
/// Files kept from the installation, never overwritten by the package.
public IReadOnlyList 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.");
}
- asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase))
- ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}.");
+ asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, options.Platform, StringComparison.OrdinalIgnoreCase))
+ ?? 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.
DeleteDirectory(options.PrivateRoot);
@@ -104,6 +110,11 @@ public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectorie
}
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");
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}.");
}
+ ///
+ /// 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.
+ ///
+ private async Task 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 components, CancellationToken cancellationToken)
{
foreach (var component in components)
@@ -267,3 +306,25 @@ public sealed class UpdateApplier(UpdateApplierOptions options, UpdateDirectorie
}
}
}
+
+/// Installs an MSI silently and returns msiexec's exit code.
+public interface IMsiInstaller
+{
+ Task InstallAsync(string msiPath, string logPath, CancellationToken cancellationToken);
+}
+
+public sealed class MsiExec : IMsiInstaller
+{
+ public async Task 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;
+ }
+}
diff --git a/src/SQLInsight.Updates/UpdateChecker.cs b/src/SQLInsight.Updates/UpdateChecker.cs
index e2369ea..4358fbf 100644
--- a/src/SQLInsight.Updates/UpdateChecker.cs
+++ b/src/SQLInsight.Updates/UpdateChecker.cs
@@ -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
/// downloads and stages application packages. It never replaces program files; does that.
///
-public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirectories directories, string channel, TimeProvider time)
+/// The package to take; default .
+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 async Task FetchAsync(CancellationToken cancellationToken)
@@ -71,8 +74,8 @@ public sealed class UpdateChecker(UpdateFeed feed, TrustedKeys keys, UpdateDirec
return false;
}
- var asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, Platform.Current, StringComparison.OrdinalIgnoreCase))
- ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {Platform.Current}.");
+ var asset = release.Assets.FirstOrDefault(a => string.Equals(a.Platform, _platform, StringComparison.OrdinalIgnoreCase))
+ ?? throw new UpdateException($"Version {release.Version} har ingen pakke til {_platform}.");
var package = Path.Combine(directories.Pending, "package.zip");
var manifestFile = Path.Combine(directories.Pending, UpdateFeed.ManifestFile);
diff --git a/src/SQLInsight.Web/InstallerSettings.cs b/src/SQLInsight.Web/InstallerSettings.cs
new file mode 100644
index 0000000..7010a2e
--- /dev/null
+++ b/src/SQLInsight.Web/InstallerSettings.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Configuration.Ini;
+using Microsoft.Extensions.Configuration.Json;
+
+namespace SQLInsight.Web;
+
+///
+/// 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.
+///
+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);
+ }
+ }
+}
diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs
index 7a50ea9..2982a7c 100644
--- a/src/SQLInsight.Web/Program.cs
+++ b/src/SQLInsight.Web/Program.cs
@@ -7,6 +7,7 @@ using SQLInsight.Web.Components;
if (args.FirstOrDefault() == "new-api-token")
{
+ Console.OutputEncoding = System.Text.Encoding.UTF8;
var (token, hash) = ApiTokenHandler.Create();
var name = args.ElementAtOrDefault(1) ?? "prometheus";
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);
+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
// as a systemd service on Linux. Each call is a no-op when not hosted that way.
diff --git a/tests/SQLInsight.Agent.Tests/InstallerSettingsTests.cs b/tests/SQLInsight.Agent.Tests/InstallerSettingsTests.cs
new file mode 100644
index 0000000..70cad66
--- /dev/null
+++ b/tests/SQLInsight.Agent.Tests/InstallerSettingsTests.cs
@@ -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()!;
+
+ 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"]);
+ }
+}
diff --git a/tests/SQLInsight.Updates.Tests/TestFeed.cs b/tests/SQLInsight.Updates.Tests/TestFeed.cs
index 0588adf..c172ed5 100644
--- a/tests/SQLInsight.Updates.Tests/TestFeed.cs
+++ b/tests/SQLInsight.Updates.Tests/TestFeed.cs
@@ -84,6 +84,18 @@ public sealed class TestFeed : IDisposable
};
}
+ /// A release published as an MSI for installations made with the MSI.
+ 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")
{
Directory.CreateDirectory(AgentFolder);
@@ -91,7 +103,7 @@ public sealed class TestFeed : IDisposable
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()
{
@@ -142,3 +154,14 @@ public sealed class FakeServices(string programFile) : IServiceControl
public Task IsRunningAsync(string service, CancellationToken cancellationToken) =>
Task.FromResult(File.ReadAllText(programFile) != "broken");
}
+
+public sealed class FakeMsi(int exitCode) : IMsiInstaller
+{
+ public List<(string Msi, byte[] Content)> Installed { get; } = [];
+
+ public Task InstallAsync(string msiPath, string logPath, CancellationToken cancellationToken)
+ {
+ Installed.Add((msiPath, File.ReadAllBytes(msiPath)));
+ return Task.FromResult(exitCode);
+ }
+}
diff --git a/tests/SQLInsight.Updates.Tests/UpdateTests.cs b/tests/SQLInsight.Updates.Tests/UpdateTests.cs
index 2fee0cd..485a430 100644
--- a/tests/SQLInsight.Updates.Tests/UpdateTests.cs
+++ b/tests/SQLInsight.Updates.Tests/UpdateTests.cs
@@ -10,10 +10,12 @@ public sealed class UpdateTests : IDisposable
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
{
Channel = "stable",
+ Platform = platform ?? Platform.Current,
+ Msi = msi ?? new FakeMsi(0),
PrivateRoot = Path.Combine(_feed.Root, "private"),
Components = [new UpdateComponent { Name = "Agent", Path = _feed.AgentFolder, Service = "SQLInsight Agent" }],
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);
var services = new FakeServices(Path.Combine(_feed.AgentFolder, "SQLInsight.Agent.dll"));
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, _ => { });
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);
}
+
+ [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);
+ }
}
diff --git a/tools/SQLInsight.LicenseTool/Program.cs b/tools/SQLInsight.LicenseTool/Program.cs
index 984bf78..23f4ed9 100644
--- a/tools/SQLInsight.LicenseTool/Program.cs
+++ b/tools/SQLInsight.LicenseTool/Program.cs
@@ -30,7 +30,7 @@ int Usage()
Byg regelpakke af katalogfilerne
publish --key --kid --feed --channel [--days 90]
[--rulepack ]
- [--release --asset win-x64= [--asset linux-x64=] [--notes ] [--security]]
+ [--release --asset win-x64= [--asset win-x64-msi=] [--asset linux-x64=] [--notes ] [--security]]
Signér kanalens manifest; det, der ikke angives,
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])
{
- 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))));
}
}