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}"
)

View file

@ -18,11 +18,11 @@ Load-bearing surface pinned here:
* The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src``
ratchets src/ thread-free) these tests run client and server as coroutines on the SAME loop,
which only works because nothing in the server blocks it.
* Root ``main.py`` is the ONE process entry (Dockerfile CMD + azure.yaml point at it): the
* Root ``main.py`` is the ONE process entry (``python main.py``, the command DEPLOY.md prints): the
subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler
(P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester).
Fase 4e closes two gaps the above leaves open, and both are about things a stand-in cannot see:
Fase 4e closed two gaps the above leaves open, and both are about things a stand-in cannot see:
* **The whitelist composes with the REAL ``run_project``.** Every test above hands ``invoke`` a
stand-in that swallows ``**kwargs``, so the whitelist could name a field ``run_project`` does not
@ -33,14 +33,11 @@ Fase 4e closes two gaps the above leaves open, and both are about things a stand
PURPOSE (a caller must never choose the server's model client), so patching the factory the run
falls back to is the only injection point this surface leaves the same argument
``test_run_cli_loadbearing`` makes for ``main()``.
* **The deployment artifacts are raw-text-gated.** ``Dockerfile`` and ``azure.yaml`` are the two
files that decide whether the image the platform pulls can run at all, and NO test executes
them here (``docker build``/``azd deploy`` are operator-gated). A raw-text gate is therefore the
only mechanism available: it pins ``--platform linux/amd64`` (measured required spike §1.4;
an arm64 image built on this Intel-free-of-charge assumption would fail only in the cloud) and
the ONE-copy rule for the start command (the image's ``CMD``; ``azure.yaml`` carries no
``startupCommand`` to drift from it). Guard-tester leser kildefiler som TEKST reformulate
the prose around them, never the strings they pin.
* **The deployment artifacts were raw-text-gated** until 14.08, when the operator directive
after an external trial made the delivery runnable Python and the two artifacts were removed
from the tree. The gate is deleted, not weakened; see the note where it stood, below the
end-to-end test. The start command now has exactly one copy left, in DEPLOY.md, and
``tests/test_handover_package_loadbearing.py`` is what keeps it there.
"""
from __future__ import annotations
@ -381,8 +378,8 @@ def _blocking_get(url: str) -> tuple[int, str]:
def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
"""Root main.py is the ONE process entry (Dockerfile CMD + azure.yaml point at it): started
as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
"""Root main.py is the ONE process entry (``python main.py``, the command DEPLOY.md prints):
started as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
catches a shim that stops calling hosting.main() or a detached SIGTERM handler."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
@ -415,7 +412,7 @@ def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
proc.wait()
# --- Fase 4e: the whitelist against the REAL run_project, and the deployment artifacts ----------
# --- Fase 4e: the whitelist against the REAL run_project -----------------------------------------
@pytest.fixture()
@ -491,51 +488,9 @@ async def test_invocations_answers_through_the_real_run_project(
assert _scripted_backend, "the scripted backend was never called — no real run happened"
def test_deployment_artifacts_pin_the_measured_platform_and_one_start_command() -> None:
"""RAW-TEXT GATE on the two files that decide whether the hosted image runs at all.
Neither is executed by any test: ``docker build`` and ``azd deploy`` are operator-gated, so a
regression in them is invisible to the whole suite until it fails in the cloud. What the gate
pins is exactly what was MEASURED, and nothing about the prose around it:
* ``--platform linux/amd64`` the platform requires x86_64 (spike §1.4). Dropping it makes the
image inherit the builder's architecture, which on an arm64 laptop yields an image that
builds green locally and cannot start in the cloud. The flag lives in the documented build
command (a Dockerfile cannot set the build platform for its own invocation), so pinning the
documented string is the only gate available and it is worth having precisely because
nothing else re-derives it.
* ONE copy of the start command: the image's ``CMD`` names ``main.py``, and ``azure.yaml``
declares NO ``startupCommand``. Two copies drift (-(p)-regelen); this is the pair that
keeps there being one.
``env:`` is checked for the same reason: ``FOUNDRY_PROJECT_ENDPOINT`` is injected by the
platform, and redeclaring it here could shadow the injected value which is the failure mode
Fase 4b's endpoint precedence exists to avoid, undone from the config side."""
dockerfile = _REPO_ROOT / "Dockerfile"
azure_yaml = _REPO_ROOT / "azure.yaml"
assert dockerfile.is_file(), "the hosted image has no build definition"
assert azure_yaml.is_file(), "azd has no project definition to deploy"
docker_text = dockerfile.read_text(encoding="utf-8")
azure_text = azure_yaml.read_text(encoding="utf-8")
assert "--platform linux/amd64" in docker_text, (
"the Dockerfile no longer names the measured build platform; an image built without it "
"inherits the builder's architecture and cannot start on the hosting platform"
)
assert "CMD" in docker_text and "main.py" in docker_text, (
"the image's CMD is the ONE copy of the start command and must name main.py"
)
# Line-anchored: a mention inside a comment is prose, a top-level key is a declaration.
azure_keys = [line.split(":")[0].strip() for line in azure_text.splitlines()]
assert "startupCommand" not in azure_keys, (
"azure.yaml declares a startupCommand — a SECOND copy of the start command, free to drift "
"from the image's CMD"
)
assert "env" not in azure_keys, (
"azure.yaml declares an env block — FOUNDRY_PROJECT_ENDPOINT is injected by the platform "
"and must never be redeclared here"
)
assert "host: azure.ai.agent" in azure_text and "kind: hosted" in azure_text, (
"azure.yaml no longer declares the hosted-agent host this whole entrypoint targets"
)
# The 4e raw-text gate on ``Dockerfile``/``azure.yaml`` lived here until 14.08. It pinned
# ``--platform linux/amd64`` and the one-copy rule for the image's ``CMD``. Both files were removed
# from the tree that day (operator directive: the delivery is runnable Python), and a gate that pins
# a surface we no longer ship is deleted with it rather than weakened into something that can only
# be green. What replaced it lives in ``tests/test_handover_package_loadbearing.py``: the package
# must carry NO container/azd wrapper and must document the Python start command.

View file

@ -14,8 +14,8 @@ made in prose that no test could see, and both drift silently:
Both gates read the source artefacts as RAW TEXT, because that is the only thing that can see prose.
Both are LINE-ANCHORED rather than substring-matched: ``backends.py`` NAMES ``DefaultAzureCredential``
four times in the comments that explain why it is not used, so a whole-file substring check would be
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the 4e
``azure.yaml`` gate is line-anchored too).
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the handover
package's python-only gate matches archive member NAMES rather than document prose).
Each positive assertion is paired with a CONTROL that the thing being searched for is actually
present. An extractor that silently finds nothing makes a gate that can only ever be green, which