"""S4.1 — Azure/Foundry offline preflight (D2/D6): validate everything checkable WITHOUT a model call before the operator pays for one. Runs ``python -m portfolio_optimiser.preflight --profile azure``. It checks, purely offline (config/string/env only — NO client construction, NO network, NO auto-login): (1) the endpoint env ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` is set and shaped like a Foundry project endpoint (``https://`` + host ``*.services.ai.azure.com``); (2) the effective model-map (honoring ``PORTFOLIO_MODEL_MAP``) is structurally valid (``ModelMapContract``); (3) no azure deployment is still a ``REPLACE-WITH-*`` placeholder (via ``resolve_model`` — the SAME seam the run path uses, so preflight and run never validate different maps). **Necessary-but-not-sufficient (honesty):** a green preflight rules out the offline-detectable misconfig class; it does NOT prove the paid live call will succeed. RBAC (403), token/tenant/consent (401), a well-formed-but-nonexistent deployment (404), and api-version skew surface only at the live call. The verified auth recipe (``az login`` / ``AzureCliCredential``; the ``Foundry User`` RBAC role; endpoint form) lives in ``docs/2026-07-15-foundry-auth-recipe.md`` — deliberately NOT in this docstring, so the no-network/no-auto-login AST guard in ``tests/test_preflight_loadbearing.py`` stays clean. Load-bearing: that guard + the refusal/placeholder teeth. """ from __future__ import annotations import os import sys from dataclasses import dataclass from pydantic import ValidationError from portfolio_optimiser.backends import Profile, _load_effective_map, resolve_model from portfolio_optimiser.contracts import ModelMapContract _ENDPOINT_ENV = "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT" _FOUNDRY_HOST_SUFFIX = ".services.ai.azure.com" _ROLES = ("default", "proposer", "checker") # Exact operator-facing disclaimer marker (Norwegian, per docs-language convention). The docs note # carries the English "necessary-but-not-sufficient" marker; each guard pins its own document. _DISCLAIMER = "ikke tilstrekkelig: RBAC/token/tenant/deployment sjekkes først ved live-kall" @dataclass(frozen=True) class PreflightOK: """Every offline-checkable Azure/Foundry precondition holds. Distinct type from a refusal.""" profile: Profile @dataclass(frozen=True) class PreflightRefusal: """A blocked precondition, carrying the actionable reason. Cannot be consumed as a ``PreflightOK`` (mirror ``validator.Rejection``).""" reason: str def _endpoint_error() -> str | None: """Return an actionable reason if the endpoint env is missing/misshapen, else ``None``. Pure string work — no ``urllib`` (both the NFR and the offline grep-guard forbid it).""" endpoint = os.environ.get(_ENDPOINT_ENV) if not endpoint: return f"{_ENDPOINT_ENV} er ikke satt (påkrevd for azure-profilen)" if not endpoint.startswith("https://"): return f"{_ENDPOINT_ENV} må være en https://-URL, fikk: {endpoint!r}" # Host = between the scheme and the first '/', minus any port; lowercased. Do NOT require the # '/api/projects/' path (official samples omit it — research §3); accept the bare host. host = endpoint[len("https://") :].split("/", 1)[0].split(":", 1)[0].lower() if not host.endswith(_FOUNDRY_HOST_SUFFIX): return ( f"{_ENDPOINT_ENV} host {host!r} er ikke en Foundry-project-endpoint " f"(*{_FOUNDRY_HOST_SUFFIX}); *.openai.azure.com / *.cognitiveservices.azure.com er feil " "klient-flate (bruk OpenAIChatClient, ikke FoundryChatClient)" ) return None def check_azure_preflight(profile: Profile | str = Profile.AZURE) -> PreflightOK | PreflightRefusal: """Offline Azure/Foundry preflight: endpoint env-contract + model-map consistency + placeholder refusal. Pure config/string/env — NO client construction, NO network. Every config error becomes a structured ``PreflightRefusal`` (never a traceback), so a bad ``PORTFOLIO_MODEL_MAP`` refuses cleanly.""" try: prof = Profile(profile) if prof is not Profile.AZURE: return PreflightRefusal( f"preflight --profile {prof.value}: kun 'azure' støttes " "(S4.1 offline Foundry-preflight; local-profilen trenger ingen preflight)" ) endpoint_err = _endpoint_error() if endpoint_err: return PreflightRefusal(endpoint_err) # Model-map consistency — the SAME effective map resolve_model uses (no divergence). ModelMapContract(**_load_effective_map()) # Placeholder refusal: resolving each role raises ValueError on a REPLACE-WITH-* id. for role in _ROLES: resolve_model(prof, role) except (OSError, ValidationError, ValueError) as exc: # OSError subsumes FileNotFoundError AND PermissionError: an existing-but-unreadable # PORTFOLIO_MODEL_MAP (read_text after is_file()) must refuse cleanly, never traceback. return PreflightRefusal(str(exc)) return PreflightOK(prof) def main(argv: list[str] | None = None) -> int: """CLI entry: ``python -m portfolio_optimiser.preflight --profile azure`` — offline Azure/Foundry config preflight. rc 0 = OK (with the necessary-but-not-sufficient disclaimer to stdout), rc 1 = structured refusal to stderr. No network, no auto-login.""" import argparse parser = argparse.ArgumentParser( prog="portfolio_optimiser.preflight", description="Offline Azure/Foundry-preflight (S4.1) — validerer endpoint, model-map og " "deployment-navn UTEN modellkall/nettverk før operatøren betaler for en live-kjøring.", ) parser.add_argument("--profile", default="azure", help="backend-profil (azure)") args = parser.parse_args(argv) result = check_azure_preflight(args.profile) if isinstance(result, PreflightRefusal): print(f"preflight: {result.reason}", file=sys.stderr) return 1 print(f"preflight OK ({result.profile.value}) — men {_DISCLAIMER}") return 0 if __name__ == "__main__": # pragma: no cover - console entry raise SystemExit(main())