feat(5): overleveringspakke for eksterne — git archive HEAD + DEPLOY.md

Én zip en mottakende organisasjon deployer uten å klone repoet eller ha konto her.
Arkivet er git archive HEAD (tracked files only), som er SAMME tre den målte
docker-build-konteksten bruker — og grunnen til at STATE.md/*.local.md/.env ikke kan
komme inn: de er gitignorert, ikke filtrert bort av et filter vi må vedlikeholde.

DEPLOY.md svarer mottakerens tre første spørsmål: hvem gjør hva (plattform-operatør,
bestiller, fagperson), prosessen ende-til-ende, og hvorfor det ikke finnes et
chat-grensesnitt. Den navngir også deploy-kravet 4e målte men aldri skrev ned:
pakket model_map.json bærer REPLACE-WITH-*, så uten PORTFOLIO_MODEL_MAP starter
containeren, svarer på /readiness og feiler hver invocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 10:43:28 +02:00
commit a3300ab0f6
5 changed files with 385 additions and 0 deletions

View file

@ -0,0 +1,129 @@
"""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 -(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"