Files
GTI_GERENCIADOR_FISCAL/installer/setup_main.py
T
gtiandClaude Sonnet 5 2e5598ce9c fix: Agent retries transient transport errors/proxy 5xx with backoff (classify, complete, fail); hide console behind the GUI wizard; 1.2.2
Found in the clean-Windows recovery test: a single TLS handshake failure mid-job failed a whole sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:02:11 -03:00

394 lines
20 KiB
Python

"""Setup.exe — universal, zero-touch Windows installer for the GTI Fiscal Agent.
The operator runs it ONCE. Nothing has to be edited, started or scheduled afterwards.
Flow (GUI wizard, or `--cli`):
1. discover the SGV Firebird database (read-only): exactly one compatible
database is selected automatically; several/none/unknown credentials ->
the wizard asks the operator DURING the installation (never silently guessed)
2. POST /v1/agents/enroll {install_code, hostname, agent_version, machine_fingerprint}
3. agent.env (non-secret only) + credentials.bin (Hub token) + firebird.bin
(Firebird user/password) — DPAPI LocalMachine + SYSTEM/Administrators ACL
4. extract the service host + Uninstall.exe + write executor
5. register the service (Automatic Delayed Start + restart on failure) and START it
6. wait for the first heartbeat; the Hub then creates the INITIAL_SYNC by itself
Re-running Setup on an already-enrolled host (no code needed) UPGRADES it in place:
same identity and credentials, legacy local toggles removed, service restarted.
The install code is never persisted; the Firebird password never goes to the Hub;
no UI or flag enables Firebird/AI writes (always dry-run, writes disabled).
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
import threading
import time
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Callable
import httpx
import firebird_discovery as fbd
import install_common as common
MEIPASS = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
Chooser = Callable[["fbd.Discovery"], "dict | None"]
def setup_version() -> str:
for candidate in (MEIPASS / "VERSION", Path(__file__).resolve().parents[1] / "release" / "VERSION"):
if candidate.exists():
return candidate.read_text(encoding="utf-8").strip()
return os.environ.get("AGENT_VERSION", "")
def resolve_firebird(*, explicit: dict | None, chooser: Chooser | None, progress: Callable[[str], None] = lambda _m: None) -> dict:
"""Return {"path","user","password"} for a verified SGV database, or raise RuntimeError."""
if explicit:
checked = fbd.probe(explicit["path"], explicit["user"], explicit["password"])
if checked.status != "OK":
raise RuntimeError(f"banco Firebird informado não é um banco SGV utilizável ({checked.status}: {checked.detail})")
return explicit
progress("Procurando o banco de dados do SGV (Firebird)…")
found = fbd.discover()
if found.state == "SELECTED":
chosen = found.selected
return {"path": chosen.path, "user": chosen.user, "password": chosen.password}
if chooser is None:
detail = {"AMBIGUOUS": f"{len(found.compatible)} bancos SGV encontrados", "AUTH_REQUIRED": "usuário/senha do Firebird necessários", "NONE": "nenhum banco SGV encontrado"}[found.state]
raise RuntimeError(f"seleção do banco Firebird necessária ({detail}); informe --fdb, --fb-user e --fb-password")
choice = chooser(found)
if not choice:
raise RuntimeError("instalação cancelada: nenhum banco Firebird selecionado")
checked = fbd.probe(choice["path"], choice["user"], choice["password"])
if checked.status != "OK":
raise RuntimeError(f"banco selecionado não é utilizável ({checked.status}: {checked.detail})")
return choice
def install_flow(code: str, *, hub: str | None = None, install_dir_path: str | None = None, register_service: bool = True,
firebird: dict | None = None, chooser: Chooser | None = None, require_firebird: bool = True,
wait_heartbeat: bool = True, progress: Callable[[str], None] = lambda _m: None) -> dict:
"""Perform the full zero-touch install. Returns a result dict; raises RuntimeError with a
user-facing message on any failure (nothing is enrolled if Firebird selection fails)."""
if install_dir_path:
os.environ["GTI_INSTALL_DIR"] = install_dir_path
install = common.install_dir()
chosen = resolve_firebird(explicit=firebird, chooser=chooser, progress=progress) if require_firebird else None
progress("Enviando o código de instalação ao hub…")
try:
result = common.enroll(code, hub=hub)
except httpx.HTTPStatusError as error:
detail = ""
try:
detail = error.response.json().get("detail", "")
except Exception: # noqa: BLE001
detail = ""
reason = detail or error.response.reason_phrase or f"HTTP {error.response.status_code}"
raise RuntimeError(f"enrollment rejeitado: {reason}") from error
except Exception as error: # noqa: BLE001
raise RuntimeError(f"falha no enrollment: {error}") from error
agent_version = str(result.get("agent_version") or os.environ.get("AGENT_VERSION") or setup_version())
os.environ["AGENT_VERSION"] = agent_version
common.write_agent_env(install, result, firebird_path=chosen["path"] if chosen else "")
common.write_credential(install, result["agent_token"])
if chosen:
common.write_firebird_credential(install, chosen["user"], chosen["password"])
progress("Instalando o serviço…")
extract_payload(install)
started = heartbeat = False
service_detail = ""
if register_service:
common.install_service(install)
common.write_arp_uninstall(install)
if chosen: # without a Firebird database the service would only fail preflight in a loop
progress("Iniciando o serviço…")
started, service_detail = common.start_and_verify()
if started and wait_heartbeat:
progress("Aguardando o primeiro heartbeat…")
heartbeat = common.wait_for_heartbeat(result["agent_token"], result.get("hub_url") or hub, timeout=120)
try:
common.audit_event(result["agent_token"], result.get("hub_url") or hub, "WINDOWS_INSTALL_COMPLETED", f"agent {result.get('agent_id')} installed; service_running={started} heartbeat={heartbeat}")
except Exception: # noqa: BLE001 — best effort
pass
result.update(service_detail=service_detail, install_dir=str(install), agent_version=agent_version, firebird_path=chosen["path"] if chosen else None,
service_running=started, heartbeat=heartbeat, mode="INSTALL")
return result
def upgrade_flow(*, install_dir_path: str | None = None, progress: Callable[[str], None] = lambda _m: None) -> dict:
"""Re-run on an enrolled host: replace the binaries in place, keep identity/credentials,
migrate agent.env to the central-control model, restart the service."""
if install_dir_path:
os.environ["GTI_INSTALL_DIR"] = install_dir_path
install = common.install_dir()
existing = common.detect_existing(install)
if existing is None:
raise RuntimeError("nenhuma instalação existente encontrada para atualizar")
version = setup_version()
progress("Atualizando o Agent (identidade e credenciais preservadas)…")
if common.service_installed():
common.ensure_service_stopped()
try:
extract_payload(install)
common.upgrade_agent_env(install, version)
common.install_service(install)
common.write_arp_uninstall(install)
except Exception as error: # noqa: BLE001 — never leave the host without a running Agent because an upgrade step failed
common.ilog(f"upgrade step failed: {error!r}; restarting the existing service")
common.start_and_verify(attempts=2)
raise
os.environ["AGENT_VERSION"] = version
progress("Iniciando o serviço…")
common.ilog(f"upgrade to {version} from {existing.get('agent_id')}")
started, service_detail = common.start_and_verify()
heartbeat = False
try:
from gti_fiscal_agent.credential_store import read_credential
heartbeat = started and common.wait_for_heartbeat(read_credential(install / "credentials.bin"), existing["hub_url"], timeout=120)
except Exception: # noqa: BLE001
pass
return {"agent_id": existing["agent_id"], "installation_id": existing["installation_id"], "install_dir": str(install), "agent_version": version,
"service_running": started, "service_detail": service_detail, "heartbeat": heartbeat, "mode": "UPGRADE", "hub_url": existing["hub_url"]}
def extract_payload(install: Path) -> None:
"""Copy the bundled service host onedir, Uninstall.exe and the write
executor out of the onefile extraction dir into the install directory."""
install.mkdir(parents=True, exist_ok=True)
service_dir = MEIPASS / "GTIFiscalAgentService"
targets = [
(service_dir, install / "GTIFiscalAgentService"),
(MEIPASS / "Uninstall.exe", install / "Uninstall.exe"),
(MEIPASS / "scripts" / "firebird_write_executor.py", install / "scripts" / "firebird_write_executor.py"),
]
for source, dest in targets:
if not source.exists():
raise RuntimeError(f"bundled payload missing: {source}")
if source.is_dir():
for attempt in range(5): # a just-stopped host can still hold files for a moment
try:
shutil.copytree(source, dest, dirs_exist_ok=True)
break
except PermissionError:
if attempt == 4:
raise
time.sleep(3)
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)
def final_message(result: dict) -> str:
if result.get("service_running") and result.get("heartbeat"):
return ("Instalação concluída.\n\nO serviço GTI Fiscal Agent está em execução e já se comunicou com o painel. "
"A primeira sincronização começa automaticamente.\n\nNenhuma outra ação é necessária neste servidor.")
if result.get("service_running"):
return ("Instalação concluída e serviço em execução, mas o primeiro heartbeat ainda não foi confirmado.\n\n"
"O serviço continua tentando sozinho; acompanhe pelo painel. Nenhuma edição de arquivo é necessária.")
return ("ATENÇÃO: o serviço GTI Fiscal Agent NÃO iniciou.\n\n"
f"Motivo: {result.get('service_detail') or 'desconhecido'}\n"
f"Detalhes em {common.log_path()} e {result.get('install_dir')}\\logs\\agent.log.")
class SetupWizard:
def __init__(self, root: tk.Tk):
self.root = root
root.title("GTI Fiscal Agent")
root.resizable(False, False)
root.geometry("720x300")
self.existing = common.detect_existing(common.install_dir())
frame = ttk.Frame(root, padding=20)
frame.pack(fill="both", expand=True)
ttk.Label(frame, text="GTI Fiscal Agent — Instalação", font=("Segoe UI", 13, "bold")).pack(anchor="w")
self.code_var = tk.StringVar()
if self.existing:
ttk.Label(frame, text="Este servidor já está instalado. Clique em Atualizar para aplicar a versão mais recente\n(identidade e credenciais são preservadas).").pack(anchor="w", pady=(6, 2))
else:
ttk.Label(frame, text="Digite o código de instalação gerado no painel GTI Fiscal.").pack(anchor="w", pady=(4, 2))
ttk.Label(frame, text=f"Hub: {common.hub_url()}", foreground="#555").pack(anchor="w")
ttk.Entry(frame, textvariable=self.code_var, width=26, font=("Consolas", 14), justify="center").pack(anchor="w", pady=(14, 4))
self.status = ttk.Label(frame, text="", foreground="#333", wraplength=660, justify="left")
self.status.pack(anchor="w", pady=(6, 0))
buttons = ttk.Frame(frame)
buttons.pack(anchor="e", pady=(10, 0))
self.install_btn = ttk.Button(buttons, text="Atualizar" if self.existing else "Instalar", command=self.on_install)
self.install_btn.pack(side="left", padx=(0, 6))
ttk.Button(buttons, text="Fechar", command=root.destroy).pack(side="left")
root.bind("<Return>", lambda _event: self.on_install())
def _say(self, text: str, color: str = "#333"):
self.root.after(0, lambda: self.status.config(text=text, foreground=color))
def _busy(self, busy: bool):
self.root.after(0, lambda: self.install_btn.config(state="disabled" if busy else "normal"))
def on_install(self):
code = self.code_var.get().strip()
if not self.existing and not code:
self.status.config(text="Informe o código de instalação.", foreground="#b00")
return
self._busy(True)
threading.Thread(target=self._run, args=(code,), daemon=True).start()
def choose_firebird(self, found: "fbd.Discovery") -> dict | None:
"""Runs on the worker thread; shows the selection dialog on the UI thread and waits."""
holder: dict = {}
done = threading.Event()
self.root.after(0, lambda: self._firebird_dialog(found, holder, done))
done.wait()
return holder.get("choice")
def _firebird_dialog(self, found: "fbd.Discovery", holder: dict, done: threading.Event):
win = tk.Toplevel(self.root)
win.title("Banco de dados do SGV")
win.geometry("640x330")
win.grab_set()
frame = ttk.Frame(win, padding=16)
frame.pack(fill="both", expand=True)
path_var, user_var, pass_var = tk.StringVar(), tk.StringVar(value="SYSDBA"), tk.StringVar()
msg = ttk.Label(frame, text="", foreground="#b00", wraplength=600)
if found.state == "AMBIGUOUS":
ttk.Label(frame, text="Foram encontrados vários bancos SGV. Selecione o correto:").pack(anchor="w")
box = tk.Listbox(frame, height=6)
for cand in found.compatible:
box.insert("end", cand.path)
box.pack(fill="x", pady=8)
box.selection_set(0)
def confirm():
if not box.curselection():
return
cand = found.compatible[box.curselection()[0]]
holder["choice"] = {"path": cand.path, "user": cand.user, "password": cand.password}
win.destroy()
done.set()
else:
why = "Usuário/senha do Firebird necessários." if found.state == "AUTH_REQUIRED" else "Nenhum banco SGV foi encontrado automaticamente."
ttk.Label(frame, text=f"{why} Informe o arquivo do banco e as credenciais:").pack(anchor="w")
row = ttk.Frame(frame)
row.pack(fill="x", pady=6)
ttk.Entry(row, textvariable=path_var, width=60).pack(side="left")
ttk.Button(row, text="Procurar…", command=lambda: path_var.set(filedialog.askopenfilename(filetypes=[("Firebird", "*.fdb *.FDB"), ("Todos", "*.*")]) or path_var.get())).pack(side="left", padx=6)
for label, var, secret in (("Usuário", user_var, False), ("Senha", pass_var, True)):
r = ttk.Frame(frame)
r.pack(fill="x", pady=2)
ttk.Label(r, text=label, width=9).pack(side="left")
ttk.Entry(r, textvariable=var, width=30, show="*" if secret else "").pack(side="left")
if found.auth_failed:
path_var.set(found.auth_failed[0].path)
def confirm():
cand = fbd.probe(path_var.get().strip(), user_var.get().strip(), pass_var.get())
if cand.status != "OK":
msg.config(text=f"Não foi possível usar este banco ({cand.status}: {cand.detail}). Verifique o arquivo e as credenciais.")
return
holder["choice"] = {"path": path_var.get().strip(), "user": user_var.get().strip(), "password": pass_var.get()}
win.destroy()
done.set()
def cancel():
win.destroy()
done.set()
msg.pack(anchor="w")
btns = ttk.Frame(frame)
btns.pack(anchor="e", pady=10)
ttk.Button(btns, text="Continuar", command=confirm).pack(side="left", padx=6)
ttk.Button(btns, text="Cancelar", command=cancel).pack(side="left")
win.protocol("WM_DELETE_WINDOW", cancel)
def _run(self, code: str):
try:
if self.existing:
result = upgrade_flow(progress=self._say)
else:
result = install_flow(code, chooser=self.choose_firebird, progress=self._say)
except RuntimeError as error:
self._busy(False)
self._say(str(error), "#b00")
self.root.after(0, lambda: messagebox.showerror("GTI Fiscal Agent", str(error)))
return
self._busy(False)
self.root.after(0, lambda: (messagebox.showinfo("GTI Fiscal Agent", final_message(result)), self.root.destroy()))
def cli() -> int:
parser = argparse.ArgumentParser(description="GTI Fiscal Agent Setup (headless)")
parser.add_argument("--cli", action="store_true", help="run headless (no GUI)")
parser.add_argument("--code", default=None, help="XXXX-XXXX-XXXX-XXXX install code (omit to upgrade an existing install)")
parser.add_argument("--hub", default=None, help="Hub base URL (dev override; default prod)")
parser.add_argument("--install-dir", default=None, help="install directory (dev override)")
parser.add_argument("--no-service", action="store_true", help="skip service registration + ARP (E2E scratch runs)")
parser.add_argument("--fdb", default=None, help="Firebird database path (skips discovery)")
parser.add_argument("--fb-user", default="SYSDBA")
parser.add_argument("--fb-password", default=None)
parser.add_argument("--no-firebird", action="store_true", help="scratch E2E without any Firebird database")
args = parser.parse_args()
if args.hub:
os.environ["GTI_HUB_URL"] = args.hub
if args.install_dir:
os.environ["GTI_INSTALL_DIR"] = args.install_dir
if not args.code:
result = upgrade_flow(install_dir_path=args.install_dir, progress=print)
print(f"UPGRADE_OK agent_id={result['agent_id']} service_running={result['service_running']} heartbeat={result['heartbeat']}")
if not result["service_running"]:
print(f"SERVICE_START_FAILED {result.get('service_detail')} (see {common.log_path()})", file=sys.stderr)
return 3
return 0
explicit = {"path": args.fdb, "user": args.fb_user, "password": args.fb_password or ""} if args.fdb else None
result = install_flow(args.code, hub=args.hub, install_dir_path=args.install_dir, register_service=not args.no_service,
firebird=explicit, require_firebird=not args.no_firebird, progress=print)
print(f"INSTALL_OK agent_id={result.get('agent_id')} install_dir={result.get('install_dir')} hub={result.get('hub_url')} "
f"service_running={result.get('service_running')} heartbeat={result.get('heartbeat')}")
if result.get("service_detail") and not result.get("service_running"): # a start was attempted and failed
if True:
print(f"SERVICE_START_FAILED {result.get('service_detail')} (see {common.log_path()})", file=sys.stderr)
return 3
return 0
def _hide_console():
"""The frozen Setup is a console-subsystem exe (needed for --cli); in GUI mode the black console window behind the wizard is noise, so hide it."""
try:
import ctypes
handle = ctypes.windll.kernel32.GetConsoleWindow()
if handle:
ctypes.windll.user32.ShowWindow(handle, 0)
except Exception: # noqa: BLE001 - cosmetic only
pass
def main():
if "--cli" in sys.argv:
try:
return cli()
except RuntimeError as error:
print(f"INSTALL_FAIL {error}", file=sys.stderr)
return 1
_hide_console()
root = tk.Tk()
SetupWizard(root)
root.mainloop()
return 0
if __name__ == "__main__":
sys.exit(main())