sqlinsight-pro/installer/verify-msi.py
Søren Eggert Lundsteen Olsen 6d755aa3d0
Some checks failed
deploy community / deploy (push) Successful in 1s
deploy hub / deploy (push) Successful in 19s
CI / Build og unit tests (push) Successful in 8s
CI / MSI-installer (push) Successful in 15s
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Has been cancelled
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Has been cancelled
Installer creates the repository database, logins and rights itself
Entering a connection string in the installer is now enough: the new
command `SQLInsight.Agent.exe setup-repository` runs as a deferred custom
action after the services exist and sqlinsight.ini is written, and before
they start. It runs as the installing user on purpose (SQL Server must see
a person with rights, not SYSTEM) and:

- creates the database from Database=... if it is missing
- creates logins/users for the service accounts: agent db_owner, web
  db_datareader. NT SERVICE\... accounts arrive at another computer as
  DOMAIN\COMPUTER$, which then gets the login instead
- runs the schema migrations
- with MONITOR_SERVER: creates the agent's login there and applies
  grant-least-privilege.sql (embedded). A failure there is a warning only

A repository failure stops the installation; the reason goes to the
Application event log with a Danish hint (login, rights, certificate,
unreachable). SKIP_REPOSITORY_SETUP=1 leaves it to a DBA. Safe to rerun.

verify-msi.py allows this one action to impersonate and checks its
placement, Return=check and condition. Tested against a local SQL
Express (twice, idempotent) and with an unreachable server. Version 1.0.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 08:18:26 +02:00

112 lines
5.8 KiB
Python
Executable file

#!/usr/bin/env python3
"""Checks a built MSI for the mistakes that cannot be caught without Windows (run by build-msi.sh and CI).
verify-msi.py <file.msi>
"""
import os
import subprocess
import sys
import tempfile
DEFERRED = 0x400
IN_SCRIPT_NO_IMPERSONATE = 0x800
CONTINUE_ON_ERROR = 0x40
# Deferred actions that deliberately run as the installing user: SQL Server must see a person with rights there.
AS_INSTALLING_USER = {"SetupRepository"}
def tables(msi):
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 and action not in AS_INSTALLING_USER:
errors.append(f"{action}: deferred uden NoImpersonate (kører ikke som SYSTEM)")
elif action in AS_INSTALLING_USER and kind & IN_SCRIPT_NO_IMPERSONATE:
errors.append(f"{action}: skal køre som den installerende bruger, ikke SYSTEM")
def after(action, standard):
if sequence.get(action, 0) <= sequence[standard]:
errors.append(f"{action} skal ligge efter {standard}")
# Repository setup: needs the services' accounts and the written sqlinsight.ini, and must finish before the
# services start; a failure must stop the installation, and an admin must be able to skip it.
after("SetupRepository", "InstallServices")
after("SetupRepository", "WriteIniValues")
if sequence.get("SetupRepository", 0) >= sequence["StartServices"]:
errors.append("SetupRepository skal ligge før StartServices")
if actions.get("SetupRepository", 0) & CONTINUE_ON_ERROR:
errors.append("SetupRepository: fejl skal stoppe installationen (Return=check)")
condition = next((r["Condition"] for r in rows("InstallExecuteSequence") if r["Action"] == "SetupRepository"), "")
if "SKIP_REPOSITORY_SETUP" not in condition or "REPOSITORY_CONNECTION" not in condition:
errors.append("SetupRepository: betingelsen skal kræve REPOSITORY_CONNECTION og respektere SKIP_REPOSITORY_SETUP")
after("SetAgentExePath", "CostFinalize") # AGENTDIR is only resolved there
after("SetDataFolderAcl", "InstallServices") # the virtual service account must exist
after("CreateUpdateTask", "InstallFiles")
after("OpenFirewall", "ReplaceFirewallRule")
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]))