portfolio-optimiser/src/portfolio_optimiser/preflight.py
Kjell Tore Guttormsen 88c223276c fix(5): preflight kjenner samme endepunkt-variabler som kjørestien [skip-docs]
Målt fra den utpakkede overleveringspakka: med KUN plattformens injiserte
FOUNDRY_PROJECT_ENDPOINT — altså nøyaktig situasjonen i en hostet Foundry-container —
avslo preflight en konfigurasjon backends.py ville godtatt. Gaten og kjørestien kjente
ulike navn; det er repoets egen «checker og kjøresti validerer ulikt»-klasse, og for
mottakeren av pakka er det et falskt avslag på riktig oppsett.

_ENDPOINT_ENVS IMPORTERES nå fra backends i stedet for å gjentas, så de to kan ikke
drifte fra hverandre igjen. Presedens over VERDIER, ikke deklarasjoner: et eksportert-men-
tomt eget navn faller igjennom i stedet for å skygge et ekte injisert inn i en fail-fast.
Avslaget navngir BEGGE variablene.

Iron Law: 3 røde diskriminatorer + 1 grønn kontroll FØR fiksen. 854 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
2026-08-14 10:48:45 +02:00

147 lines
7.3 KiB
Python

"""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) an endpoint is set
under EITHER name the run path accepts (``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` first, then the
platform-injected ``FOUNDRY_PROJECT_ENDPOINT`` — same tuple, imported from ``backends``) and is
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 _ENDPOINT_ENVS, Profile, _load_effective_map, resolve_model
from portfolio_optimiser.contracts import ModelMapContract
# Fase 5 — the SAME tuple the run path resolves against, imported rather than restated. A second
# copy here is how the gate and the run path came to know different variable names in the first
# place: preflight refused a hosted container's platform-injected endpoint that backends.py would
# have accepted (measured from the extracted handover package, 14.08).
_ENDPOINT_ENV = _ENDPOINT_ENVS[0]
_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 _resolve_endpoint() -> str | None:
"""First NON-EMPTY of ``_ENDPOINT_ENVS`` — ours first, the platform-injected name as fallback.
Precedence over VALUES, not declarations: an exported-but-empty own name falls through instead
of shadowing a real injected one into a refusal (the 4b rule, same seam as ``backends.py``)."""
for name in _ENDPOINT_ENVS:
value = os.environ.get(name)
if value:
return value
return None
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 = _resolve_endpoint()
if not endpoint:
# Name BOTH: the operator on a laptop and the operator in a hosted container are looking
# for different variables (the fail-fast in ``backends.py`` says the same thing).
return (
f"{_ENDPOINT_ENVS[0]} (eller plattformens injiserte {_ENDPOINT_ENVS[1]}) "
"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/<project>' 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())