nomyo-router/compliance/settings.py

98 lines
4 KiB
Python

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