"""Configuration model for the aissurance compliance plugin. Populated from the ``compliance`` block of the Router's ``config.yaml`` (see config.Config). ``${VAR}`` references in the YAML are already expanded by ``Config._expand_env_refs`` before this model is constructed; on top of that, the aissurance-specific environment variables (``AISSURANCE_KEY`` etc.) are honoured as defaults so the plugin works with env-only configuration too. This module imports nothing from the Router, so config.py can import it without creating a cycle. """ import os import socket from typing import Optional from pydantic import BaseModel, Field, field_validator def _env_or_none(name: str) -> Optional[str]: val = os.getenv(name) return val if val else None class ComplianceSettings(BaseModel): """The ``compliance:`` config block.""" enabled: bool = False # Full URL of the discovery receive endpoint. Health and config-report URLs # are derived from this by replacing the trailing ``/receive`` path. server_url: str = "https://www.aissurance.eu/api/v1/discovery/receive" # Tenant-provisioned secret (AISSURANCE_KEY). Used both as the Bearer token # and as the HMAC signing key unless ``hmac_secret`` overrides the latter. api_key: Optional[str] = Field(default_factory=lambda: _env_or_none("AISSURANCE_KEY")) # Optional per-tenant HMAC secret override (AISSURANCE_HMAC_SECRET). hmac_secret: Optional[str] = Field(default_factory=lambda: _env_or_none("AISSURANCE_HMAC_SECRET")) # Tenant UUID, if not embedded in the key (AISSURANCE_TENANT_ID). tenant_id: Optional[str] = Field(default_factory=lambda: _env_or_none("AISSURANCE_TENANT_ID")) # Unique identifier for this Router instance. Multiple instances per tenant # are allowed, so this defaults to the hostname rather than the tenant id. router_id: str = Field(default_factory=socket.gethostname) polling_interval: int = 300 # seconds between discovery snapshots health_interval: int = 3600 # seconds between health/config-sync calls batch_size: int = 50 # max models per payload max_retry_attempts: int = 10 # in-cycle send retries before buffering retry_backoff_base: int = 2 # exponential backoff base (seconds) buffer_dir: str = "./compliance-buffer" # persistent encrypted retry storage max_buffer_payloads: int = 100 # oldest dropped first when full # TLS verification. AISSURANCE_VERIFY_TLS=0 disables it (dev only). verify_tls: bool = Field( default_factory=lambda: os.getenv("AISSURANCE_VERIFY_TLS", "1") != "0" ) @field_validator("api_key", "hmac_secret", "tenant_id", mode="before") @classmethod def _empty_to_none(cls, v): # config.yaml ``${VAR}`` expansion yields "" for unset vars; treat as None # so the env-var default_factory can take over. if v == "": return None return v @property def discovery_base_url(self) -> str: """Base discovery URL, derived by stripping a trailing ``/receive``.""" url = self.server_url.rstrip("/") if url.endswith("/receive"): url = url[: -len("/receive")] return url @property def receive_url(self) -> str: return self.server_url @property def health_url(self) -> str: tid = self.tenant_id or "unknown" return f"{self.discovery_base_url}/health/{tid}" @property def config_report_url(self) -> str: return f"{self.discovery_base_url}/config/report" def validation_errors(self) -> list[str]: """Return human-readable reasons the plugin cannot run, or [] if OK.""" errors: list[str] = [] if not self.api_key: errors.append("AISSURANCE_KEY / compliance.api_key is not set") if not self.server_url.lower().startswith("https://"): errors.append(f"server_url must be HTTPS (got {self.server_url!r})") if self.polling_interval < 60: errors.append("polling_interval must be >= 60 seconds") return errors