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.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 14:09:35 +02:00
commit 605c02aa62
10 changed files with 215 additions and 205 deletions

View file

@ -2,15 +2,24 @@
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).
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 -(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
**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 -(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 container with no example knowledge base.
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.
@ -19,6 +28,10 @@ than in ours:
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
@ -33,10 +46,10 @@ _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.
# 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 = (
"Dockerfile",
"azure.yaml",
"main.py",
"pyproject.toml",
"uv.lock",
@ -51,6 +64,25 @@ _REQUIRED_MEMBERS = (
_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:
@ -129,3 +161,63 @@ def test_deploy_doc_states_the_placeholder_requirement(package: zipfile.ZipFile)
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}"
)