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
This commit is contained in:
parent
a3300ab0f6
commit
88c223276c
3 changed files with 91 additions and 12 deletions
|
|
@ -2,9 +2,11 @@
|
||||||
call before the operator pays for one.
|
call before the operator pays for one.
|
||||||
|
|
||||||
Runs ``python -m portfolio_optimiser.preflight --profile azure``. It checks, purely offline
|
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
|
(config/string/env only — NO client construction, NO network, NO auto-login): (1) an endpoint is set
|
||||||
``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` is set and shaped like a Foundry project endpoint
|
under EITHER name the run path accepts (``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` first, then the
|
||||||
(``https://`` + host ``*.services.ai.azure.com``); (2) the effective model-map (honoring
|
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
|
``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
|
still a ``REPLACE-WITH-*`` placeholder (via ``resolve_model`` — the SAME seam the run path uses, so
|
||||||
preflight and run never validate different maps).
|
preflight and run never validate different maps).
|
||||||
|
|
@ -26,10 +28,14 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from portfolio_optimiser.backends import Profile, _load_effective_map, resolve_model
|
from portfolio_optimiser.backends import _ENDPOINT_ENVS, Profile, _load_effective_map, resolve_model
|
||||||
from portfolio_optimiser.contracts import ModelMapContract
|
from portfolio_optimiser.contracts import ModelMapContract
|
||||||
|
|
||||||
_ENDPOINT_ENV = "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT"
|
# 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"
|
_FOUNDRY_HOST_SUFFIX = ".services.ai.azure.com"
|
||||||
_ROLES = ("default", "proposer", "checker")
|
_ROLES = ("default", "proposer", "checker")
|
||||||
# Exact operator-facing disclaimer marker (Norwegian, per docs-language convention). The docs note
|
# Exact operator-facing disclaimer marker (Norwegian, per docs-language convention). The docs note
|
||||||
|
|
@ -52,12 +58,28 @@ class PreflightRefusal:
|
||||||
reason: str
|
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:
|
def _endpoint_error() -> str | None:
|
||||||
"""Return an actionable reason if the endpoint env is missing/misshapen, else ``None``. Pure
|
"""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)."""
|
string work — no ``urllib`` (both the NFR and the offline grep-guard forbid it)."""
|
||||||
endpoint = os.environ.get(_ENDPOINT_ENV)
|
endpoint = _resolve_endpoint()
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return f"{_ENDPOINT_ENV} er ikke satt (påkrevd for azure-profilen)"
|
# 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://"):
|
if not endpoint.startswith("https://"):
|
||||||
return f"{_ENDPOINT_ENV} må være en https://-URL, fikk: {endpoint!r}"
|
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
|
# Host = between the scheme and the first '/', minus any port; lowercased. Do NOT require the
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,7 @@ def test_package_leaks_no_local_or_secret_files(package: zipfile.ZipFile) -> Non
|
||||||
"control: STATE.md must exist locally, else this gate cannot discriminate"
|
"control: STATE.md must exist locally, else this gate cannot discriminate"
|
||||||
)
|
)
|
||||||
leaked = [
|
leaked = [
|
||||||
n
|
n for n in names if Path(n).name in _FORBIDDEN_NAMES or n.endswith(_FORBIDDEN_SUFFIXES)
|
||||||
for n in names
|
|
||||||
if Path(n).name in _FORBIDDEN_NAMES or n.endswith(_FORBIDDEN_SUFFIXES)
|
|
||||||
]
|
]
|
||||||
assert not leaked, f"handover package leaks local-only files: {leaked}"
|
assert not leaked, f"handover package leaks local-only files: {leaked}"
|
||||||
|
|
||||||
|
|
@ -111,7 +109,9 @@ def test_deploy_doc_names_both_required_env_vars(package: zipfile.ZipFile) -> No
|
||||||
)
|
)
|
||||||
# The injected name must appear on a line that is NOT merely our own name.
|
# The injected name must appear on a line that is NOT merely our own name.
|
||||||
injected_lines = [
|
injected_lines = [
|
||||||
line for line in lines if "FOUNDRY_PROJECT_ENDPOINT" in line.replace("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "")
|
line
|
||||||
|
for line in lines
|
||||||
|
if "FOUNDRY_PROJECT_ENDPOINT" in line.replace("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "")
|
||||||
]
|
]
|
||||||
assert injected_lines, "DEPLOY.md must name the platform-injected FOUNDRY_PROJECT_ENDPOINT"
|
assert injected_lines, "DEPLOY.md must name the platform-injected FOUNDRY_PROJECT_ENDPOINT"
|
||||||
|
|
||||||
|
|
@ -126,4 +126,6 @@ def test_deploy_doc_states_the_placeholder_requirement(package: zipfile.ZipFile)
|
||||||
assert "REPLACE-WITH-" in packaged_map, (
|
assert "REPLACE-WITH-" in packaged_map, (
|
||||||
"control: packaged model_map no longer has placeholders — this gate would be vacuous"
|
"control: packaged model_map no longer has placeholders — this gate would be vacuous"
|
||||||
)
|
)
|
||||||
assert "REPLACE-WITH-" in doc, "DEPLOY.md must state that the packaged deployment ids are placeholders"
|
assert "REPLACE-WITH-" in doc, (
|
||||||
|
"DEPLOY.md must state that the packaged deployment ids are placeholders"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -155,3 +155,58 @@ def test_auth_recipe_doc_exists_and_names_facts() -> None:
|
||||||
assert "Foundry User" in text
|
assert "Foundry User" in text
|
||||||
assert "services.ai.azure.com" in text
|
assert "services.ai.azure.com" in text
|
||||||
assert "necessary-but-not-sufficient" in text
|
assert "necessary-but-not-sufficient" in text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fase 5: preflight and the run path must know the SAME endpoint variables -----------------
|
||||||
|
# Before this, preflight read ONLY our own name while backends.py accepted the platform-injected
|
||||||
|
# one as a fallback. Inside a hosted Foundry container — where the platform injects
|
||||||
|
# FOUNDRY_PROJECT_ENDPOINT and nothing else — the gate therefore refused a configuration the run
|
||||||
|
# path would have accepted. That is the repo's own "checker and run path validate differently"
|
||||||
|
# defect class, and it is friction the receiver of the handover package pays.
|
||||||
|
|
||||||
|
_INJECTED_ENV = "FOUNDRY_PROJECT_ENDPOINT"
|
||||||
|
_OURS_ENV = "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_injected_endpoint_alone_is_accepted(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A hosted container sets only the platform name. Detach the fallback → RED (refuses)."""
|
||||||
|
monkeypatch.delenv(_OURS_ENV, raising=False)
|
||||||
|
monkeypatch.setenv(_INJECTED_ENV, _VALID_ENDPOINT)
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
|
||||||
|
|
||||||
|
|
||||||
|
def test_our_name_wins_over_the_injected_one(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Precedence mirrors backends.py: ours first. The injected value here is a WRONG host, so a
|
||||||
|
green result can only mean ours was read — the two arms are distinguishable."""
|
||||||
|
monkeypatch.setenv(_OURS_ENV, _VALID_ENDPOINT)
|
||||||
|
monkeypatch.setenv(_INJECTED_ENV, "https://wrong.openai.azure.com/")
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedence_is_over_values_not_declarations(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""An exported-but-EMPTY own name must fall through to a real injected one rather than shadow
|
||||||
|
it into a refusal (the 4b rule, same seam)."""
|
||||||
|
monkeypatch.setenv(_OURS_ENV, "")
|
||||||
|
monkeypatch.setenv(_INJECTED_ENV, _VALID_ENDPOINT)
|
||||||
|
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
|
||||||
|
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_endpoint_refusal_names_both_variables(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Line-anchored, not substring: PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT CONTAINS
|
||||||
|
FOUNDRY_PROJECT_ENDPOINT, so a message naming only ours satisfies a naive assert (the repo's
|
||||||
|
08-09 defect class). Strip our name before looking for the injected one."""
|
||||||
|
monkeypatch.delenv(_OURS_ENV, raising=False)
|
||||||
|
monkeypatch.delenv(_INJECTED_ENV, raising=False)
|
||||||
|
result = preflight.check_azure_preflight("azure")
|
||||||
|
assert isinstance(result, preflight.PreflightRefusal)
|
||||||
|
assert _OURS_ENV in result.reason
|
||||||
|
assert _INJECTED_ENV in result.reason.replace(_OURS_ENV, "")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue