portfolio-optimiser/tests/test_handover_package_loadbearing.py
Kjell Tore Guttormsen 605c02aa62 feat(5): overleveringspakka er KUN kjørbar Python — container-innpakningen fjernet
Operatørdirektiv 14.08 etter ekstern test: mottakeren skal få kjørbar Python, ikke en
Docker-innpakking. `Dockerfile` og `azure.yaml` er SLETTET fra treet.

Sømmen er valgt av den eksisterende invarianten, ikke av smak: pakka ER `git archive HEAD`,
så å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme — den andre kopien av
«hva mottakeren får», fri til å drifte fra HEAD (kø-(p)). Fjerning holder arkivet ukurert og
gjør fraværet til en egenskap ved HEAD, som er det eneste en gate kan måle.

De to gatene som pinnet flaten er håndtert bevisst:
- 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen) er SLETTET, med
  et notat der den sto. En gate som pinner en fjernet flate kan bare bli grønn.
- handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en
  POSITIV fraværs-assert pluss en dokument-gate. Å kun slutte å KREVE dem ville gitt en gate
  som ikke kan skille «fjernet» fra «shippes fortsatt».

Startkommandoen har nå ÉN kopi igjen: DEPLOY.md-ens `python main.py`, som navngir inngangen
subprosess-testen faktisk kjører.
2026-08-14 14:09:35 +02:00

223 lines
12 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 run it. The package is ``git archive HEAD`` itself — never a
hand-curated copy, which would be the second copy that drifts (the kø-(p) rule applied to a
deliverable).
**Runnable Python, no container wrapper (operator directive 14.08, from an external trial).** What a
receiver gets is a Python tree they install and start themselves — ``uv sync --frozen`` +
``python main.py`` — and the ``Dockerfile``/``azure.yaml`` pair that used to ride along was removed
from the tree rather than filtered out of the archive. Filtering would have meant a curation
mechanism deciding what a receiver sees, i.e. a SECOND copy of "what is delivered" free to drift
from HEAD (the kø-(p) rule, which is the very reason this package is ``git archive HEAD``). Removing
the files keeps the archive uncurated and makes the absence a property of HEAD — which is what the
gate below can actually measure.
Four 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 receiver 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.
4. **Python-only delivery** — the archive carries no container/azd wrapper, and DEPLOY.md starts the
service the way the tree actually supports: as a Python process. A gate that merely stopped
REQUIRING ``Dockerfile`` could not tell "removed" from "still shipped", so the check is positive
(absence, asserted) rather than an omission.
"""
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 entry point, the locked resolution, the packaged data and the example base. `Dockerfile` and
# `azure.yaml` were REQUIRED here until 14.08; they are gone from the tree, and their absence is now
# asserted below instead of their presence.
_REQUIRED_MEMBERS = (
"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",)
# The container/azd wrapper. Matched on archive MEMBER NAMES, never on prose: the documents may
# explain that no image is shipped, and a gate that read the word out of a sentence would be red on
# exactly the prose it protects (this repo's 08-09 defect class).
_CONTAINER_WRAPPER_NAMES = ("Dockerfile", ".dockerignore", "docker-compose.yml", "azure.yaml")
# The start command a receiver is told to run. With no image CMD left, the document IS the one copy —
# and it names the entry point whose serve/SIGTERM behaviour test_hosting_loadbearing measures.
_PYTHON_START = "python main.py"
# Command invocations that would put a container step back into the documented path. Fragments, not
# the bare word: "no container image is shipped" must stay sayable.
_WRAPPER_COMMANDS = ("docker build", "docker run", "azd up", "azd deploy", "azd provision")
def _members_named(names: list[str], wanted: tuple[str, ...]) -> list[str]:
"""Archive members whose basename is one of ``wanted`` — the matcher both the assertion and its
control run through, so a matcher that silently matches nothing cannot pass unnoticed."""
return [n for n in names if Path(n).name in wanted]
@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"
)
def test_package_ships_runnable_python_and_no_container_wrapper(package: zipfile.ZipFile) -> None:
"""Detach point: put ``Dockerfile`` or ``azure.yaml`` back into HEAD → RED.
The 14.08 operator directive is that the deliverable is runnable Python. Removing the wrapper
from the TREE (rather than filtering it out of the archive) is what makes that measurable here:
the package is ``git archive HEAD``, so absence in the archive IS absence in what we ship.
Two controls, because a name-matcher that matches nothing would make this green forever:
the run path a receiver actually needs must be present, and the matcher must be shown to match
a member this archive really has."""
names = package.namelist()
# Control 1 — the Python run path is what replaces the image. If these are missing, "no
# container" would just mean "nothing to run".
for member in ("main.py", "pyproject.toml", "uv.lock"):
assert member in names, f"the runnable-Python path is incomplete: {member} is not packaged"
# Control 2 — the matcher matches by basename against THIS archive, proven on a member we know
# is there. Without it, a matcher comparing full paths would find nothing and pass silently.
assert _members_named(names, ("main.py",)) == ["main.py"], (
"control: the member matcher found nothing for a member the archive demonstrably has"
)
wrappers = _members_named(names, _CONTAINER_WRAPPER_NAMES)
assert not wrappers, (
f"the handover package ships a container/azd wrapper: {wrappers}. The delivery is runnable "
"Python (operator directive 14.08); a wrapper here is a second, unmeasured way to start it."
)
def test_receiver_documents_start_the_service_as_a_python_process(package: zipfile.ZipFile) -> None:
"""Detach point: tell the receiver to build an image again in either document → RED.
Both documents ride inside the archive, so both are the receiver's instructions. Each is checked
LINE-ANCHORED with its own positive control first: a negative assertion on a document the
extractor failed to read is a gate that can only be green."""
deploy = package.read("DEPLOY.md").decode("utf-8").splitlines()
readme = package.read("README.md").decode("utf-8").splitlines()
# Positive control on DEPLOY.md: with no image CMD left, this document carries the ONE copy of
# the start command, and it must name the entry point the hosting tests actually exercise.
assert [line for line in deploy if _PYTHON_START in line], (
f"DEPLOY.md no longer prints the start command ({_PYTHON_START!r}) — the receiver has "
"nothing to run, and the one copy of the start command is gone"
)
# Positive control on README.md: proves this document was read and searched at all.
assert [line for line in readme if "uv sync" in line], (
"control: README.md has no install line — the check below would be searching nothing"
)
for label, lines in (("DEPLOY.md", deploy), ("README.md", readme)):
offenders = [
line for line in lines if any(fragment in line for fragment in _WRAPPER_COMMANDS)
]
assert not offenders, (
f"{label} instructs a container/azd build step that this package no longer ships: "
f"{offenders}"
)