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:
@@ -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())
|
||||
Reference in New Issue
Block a user