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())