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:
parent
642ce8ae9a
commit
605c02aa62
10 changed files with 215 additions and 205 deletions
|
|
@ -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 kø-(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 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
|
||||
**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 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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 RÅ 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 (kø-(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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue