feat(s41): offline Azure/Foundry preflight CLI (env-contract + placeholder refusal)
This commit is contained in:
parent
82c85d5e7c
commit
871999a55b
2 changed files with 227 additions and 0 deletions
123
src/portfolio_optimiser/preflight.py
Normal file
123
src/portfolio_optimiser/preflight.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""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/<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 (FileNotFoundError, ValidationError, ValueError) as exc:
|
||||||
|
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())
|
||||||
104
tests/test_preflight.py
Normal file
104
tests/test_preflight.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""S4.1 preflight — unit + CLI tests: happy path, refusals, structured error handling, doc-guard.
|
||||||
|
|
||||||
|
Load-bearing detach seams (AST no-network/no-auto-login guard + refusal teeth) live in
|
||||||
|
``test_preflight_loadbearing.py``; these are the happy-path + behaviour tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser import preflight
|
||||||
|
|
||||||
|
_VALID_ENDPOINT = "https://x.services.ai.azure.com/api/projects/p"
|
||||||
|
_VALID_MAP = {
|
||||||
|
"local": {"default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b"},
|
||||||
|
"azure": {"default": "gpt-4o-mini", "proposer": "gpt-4o-mini", "checker": "gpt-4o-mini"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_map(tmp_path: Path, data: dict) -> Path:
|
||||||
|
p = tmp_path / "model_map.json"
|
||||||
|
p.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_sc1_placeholder_refusal(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# SC1: bundled azure map ships REPLACE-WITH-* → refusal naming the deployment (endpoint valid,
|
||||||
|
# so the placeholder is the sole failure). Value-pinned per test_costsim.py:95-106.
|
||||||
|
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
||||||
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
|
||||||
|
rc = preflight.main(["--profile", "azure"])
|
||||||
|
assert rc == 1
|
||||||
|
assert "REPLACE-WITH-FOUNDRY-DEPLOYMENT" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_sc2_happy_path(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# SC2: complete valid override map + URL-shaped endpoint → rc 0, stdout carries the exact
|
||||||
|
# necessary-but-not-sufficient marker.
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
|
||||||
|
rc = preflight.main(["--profile", "azure"])
|
||||||
|
assert rc == 0
|
||||||
|
assert "ikke tilstrekkelig" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_sc3_missing_endpoint(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# SC3: unset endpoint → rc 1 + message names the env var.
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
||||||
|
rc = preflight.main(["--profile", "azure"])
|
||||||
|
assert rc == 1
|
||||||
|
assert "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_host_surface_refused(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# A URL-valid but wrong-surface endpoint (*.openai.azure.com) is refused, naming the host.
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "https://x.openai.azure.com/foo")
|
||||||
|
rc = preflight.main(["--profile", "azure"])
|
||||||
|
assert rc == 1
|
||||||
|
assert "openai.azure.com" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_host_endpoint_accepted(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
# Research §3: official samples use the bare-host form (no /api/projects/) — must be accepted.
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "https://x.services.ai.azure.com")
|
||||||
|
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_override_file_is_structured_refusal(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# A bad PORTFOLIO_MODEL_MAP must NOT traceback — structured refusal, rc 1.
|
||||||
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", "/nonexistent/model_map.json")
|
||||||
|
rc = preflight.main(["--profile", "azure"])
|
||||||
|
assert rc == 1
|
||||||
|
assert "model map not found" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_override_is_structured_refusal(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
# Override missing the azure block → ModelMapContract ValidationError → structured refusal.
|
||||||
|
bad = _write_map(tmp_path, {"local": {"default": "qwen3:4b"}})
|
||||||
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(bad))
|
||||||
|
rc = preflight.main(["--profile", "azure"])
|
||||||
|
assert rc == 1
|
||||||
|
assert capsys.readouterr().err.strip()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue