"""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"]