From d764552c2cd8c55ef03269fcc0c8354ee1fc1598 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:31:28 +0000 Subject: [PATCH] MSI installer for Agent and Web 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 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- .forgejo/workflows/ci.yml | 28 +++ .gitignore | 1 + Directory.Packages.props | 1 + README.md | 2 +- docs/installation.md | 41 +++- docs/opdatering.md | 13 +- installer/Binary/Binary.WixUI_Bmp_Banner | Bin 0 -> 85894 bytes installer/Binary/Binary.WixUI_Bmp_Dialog | Bin 0 -> 461814 bytes installer/Binary/Binary.WixUI_Bmp_New | Bin 0 -> 318 bytes installer/Binary/Binary.WixUI_Bmp_Up | Bin 0 -> 318 bytes installer/Binary/Binary.WixUI_Ico_Exclam | Bin 0 -> 766 bytes installer/Binary/Binary.WixUI_Ico_Info | Bin 0 -> 1078 bytes installer/License.rtf | 7 + installer/SQLInsight.wxs | 208 ++++++++++++++++++ installer/SettingsDlg.wxs | 42 ++++ installer/banner.bmp | Bin 0 -> 85894 bytes installer/build-msi.sh | 51 +++++ installer/dialog.bmp | Bin 0 -> 461814 bytes installer/msi-tables.py | 102 +++++++++ installer/sqlinsight.ico | Bin 0 -> 30564 bytes installer/test-msi-wine.sh | 45 ++++ installer/verify-msi.py | 94 ++++++++ .../CustomRules/RuleCommands.cs | 1 + src/SQLInsight.Agent/InstallerSettings.cs | 32 +++ src/SQLInsight.Agent/Program.cs | 7 + src/SQLInsight.Agent/SQLInsight.Agent.csproj | 1 + .../Updates/UpdateCommands.cs | 1 + src/SQLInsight.Agent/appsettings.json | 10 +- src/SQLInsight.Updates/ManifestVerifier.cs | 19 ++ src/SQLInsight.Updates/UpdateApplier.cs | 65 +++++- src/SQLInsight.Updates/UpdateChecker.cs | 9 +- src/SQLInsight.Web/InstallerSettings.cs | 32 +++ src/SQLInsight.Web/Program.cs | 2 + .../InstallerSettingsTests.cs | 62 ++++++ tests/SQLInsight.Updates.Tests/TestFeed.cs | 25 ++- tests/SQLInsight.Updates.Tests/UpdateTests.cs | 45 +++- tools/SQLInsight.LicenseTool/Program.cs | 4 +- 37 files changed, 927 insertions(+), 23 deletions(-) create mode 100644 installer/Binary/Binary.WixUI_Bmp_Banner create mode 100644 installer/Binary/Binary.WixUI_Bmp_Dialog create mode 100644 installer/Binary/Binary.WixUI_Bmp_New create mode 100644 installer/Binary/Binary.WixUI_Bmp_Up create mode 100644 installer/Binary/Binary.WixUI_Ico_Exclam create mode 100644 installer/Binary/Binary.WixUI_Ico_Info create mode 100644 installer/License.rtf create mode 100644 installer/SQLInsight.wxs create mode 100644 installer/SettingsDlg.wxs create mode 100644 installer/banner.bmp create mode 100755 installer/build-msi.sh create mode 100644 installer/dialog.bmp create mode 100755 installer/msi-tables.py create mode 100644 installer/sqlinsight.ico create mode 100755 installer/test-msi-wine.sh create mode 100755 installer/verify-msi.py create mode 100644 src/SQLInsight.Agent/InstallerSettings.cs create mode 100644 src/SQLInsight.Web/InstallerSettings.cs create mode 100644 tests/SQLInsight.Agent.Tests/InstallerSettingsTests.cs 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 0000000000000000000000000000000000000000..5e42f74ed10fc9aff548718706ab1f41ad81726f GIT binary patch literal 85894 zcmeI*-AmI^9LMqb56o?9*mVR!*A;~q(UlNV_d%xlIHzf8_TWP~o%xs&q$z0>UX+29 z7a560d6SkGY8D!XNNJ=PRF)5EXAVR*FMeJ8PM+U+kF)81j`Q&P9zKlU=5{2`cPz~2 zzw8&+J>ohi?j909!)(#w)@C~{{@!P|tzXv-Ic|+QvI+zc&{u#Q*SGep903HZAwZ5>qmHZs0R;3FAjkEsJu625 z0c!}5c}b(KtNvsa$MiqvvLFwu!aCRZjCy! z3Iq_)SAZPXxAv?Y0R*feU?j)e>W}vK-26N_`2E|=pOvMh#f6!v;hslcUuN{i=N-Q6 zstbU0ca-sYht}8|tO5Z9)DSR|Xe@*bR>9t%vd(Hj%^{BfR-%c~h9A%Fk^n+1&Ic?4EMJOI%#i;C&xE? z@;d?uARt@dpK`p}7xVN%{)drg8?$VwiY3Q4H24Jp1Q7TS0tR!u^KR<=*N;K-o1Pl- zx}(VPAQtW-fB*tg1&ri)M@vG`oECn}4LonI&fU%HoIx_&MF0T=WD6L{@rlvyz_cdb z_jcaPXe>G4b-RMTXPMVIWmk^z5I_KdfPj%4|MF=>Jowkod7sBs>UNa6o!)dOInEYB z009KHg@BP9e>d3SpX=&s$L++5?431vyY5uSzUpc0ds54Jm2FW)CV>C~Itv)d@z%=2 z%S*pQmEOE)CC5WqID!BI2uKw$lH=n2<4xXu;=h5!TUJ(<7sUyd*_rXt!N>O-PLShL ztHodlAb>!qfWaIu_c(kWM_GnAIllGz@!}L$QL?`kCb{^0l~8AnAb0&)b% zaXHS6f&c;tm?c1tn% YTL1tA5Kvrz99O)6OdkORv=^}b1v7jhF8}}l literal 0 HcmV?d00001 diff --git a/installer/Binary/Binary.WixUI_Bmp_Dialog b/installer/Binary/Binary.WixUI_Bmp_Dialog new file mode 100644 index 0000000000000000000000000000000000000000..7401db1408c6c5a859425f22551d0956d6fd816f GIT binary patch literal 461814 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®&co1UySXsmG1wrwsY|l}qQs4-6g; zXTlN)K)}lc^m^Q=+zh6N9~(R#&V(fpfPj|?i1PTj?E9C_N-kYD;l!bB7r#DQas7&^ z^Jl)?y=CU~{9lESw2aQW$E@HuJb2szgohA-fX4|4@%Y>0{;+rZd^22rJKWc=U7G#+ zcL$f7uU-kiKp&5LJhJQ_ z1R&rbAjjheK3g%j=(j;fM{{TW(g6+}<{=&rXTlN)K)}lcBzW9R8(%iNO*G=JzK)r4 zexu@A__4v`;Y?Tp0SI`RK>U0B=FO7gm#Yiz2Xk8FuvadA1>^1_T=YxI39TX4sdu10ub;Zfw=d0#H;Ot;x>0C^nd8x z$uFAuh32x)hyy8(qk_jBKzIlN2zZ=8e0%)TH>XGTYEs?W_WsyD_pO+hR#v*e{76u3 z4*(voEiu-I00bJAKzw`r#NqAX5;s5UX8eBFSn>E>qvM4TfIx!~h;NS{*}pE_^0 z)CDCTuM0h13jqkYPawWMe)`0oaE~AQd`(?A;_)`QP1%;I%00GYukl^uaSH5{`%rg#6fA2#)?%7bX zV-SEqtq4f)xM|pY+xV7Mh2ygC|6YCD70fr*nhW$Jl45*3A{G`x00N#RAjIRA@AYe! zKHD~T<^0aGM?5icU^DX-mpINO7ObOLzX?IVoG-6qcVARwIp9(Vf)<8il% zvpon%CxFM@KEin1ZQ^VX0@4ZKakq~!9(S8K+k=2~0(ji*BaFx0CeHRCAe{gncl!wA zakq)HJqSoAfXCfF!g$4aT0nd3{lFGAF67jg2KP4K4K!XwRqQ{4c zI4iG|I391XWV2xi=po=ykH@>p`v%U$Z85H*=dX!QAENrX_P(6T8ra?!s#M>=RNsJ5B@RdpRR4C)>enu@U)vgm#~U`sY#joc z3E*)&t~vko4gm=S@HifqkYjp)fMx=C9FJ?xKfOah0s%aZ$0g*L9w4BZ03OHVn)6TZ z5RgCskK=I(Ii?2)XeNNi@wn#v(>nwt5WwSjTtbfN0Rox{;Bh>zIsfzy0SN@~I3Aaf zV|svqW&(H|k8939y+c3(0X&Y!CFGbMAfTB59>?RF^H1*(kU#*B<8cW&rUwXUCV9aS1u52MA~;fXDH;=KRw;1SAl^<9J*`j_Cmc znhD@>Jgzza^bP?D1n@W>mylz6fPiKKcpQ&w&Og0FKmq|gj>jeBm>wXYnE)QgYnwt5NP!Ozk??S literal 0 HcmV?d00001 diff --git a/installer/Binary/Binary.WixUI_Bmp_New b/installer/Binary/Binary.WixUI_Bmp_New new file mode 100644 index 0000000000000000000000000000000000000000..27881dfe972209bb5825c15cb7bc00bbde46c7c2 GIT binary patch literal 318 zcmZQzU<5(|0RaXO&|qX>5ChRb3=&ZQVnzlQAj!bc04A{!KpBu^Lj!~Yl0YWIfddDC zG)Uq<7%}|+4`Kl^5Q2n21UlFa1RH>Y#L_VR++bZW9?>+&2@U*cm>0w0`Ys0yAd=1E8#zj literal 0 HcmV?d00001 diff --git a/installer/Binary/Binary.WixUI_Bmp_Up b/installer/Binary/Binary.WixUI_Bmp_Up new file mode 100644 index 0000000000000000000000000000000000000000..86f6b5a84755483dee8175ebc821d3af0ce866b8 GIT binary patch literal 318 zcma)&F%Ez*3jinPaBfq3iVrS`7_(?ej5C}1lGoRa7bAIcM3XBn@W5%19)2 zpi~0CfR3A`90Zc8)(X4P;;1!e1a8Ujwl!?g%}lJWd!z?T2-Gq9(9iqVy9{0F7kh<5 P<5%!Eb(Xf+-TO;BySGdd literal 0 HcmV?d00001 diff --git a/installer/Binary/Binary.WixUI_Ico_Exclam b/installer/Binary/Binary.WixUI_Ico_Exclam new file mode 100644 index 0000000000000000000000000000000000000000..906ce3246dba72e290f6a3008b19ea129233f213 GIT binary patch literal 766 zcma))v2KGv42DfF5@Q-6bq#75ItJXmsN}tJkH(`gX3Ur&iu>EX>!nOJ`Z@mdeYQam zRk+&rl{79dL~mF>pcWdTiZ-|tMda2o&ojjsk%TlveEsL0x!;v&m%$jca0d$R{t35{ zf4ggmF1@>#Q{c~W=J!KRt=1b+y%2>Nzgb*K_n@%2>MPDdTQI8q-Szb#Ql0VVsYIfx W5}8#&WKq>3SJhVJwimgdMdJrzKB>e2 literal 0 HcmV?d00001 diff --git a/installer/Binary/Binary.WixUI_Ico_Info b/installer/Binary/Binary.WixUI_Ico_Info new file mode 100644 index 0000000000000000000000000000000000000000..7e0ff7f14c24ace2d59e24260b666ca60ef57482 GIT binary patch literal 1078 zcmbW0y-ve06opR-l9@=}pq)_PqC8SIv&9<=QeMd$43&6=ZZKveLsj!#J1I(4B!t`J z>vMnI>sx818*Mg?Y<=EH56Gq|e7lnFD`|^v10KOjx9n2D_xcJLv4l_rJ4X-{W5kg1 zS1H*E@G+Hb^K$KcD$-x@3wwRR(}KHt!M%rjKNlc%Z+ucX-Eos3KbP#GDUB#{M4>2L zcBr$}wjn#5X(DH<4u0ITvP0`~GzxPggEMIkSvvo;;K6qbKKzec{hViLOa4sQLR;$3 z+^O8}W&dWXk>A_dVTvE%Ibhq4i;& + + + + + + + + + + + + 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 0000000000000000000000000000000000000000..5e42f74ed10fc9aff548718706ab1f41ad81726f GIT binary patch literal 85894 zcmeI*-AmI^9LMqb56o?9*mVR!*A;~q(UlNV_d%xlIHzf8_TWP~o%xs&q$z0>UX+29 z7a560d6SkGY8D!XNNJ=PRF)5EXAVR*FMeJ8PM+U+kF)81j`Q&P9zKlU=5{2`cPz~2 zzw8&+J>ohi?j909!)(#w)@C~{{@!P|tzXv-Ic|+QvI+zc&{u#Q*SGep903HZAwZ5>qmHZs0R;3FAjkEsJu625 z0c!}5c}b(KtNvsa$MiqvvLFwu!aCRZjCy! z3Iq_)SAZPXxAv?Y0R*feU?j)e>W}vK-26N_`2E|=pOvMh#f6!v;hslcUuN{i=N-Q6 zstbU0ca-sYht}8|tO5Z9)DSR|Xe@*bR>9t%vd(Hj%^{BfR-%c~h9A%Fk^n+1&Ic?4EMJOI%#i;C&xE? z@;d?uARt@dpK`p}7xVN%{)drg8?$VwiY3Q4H24Jp1Q7TS0tR!u^KR<=*N;K-o1Pl- zx}(VPAQtW-fB*tg1&ri)M@vG`oECn}4LonI&fU%HoIx_&MF0T=WD6L{@rlvyz_cdb z_jcaPXe>G4b-RMTXPMVIWmk^z5I_KdfPj%4|MF=>Jowkod7sBs>UNa6o!)dOInEYB z009KHg@BP9e>d3SpX=&s$L++5?431vyY5uSzUpc0ds54Jm2FW)CV>C~Itv)d@z%=2 z%S*pQmEOE)CC5WqID!BI2uKw$lH=n2<4xXu;=h5!TUJ(<7sUyd*_rXt!N>O-PLShL ztHodlAb>!qfWaIu_c(kWM_GnAIllGz@!}L$QL?`kCb{^0l~8AnAb0&)b% zaXHS6f&c;tm?c1tn% YTL1tA5Kvrz99O)6OdkORv=^}b1v7jhF8}}l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7401db1408c6c5a859425f22551d0956d6fd816f GIT binary patch literal 461814 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®&co1UySXsmG1wrwsY|l}qQs4-6g; zXTlN)K)}lc^m^Q=+zh6N9~(R#&V(fpfPj|?i1PTj?E9C_N-kYD;l!bB7r#DQas7&^ z^Jl)?y=CU~{9lESw2aQW$E@HuJb2szgohA-fX4|4@%Y>0{;+rZd^22rJKWc=U7G#+ zcL$f7uU-kiKp&5LJhJQ_ z1R&rbAjjheK3g%j=(j;fM{{TW(g6+}<{=&rXTlN)K)}lcBzW9R8(%iNO*G=JzK)r4 zexu@A__4v`;Y?Tp0SI`RK>U0B=FO7gm#Yiz2Xk8FuvadA1>^1_T=YxI39TX4sdu10ub;Zfw=d0#H;Ot;x>0C^nd8x z$uFAuh32x)hyy8(qk_jBKzIlN2zZ=8e0%)TH>XGTYEs?W_WsyD_pO+hR#v*e{76u3 z4*(voEiu-I00bJAKzw`r#NqAX5;s5UX8eBFSn>E>qvM4TfIx!~h;NS{*}pE_^0 z)CDCTuM0h13jqkYPawWMe)`0oaE~AQd`(?A;_)`QP1%;I%00GYukl^uaSH5{`%rg#6fA2#)?%7bX zV-SEqtq4f)xM|pY+xV7Mh2ygC|6YCD70fr*nhW$Jl45*3A{G`x00N#RAjIRA@AYe! zKHD~T<^0aGM?5icU^DX-mpINO7ObOLzX?IVoG-6qcVARwIp9(Vf)<8il% zvpon%CxFM@KEin1ZQ^VX0@4ZKakq~!9(S8K+k=2~0(ji*BaFx0CeHRCAe{gncl!wA zakq)HJqSoAfXCfF!g$4aT0nd3{lFGAF67jg2KP4K4K!XwRqQ{4c zI4iG|I391XWV2xi=po=ykH@>p`v%U$Z85H*=dX!QAENrX_P(6T8ra?!s#M>=RNsJ5B@RdpRR4C)>enu@U)vgm#~U`sY#joc z3E*)&t~vko4gm=S@HifqkYjp)fMx=C9FJ?xKfOah0s%aZ$0g*L9w4BZ03OHVn)6TZ z5RgCskK=I(Ii?2)XeNNi@wn#v(>nwt5WwSjTtbfN0Rox{;Bh>zIsfzy0SN@~I3Aaf zV|svqW&(H|k8939y+c3(0X&Y!CFGbMAfTB59>?RF^H1*(kU#*B<8cW&rUwXUCV9aS1u52MA~;fXDH;=KRw;1SAl^<9J*`j_Cmc znhD@>Jgzza^bP?D1n@W>mylz6fPiKKcpQ&w&Og0FKmq|gj>jeBm>wXYnE)QgYnwt5NP!Ozk??S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4134cc6112a796fe9068501dc2e685462119cbae GIT binary patch literal 30564 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 CkARW@ literal 0 HcmV?d00001 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)))); } }