Files
GTI_GERENCIADOR_FISCAL/fiscal/central/app/installation_identity.py
T
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

147 lines
6.3 KiB
Python

"""Stable installation identity and safe operational metadata helpers."""
from __future__ import annotations
import hashlib
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from .config import settings
SECRET_KEYS = {"password", "fb_password", "token", "agent_token", "api_key", "secret", "authorization", "connection_string"}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def ensure_installation_id(state_path: str, *, new_installation: bool = False) -> str:
"""Persist a UUID beside the local Agent state without using hostname/IP."""
path = Path(state_path)
identity_path = path.with_suffix(path.suffix + ".installation_id")
if identity_path.exists() and not new_installation:
value = identity_path.read_text(encoding="utf-8").strip()
try:
return str(uuid.UUID(value))
except ValueError:
pass
value = str(uuid.uuid4())
identity_path.parent.mkdir(parents=True, exist_ok=True)
temporary = identity_path.with_suffix(identity_path.suffix + ".tmp")
temporary.write_text(value + "\n", encoding="utf-8")
temporary.replace(identity_path)
return value
def mask_cnpj(value: str | None) -> str | None:
digits = re.sub(r"\D", "", value or "")
if len(digits) != 14:
return None if not value else "***"
return f"{digits[:2]}.***.***/{digits[8:12]}-{digits[12:]}"
def sanitize_database_path(value: str | None) -> str | None:
if not value:
return None
normalized = str(value).replace("\\", "/")
return normalized.rsplit("/", 1)[-1][:255]
def _safe_value(key: str, value):
if key.lower() in SECRET_KEYS:
return "[REDACTED]"
if isinstance(value, dict):
return {str(k): _safe_value(str(k), v) for k, v in value.items() if str(k).lower() not in SECRET_KEYS}
if isinstance(value, list):
return [_safe_value(key, item) for item in value[:100]]
return value
def sanitize_registration(payload: dict) -> dict:
allowed = {
"installation_id", "display_name", "customer_code", "notes", "hostname", "windows_version", "machine_fingerprint",
"company_code", "company_name", "company_cnpj", "company_uf", "company_crt", "company_contribuinte_ipi", "firebird_version", "firebird_ods",
"database_identifier", "database_path", "capabilities", "fiscal_engine_version", "dataset_versions", "started_at",
"status", "schema_fingerprint", "agent_version", "last_job_id", "coverage", "health", "event_type", "message", "context",
}
result = {key: _safe_value(key, value) for key, value in payload.items() if key in allowed}
if "company_cnpj" in result:
result["company_cnpj_masked"] = mask_cnpj(result.pop("company_cnpj"))
if "database_path" in result:
result["database_identifier"] = sanitize_database_path(result.pop("database_path"))
if result.get("installation_id"):
result["installation_id"] = str(uuid.UUID(str(result["installation_id"])))
return result
def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental=None) -> tuple[str, str]:
now = now or utcnow()
last_seen = getattr(snapshot, "last_seen_at", None)
if last_seen is None:
return "UNKNOWN", "no heartbeat received"
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
source = supplemental or snapshot
if any(getattr(candidate, "agent_health", None) == "FAILED" or getattr(candidate, "last_status", None) == "FAILED" for candidate in (snapshot, supplemental) if candidate is not None):
return "FAILED", "persistent technical failure"
offline_after = settings.agent_offline_after_seconds or settings.agent_heartbeat_interval_seconds * 3
if (now - last_seen).total_seconds() > offline_after:
return "OFFLINE", f"last heartbeat exceeds {offline_after}s"
if getattr(source, "failed_products", 0) or getattr(source, "last_job_status", "") == "FAILED":
return "DEGRADED", "recent technical or job failure"
return "ONLINE", "recent heartbeat without critical technical failure"
_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.]?(rc|beta|alpha|b|a)[-_.]?(\d*))?$", re.IGNORECASE)
_PRE_RANK = {"alpha": -3, "a": -3, "beta": -2, "b": -2, "rc": -1}
def parse_version(value: str | None):
"""Comparable tuple for '1.1.7', '1.0.0rc1', ... (a pre-release sorts below its final); None if unparsable."""
match = _VERSION_RE.match((value or "").strip())
if not match:
return None
parts = [int(x) for x in match.group(1).split(".")]
while len(parts) < 4:
parts.append(0)
pre = (_PRE_RANK[match.group(2).lower()], int(match.group(3) or 0)) if match.group(2) else (0, 0)
return (*parts, *pre)
def reference_agent_version(session) -> str:
"""The version an Agent should be running: the highest PUBLISHED Windows Agent release (REVOKED/DRAFT are
never a target). Falls back to the configured server version when no release is published."""
from sqlalchemy import select
from .models import AgentRelease
best, best_key = None, None
for version in session.scalars(select(AgentRelease.version).where(AgentRelease.status == "PUBLISHED", AgentRelease.platform == "windows")):
key = parse_version(version)
if key is not None and (best_key is None or key > best_key):
best, best_key = version, key
return best or settings.agent_server_version
def compute_version_drift(agent_version: str | None, reference: str | None = None) -> str:
"""CURRENT when the Agent is at (or ahead of) the reference release, OUTDATED when behind it."""
if not agent_version:
return "UNKNOWN"
reference = reference or settings.agent_server_version
have, want = parse_version(agent_version), parse_version(reference)
if have is None or want is None:
return "CURRENT" if agent_version == reference else "OUTDATED"
return "CURRENT" if have >= want else "OUTDATED"
def outdated_versions(session, reference: str) -> list[str]:
"""Distinct Agent versions in use that are behind the reference (used to keep SQL filters exact)."""
from sqlalchemy import select
from .models import Agent
used = [v for v in session.scalars(select(Agent.agent_version).where(Agent.agent_version.is_not(None)).distinct())]
return [v for v in used if compute_version_drift(v, reference) == "OUTDATED"]