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