feat: zero-touch Agent — central scheduler, central pause, delta sync, discovery-driven installer

Hub: installations.processing_enabled / sync_interval_seconds / last_sync_* (migration 0026).
The first heartbeat of an installation without coverage queues exactly one INITIAL_SYNC;
afterwards one SYNC_INCREMENTAL per interval (idempotent, backs off on failure). One fiscal
job at a time per Agent (FIFO, stale RUNNING recovery). Job completion now updates the
coverage snapshot, blockers and JOB_STARTED/COMPLETED/FAILED events. Panel: Sincronizar
agora / Reprocessar tudo / Pausar / Retomar + status; pause is enforced Hub-side.
Agent: local AGENT_JOBS_ENABLED is ignored (central control); SYNC_INCREMENTAL re-classifies
only new/changed products via a per-product fingerprint+decision cache and rebuilds coverage
from it; results carry operational summary and blockers; Firebird credentials come from a
DPAPI firebird.bin. Setup: read-only SGV Firebird discovery (auto-select only when
unambiguous, wizard chooser otherwise), starts the service, waits for the first heartbeat,
upgrade-in-place mode. All jobs stay read-only (FIREBIRD_WRITES=0). Version 1.1.3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 08:47:00 -03:00
co-authored by Claude Sonnet 5
parent 9f9e06e9e9
commit bb663a979c
36 changed files with 1409 additions and 118 deletions
+166
View File
@@ -0,0 +1,166 @@
"""Safe, read-only Firebird/SGV discovery for Setup.exe.
Finds candidate .FDB files, and for each one checks (read-only connection, no
writes, ever) that it is an SGV database: it must contain both C000025
(products) and C000004 (company). Exactly one compatible database is selected
automatically; several are NEVER chosen silently — the caller (wizard) asks the
operator during installation. Credentials are only ever used locally.
Pure/injectable on purpose (`connect`, `finder`) so it is unit-testable without
Firebird or a Windows host.
"""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
# The SGV/Firebird default. Tried silently; anything else is asked for inside the wizard.
DEFAULT_CREDENTIALS: tuple[tuple[str, str], ...] = (("SYSDBA", "masterkey"),)
REQUIRED_TABLES = ("C000025", "C000004")
MAX_DEPTH = 4
MIN_BYTES = 200 * 1024
MAX_FILES = 300
SKIP_DIRS = {"windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "system volume information",
"appdata", "node_modules", ".git", "winsxs", "recovery", "perflogs", "users"}
SKIP_FILES = {"security2.fdb", "security3.fdb", "security4.fdb", "security.fdb"}
@dataclass
class Candidate:
path: str
status: str # OK | NOT_SGV | AUTH_FAILED | ERROR
user: str | None = None
password: str | None = None
detail: str = ""
@dataclass
class Discovery:
state: str # SELECTED | AMBIGUOUS | AUTH_REQUIRED | NONE
compatible: list[Candidate] = field(default_factory=list)
auth_failed: list[Candidate] = field(default_factory=list)
scanned: int = 0
firebird_service: str | None = None
@property
def selected(self) -> Candidate | None:
return self.compatible[0] if self.state == "SELECTED" else None
def fixed_drives() -> list[str]:
try:
import ctypes
mask = ctypes.windll.kernel32.GetLogicalDrives()
drives = []
for i in range(26):
root = f"{chr(65 + i)}:\\"
if mask & (1 << i) and ctypes.windll.kernel32.GetDriveTypeW(root) == 3: # DRIVE_FIXED
drives.append(root)
return drives
except Exception: # noqa: BLE001
return ["C:\\"]
def find_fdb_files(roots: list[str], max_depth: int = MAX_DEPTH, limit: int = MAX_FILES) -> list[str]:
found: list[str] = []
for root in roots:
root = str(root)
base_depth = root.rstrip("\\/").count(os.sep)
for current, dirs, files in os.walk(root, onerror=lambda _e: None):
depth = current.rstrip("\\/").count(os.sep) - base_depth
dirs[:] = [d for d in dirs if d.lower() not in SKIP_DIRS] if depth < max_depth else []
for name in files:
lower = name.lower()
if lower.endswith(".fdb") and lower not in SKIP_FILES:
full = os.path.join(current, name)
try:
if os.path.getsize(full) >= MIN_BYTES:
found.append(full)
except OSError:
continue
if len(found) >= limit:
return found
return found
def default_roots() -> list[str]:
roots: list[str] = []
for var in ("ProgramFiles", "ProgramFiles(x86)"):
base = os.environ.get(var)
if base:
roots.extend(str(p) for p in Path(base).glob("Firebird*"))
roots.extend(fixed_drives())
return roots
def firebird_service() -> str | None:
try:
out = subprocess.run(["sc.exe", "query", "type=", "service", "state=", "all"], capture_output=True, text=True, timeout=20).stdout
except Exception: # noqa: BLE001
return None
for line in out.splitlines():
if line.strip().upper().startswith("SERVICE_NAME") and "FIREBIRD" in line.upper():
return line.split(":", 1)[1].strip()
return None
def _default_connect(path: str, user: str, password: str):
import fdb
return fdb.connect(database=path, user=user, password=password, isolation_level=fdb.ISOLATION_LEVEL_READ_COMMITED_RO)
def probe(path: str, user: str, password: str, *, connect: Callable | None = None) -> Candidate:
connect = connect or _default_connect
try:
conn = connect(path, user, password)
except Exception as error: # noqa: BLE001
text = str(error).lower()
if "user name" in text or "password" in text or "not defined" in text:
return Candidate(path, "AUTH_FAILED", detail="credenciais recusadas")
return Candidate(path, "ERROR", detail=type(error).__name__)
try:
cursor = conn.cursor()
cursor.execute("SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE RDB$RELATION_NAME IN ('C000025','C000004')")
have = {row[0] for row in cursor.fetchall()}
except Exception as error: # noqa: BLE001
return Candidate(path, "ERROR", detail=type(error).__name__)
finally:
try:
conn.close()
except Exception: # noqa: BLE001
pass
if all(table in have for table in REQUIRED_TABLES):
return Candidate(path, "OK", user, password)
return Candidate(path, "NOT_SGV", detail="sem C000025/C000004")
def discover(*, roots: list[str] | None = None, credentials=DEFAULT_CREDENTIALS, connect: Callable | None = None,
finder: Callable | None = None) -> Discovery:
finder = finder or find_fdb_files
files = finder(roots if roots is not None else default_roots())
compatible: list[Candidate] = []
auth_failed: list[Candidate] = []
for path in files:
result = None
for user, password in credentials:
result = probe(path, user, password, connect=connect)
if result.status != "AUTH_FAILED":
break
if result is None:
continue
if result.status == "OK":
compatible.append(result)
elif result.status == "AUTH_FAILED":
auth_failed.append(result)
if len(compatible) == 1:
state = "SELECTED"
elif len(compatible) > 1:
state = "AMBIGUOUS"
elif auth_failed:
state = "AUTH_REQUIRED"
else:
state = "NONE"
return Discovery(state, compatible, auth_failed, len(files), firebird_service())
+71 -2
View File
@@ -13,9 +13,11 @@ Policy enforced here (mirrors CLAUDE.md):
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import winreg
from pathlib import Path
@@ -64,7 +66,7 @@ def enroll(install_code: str, *, hub: str | None = None) -> dict:
return response.json()
def write_agent_env(install_dir_path: Path, enroll_result: dict) -> None:
def write_agent_env(install_dir_path: Path, enroll_result: dict, firebird_path: str = "") -> None:
"""Write the non-secret agent.env. The permanent credential goes to
credentials.bin (DPAPI), never here. FIREBIRD_WRITES=0 / DRY_RUN always."""
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1
@@ -76,7 +78,8 @@ def write_agent_env(install_dir_path: Path, enroll_result: dict) -> None:
(f"INSTALLATION_ID={enroll_result.get('installation_id') or ''}"),
("COMPANY_ID="),
("COMPANY_UF="),
("FIREBIRD_DATABASE_PATH="),
(f"FIREBIRD_DATABASE_PATH={firebird_path}"),
# Firebird user/password live ONLY in the DPAPI-protected firebird.bin (never here, never sent to the Hub).
("FB_USER="),
("FB_PASSWORD="),
("FISCAL_MODE=DRY_RUN"),
@@ -103,6 +106,72 @@ def write_credential(install_dir_path: Path, agent_token: str) -> None:
_write(install / "credentials.bin", agent_token)
def write_firebird_credential(install_dir_path: Path, user: str, password: str) -> None:
"""Firebird user/password -> firebird.bin via DPAPI LocalMachine + ACL (fail closed).
Local only: this never reaches the Hub and never lands in agent.env."""
from gti_fiscal_agent.credential_store import write_credential as _write
install = Path(install_dir_path)
install.mkdir(parents=True, exist_ok=True)
_write(install / "firebird.bin", json.dumps({"user": user, "password": password}))
def detect_existing(install_dir_path: Path) -> dict | None:
"""An already-enrolled installation (identity + DPAPI credential present)."""
install = Path(install_dir_path)
env_file, cred = install / "agent.env", install / "credentials.bin"
if not env_file.exists() or not cred.exists():
return None
values = {}
for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
if not values.get("AGENT_ID"):
return None
return {"agent_id": values["AGENT_ID"], "installation_id": values.get("INSTALLATION_ID"), "hub_url": values.get("GTI_HUB_URL") or hub_url(), "env": values}
def upgrade_agent_env(install_dir_path: Path, agent_version: str) -> None:
"""Migrate an existing agent.env to the central-control model: drop the legacy local
AGENT_JOBS_ENABLED toggle, refresh AGENT_VERSION, keep every other value (identity, Firebird path...)."""
env_file = Path(install_dir_path) / "agent.env"
kept, seen_version = [], False
for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines():
key = line.partition("=")[0].strip()
if key == "AGENT_JOBS_ENABLED":
continue
if key == "AGENT_VERSION":
line, seen_version = f"AGENT_VERSION={agent_version}", True
kept.append(line)
if not seen_version:
kept.append(f"AGENT_VERSION={agent_version}")
env_file.write_text(chr(10).join(kept) + chr(10), encoding="utf-8")
def wait_service_state(wanted: str, timeout: float = 45.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if wanted in _sc(["query", SERVICE_NAME]).stdout:
return True
time.sleep(1.5)
return False
def wait_for_heartbeat(agent_token: str, hub: str, timeout: float = 120.0) -> bool:
"""True once the Hub sees this Agent ONLINE (first heartbeat after the service started)."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
response = httpx.get((hub or hub_url()).rstrip("/") + "/v1/agents/me", headers={"Authorization": f"Bearer {agent_token}"}, timeout=10)
if response.status_code == 200 and response.json().get("status") == "ONLINE":
return True
except Exception: # noqa: BLE001
pass
time.sleep(4)
return False
def _sc(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(["sc.exe", *args], capture_output=True, text=True)
+227 -63
View File
@@ -1,26 +1,23 @@
"""Setup.exe — universal Windows installer for the GTI Fiscal Agent.
"""Setup.exe — universal, zero-touch Windows installer for the GTI Fiscal Agent.
Two entry modes:
- GUI (default): tkinter wizard with a single install-code field.
- CLI (`--cli --code XXXX [--hub URL] [--install-dir PATH] [--no-service]`):
scripted install used by the automated E2E and the build smoke test.
The operator runs it ONCE. Nothing has to be edited, started or scheduled afterwards.
Run elevated (PyInstaller --uac-admin). Enrollment is resolved by the Hub from
the install-code hash alone — no enrollment token, no bundle.
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
Flow on success:
1. POST /v1/agents/enroll {install_code, hostname, agent_version, machine_fingerprint}
2. write agent.env (non-secret only) + credentials.bin (DPAPI LocalMachine + ACL)
3. extract the bundled Service host (onedir) + Uninstall.exe + write executor
into the install directory
4. register service GTIFiscalAgent (Delayed Auto) + failure recovery
5. Firebird not configured -> report "serviço instalado; configure o arquivo
de banco para ativar" and do NOT start the service (no error loop)
6. write ARP uninstall key
7. best-effort WINDOWS_INSTALL_COMPLETED audit
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; agent.env never holds the token; no UI or
flag enables Firebird/AI writes (always dry-run, writes disabled).
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
@@ -31,22 +28,61 @@ import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
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 install_flow(code: str, *, hub: str | None = None, install_dir_path: str | None = None, register_service: bool = True) -> dict:
"""Perform the full install. Returns the enroll result dict; raises RuntimeError
with a user-facing message on any failure."""
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:
@@ -60,29 +96,71 @@ def install_flow(code: str, *, hub: str | None = None, install_dir_path: str | N
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 "")
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)
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
if register_service:
common.install_service(install)
common.write_arp_uninstall(install)
if common.service_running():
common.start_service()
if chosen: # without a Firebird database the service would only fail preflight in a loop
progress("Iniciando o serviço…")
if not common.service_running():
common.start_service()
started = common.wait_service_state("RUNNING", timeout=45)
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")
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["install_dir"] = str(install)
result["agent_version"] = agent_version
result.update(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.stop_service()
common.wait_service_state("STOPPED", timeout=45)
extract_payload(install)
common.upgrade_agent_env(install, version)
common.install_service(install)
common.write_arp_uninstall(install)
os.environ["AGENT_VERSION"] = version
progress("Iniciando o serviço…")
common.start_service()
started = common.wait_service_state("RUNNING", timeout=45)
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, "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."""
@@ -103,72 +181,151 @@ def extract_payload(install: Path) -> None:
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 ("Instalação concluída, mas o serviço não iniciou.\n\nVerifique o log em "
f"{result.get('install_dir')}\\logs\\agent.log ou contate o suporte.")
class SetupWizard:
def __init__(self, root: tk.Tk):
self.root = root
root.title("GTI Fiscal Agent")
root.resizable(False, False)
root.geometry("720x250")
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")
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")
self.code_var = tk.StringVar()
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")
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="Instalar", command=self.on_install)
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 _set_busy(self, busy: bool, text: str = ""):
self.status.config(text=text)
self.install_btn.config(state="disabled" if busy else "normal")
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 code:
if not self.existing and not code:
self.status.config(text="Informe o código de instalação.", foreground="#b00")
return
self._set_busy(True, "Enviando código ao hub…")
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:
result = install_flow(code)
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._set_busy(False)
messagebox.showerror("GTI Fiscal Agent", str(error))
self._busy(False)
self._say(str(error), "#b00")
self.root.after(0, lambda: messagebox.showerror("GTI Fiscal Agent", str(error)))
return
self._set_busy(False)
install = result["install_dir"]
messagebox.showinfo(
"GTI Fiscal Agent",
"Serviço GTI Fiscal Agent instalado com sucesso.\n\n"
"Firebird não está configurado — o serviço não foi iniciado.\n"
f"Local: {install}\n"
"Configure o arquivo de banco em agent.env para ativar.",
)
self.root.destroy()
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", required=True, help="XXXX-XXXX-XXXX-XXXX install code")
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:
@@ -176,8 +333,15 @@ def cli() -> int:
if args.install_dir:
os.environ["GTI_INSTALL_DIR"] = args.install_dir
result = install_flow(args.code, hub=args.hub, install_dir_path=args.install_dir, register_service=not args.no_service)
print(f"INSTALL_OK agent_id={result.get('agent_id')} install_dir={result.get('install_dir')} hub={result.get('hub_url')}")
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']}")
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')}")
return 0
@@ -195,4 +359,4 @@ def main():
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())