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
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>
112 lines
5.8 KiB
Python
Executable file
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]))
|