"""Startup guards: the VISUAL target must match STACK, and insecure LDAP is never silent.""" from __future__ import annotations import logging from dataclasses import dataclass from typing import Any from .settings import Settings log = logging.getLogger(__name__) _TARGET_FIELDS = {"server", "database", "profile"} class StackGuardError(Exception): """The configuration must not start.""" @dataclass class GuardResult: target: str # e.g. "VSQL\DEV / PESCOINCDEV (DEV)" production: bool explicit: bool warnings: list[str] def load_visual_settings() -> Any: """visualkit's own Settings (VISUAL_* variables). Tests monkeypatch this.""" from visualkit.config import Settings as VisualSettings return VisualSettings() def check_stack(settings: Settings, visual: Any | None = None) -> GuardResult: """Raise StackGuardError when STACK and the VISUAL target disagree.""" if visual is None: visual = load_visual_settings() production = bool(visual.is_production) explicit = bool(_TARGET_FIELDS & set(getattr(visual, "model_fields_set", set()))) target = f"{visual.server} / {visual.database} ({'PRODUCTION' if production else 'DEV'})" warnings: list[str] = [] if settings.stack != "prod" and production: raise StackGuardError(f"STACK={settings.stack} but the VISUAL target is production: {target}") if settings.stack == "prod" and not production: if not explicit: raise StackGuardError( "STACK=prod but no VISUAL target is set (VisualKit would default to DEV); " "set VISUAL_PROFILE=prod or VISUAL_SERVER and VISUAL_DATABASE" ) warnings.append(f"STACK=prod reads a non-production VISUAL target: {target}") if settings.ldap_allow_insecure: warnings.append(f"LDAP_ALLOW_INSECURE=true on STACK={settings.stack}: LDAP may run without TLS") return GuardResult(target, production, explicit, warnings) def startup_checks(settings: Settings) -> GuardResult: """Run at app and worker start: raise on a bad stack, log every warning.""" result = check_stack(settings) for w in result.warnings: log.warning(w) return result