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
131 lines
6.2 KiB
Python
131 lines
6.2 KiB
Python
"""Fase 5 — the external handover package: one archive a stranger can deploy into their own
|
|
Microsoft Foundry with minimal friction.
|
|
|
|
**Why an archive and not "clone the repo".** The receiving party is not a contributor: they get a
|
|
tree, set two environment variables and deploy it. The measured docker build context has always been
|
|
``git archive HEAD`` (Dockerfile header), so the package is that SAME tree — never a hand-curated
|
|
copy, which would be the second copy that drifts (the kø-(p) rule applied to a deliverable).
|
|
|
|
Three seams are gated here, and each one is a way the handover fails in the receiver's hands rather
|
|
than in ours:
|
|
|
|
1. **Completeness** — a tree missing ``uv.lock`` resolves different versions than every measurement
|
|
in this repo ran against; missing ``shared/`` gives a container with no example knowledge base.
|
|
2. **Exposure** — ``STATE.md``, ``*.local.md`` and ``.env`` must never leave this machine. The
|
|
archive is built from tracked files only, so this is a property of the BUILDER; the control below
|
|
proves the check looks for names that could actually appear.
|
|
3. **The deploy contract is written down** — the packaged ``model_map.json`` ships
|
|
``REPLACE-WITH-*`` placeholders and ``backends.py`` fail-fasts on them, so a receiver who sets
|
|
only an endpoint gets a container that answers ``/readiness`` and fails every ``/invocations``.
|
|
4e measured that requirement and called it "et deploy-krav ingen rad hadde skrevet ned"; DEPLOY.md
|
|
is that row, and this test is what keeps it written.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
_SCRIPT = _REPO_ROOT / "scripts" / "make-handover-package.sh"
|
|
|
|
# Deploy-critical members. Each one is load-bearing for a receiver, not decoration:
|
|
# the two manifests, the entry point, the locked resolution, the packaged data and the example base.
|
|
_REQUIRED_MEMBERS = (
|
|
"Dockerfile",
|
|
"azure.yaml",
|
|
"main.py",
|
|
"pyproject.toml",
|
|
"uv.lock",
|
|
"DEPLOY.md",
|
|
"src/portfolio_optimiser/hosting.py",
|
|
"src/portfolio_optimiser/backends.py",
|
|
"src/portfolio_optimiser/data/model_map.json",
|
|
"shared/examples/bygg-energi-mikro/index.md",
|
|
)
|
|
|
|
# Names that must NEVER reach a stranger. Local-only continuity, operator config, secrets.
|
|
_FORBIDDEN_SUFFIXES = (".local.md", ".env")
|
|
_FORBIDDEN_NAMES = ("STATE.md",)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile:
|
|
"""Build the real package by running the real script — the packaging config is itself a seam
|
|
(the 4a precedent: an ``uv build`` in the fixture, never a simulated one)."""
|
|
dest = tmp_path_factory.mktemp("handover")
|
|
result = subprocess.run(
|
|
[str(_SCRIPT), str(dest)],
|
|
cwd=_REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, f"builder failed: {result.stderr}"
|
|
archives = sorted(dest.glob("*.zip"))
|
|
assert len(archives) == 1, f"expected exactly one archive, got {archives}"
|
|
return zipfile.ZipFile(archives[0])
|
|
|
|
|
|
def test_package_carries_every_deploy_critical_file(package: zipfile.ZipFile) -> None:
|
|
"""Detach point: drop a member from the archive → RED. A receiver cannot supply what we omit."""
|
|
names = set(package.namelist())
|
|
missing = [m for m in _REQUIRED_MEMBERS if m not in names]
|
|
assert not missing, f"handover package is missing {missing}"
|
|
|
|
|
|
def test_package_leaks_no_local_or_secret_files(package: zipfile.ZipFile) -> None:
|
|
"""Detach point: build from the working tree instead of tracked files → RED (STATE.md appears).
|
|
|
|
The control is the point: a filter that matched nothing would make this gate green forever, so
|
|
we first prove the archive is populated and that the suffixes we forbid are ones the repo
|
|
actually produces (``STATE.md`` exists on this machine, untracked-by-design)."""
|
|
names = package.namelist()
|
|
assert len(names) > 50, "archive suspiciously small — the check below would be vacuous"
|
|
assert (_REPO_ROOT / "STATE.md").exists(), (
|
|
"control: STATE.md must exist locally, else this gate cannot discriminate"
|
|
)
|
|
leaked = [
|
|
n 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}"
|
|
|
|
|
|
def test_deploy_doc_names_both_required_env_vars(package: zipfile.ZipFile) -> None:
|
|
"""Detach point: remove either variable from DEPLOY.md → RED.
|
|
|
|
Line-anchored, not substring: ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` CONTAINS
|
|
``FOUNDRY_PROJECT_ENDPOINT``, so a naive substring assert on the platform-injected name is
|
|
satisfied by our own (the 08-09 defect class, measured twice before in this repo)."""
|
|
doc = package.read("DEPLOY.md").decode("utf-8")
|
|
lines = doc.splitlines()
|
|
|
|
assert any("PORTFOLIO_MODEL_MAP" in line for line in lines), (
|
|
"DEPLOY.md must name PORTFOLIO_MODEL_MAP — without it the container fail-fasts on the "
|
|
"REPLACE-WITH-* placeholders"
|
|
)
|
|
# The injected name must appear on a line that is NOT merely our own name.
|
|
injected_lines = [
|
|
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"
|
|
|
|
|
|
def test_deploy_doc_states_the_placeholder_requirement(package: zipfile.ZipFile) -> None:
|
|
"""Detach point: drop the placeholder warning → RED. The packaged map ships REPLACE-WITH-*, so a
|
|
receiver who is not told will deploy a container that fails every invocation."""
|
|
doc = package.read("DEPLOY.md").decode("utf-8")
|
|
packaged_map = package.read("src/portfolio_optimiser/data/model_map.json").decode("utf-8")
|
|
|
|
# Control: the requirement is only real while the packaged map actually ships placeholders.
|
|
assert "REPLACE-WITH-" in packaged_map, (
|
|
"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"
|
|
)
|