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:
parent
817bcf130f
commit
a3300ab0f6
5 changed files with 385 additions and 0 deletions
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -531,6 +531,25 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
||||||
import), eksempelet er gyldig pipeline-input inkl. `FeedbackContract` (RØD på skjema-/kontrakt-drift,
|
import), eksempelet er gyldig pipeline-input inkl. `FeedbackContract` (RØD på skjema-/kontrakt-drift,
|
||||||
på en throwaway-kopi — aldri den git-tracked fixturen), og sim-ens markør følger artefakt-fila (RØD i
|
på en throwaway-kopi — aldri den git-tracked fixturen), og sim-ens markør følger artefakt-fila (RØD i
|
||||||
det øyeblikk personaen re-inlines).
|
det øyeblikk personaen re-inlines).
|
||||||
|
- **Overleverings-pakka ER `git archive HEAD`, aldri en kuratert kopi (Fase 5):**
|
||||||
|
`scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone
|
||||||
|
repoet. **Tracked files only er hele eksponerings-kontrollen** — `STATE.md`, `*.local.md` og
|
||||||
|
`.env` er gitignorert, så de KAN ikke komme inn; et filter vedlikeholdt i skriptet ville vært den
|
||||||
|
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Det er dessuten
|
||||||
|
SAMME tre som den målte docker-build-konteksten (Dockerfile-headeren), så mottakeren deployer det
|
||||||
|
vi målte. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
|
||||||
|
nøyaktig som README-ens wheel-filnavn gjorde (Fase 3). `DEPLOY.md` ligger i treet og blir dermed
|
||||||
|
med i arkivet av seg selv; den bærer 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** (flaten er `POST /invocations`, og `as_agent()` er bevisst vraket
|
||||||
|
fordi validator, baseline-forankring, checker-gate og ledger ligger UTENFOR grafen — et chat-lag
|
||||||
|
ville rutet forespørsler rundt nøyaktig det som gjør svaret etterprøvbart). Den navngir også det
|
||||||
|
4e målte deploy-kravet som ingen rad hadde skrevet ned: pakket `model_map.json` bærer
|
||||||
|
`REPLACE-WITH-*`, så uten `PORTFOLIO_MODEL_MAP` starter containeren, svarer på `/readiness` og
|
||||||
|
feiler HVER invocation. Gaten er `tests/test_handover_package_loadbearing.py`, og
|
||||||
|
DEPLOY.md-asserten er LINJEFORANKRET: `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER
|
||||||
|
`FOUNDRY_PROJECT_ENDPOINT`, så en delstreng-assert på det injiserte navnet ville vært oppfylt av
|
||||||
|
vårt eget (repoets 08-09-klasse, femte gang).
|
||||||
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
|
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
|
||||||
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
||||||
|
|
||||||
|
|
|
||||||
189
DEPLOY.md
Normal file
189
DEPLOY.md
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
# Deploying and using this package
|
||||||
|
|
||||||
|
This archive is a complete, self-contained copy of `portfolio-optimiser`. You do not need to clone
|
||||||
|
anything or have an account anywhere to deploy it. Unzip it and follow this document.
|
||||||
|
|
||||||
|
It is written in English because its readers are implementing developers and platform operators.
|
||||||
|
The two documents that describe the *human* side of the loop are written in Norwegian and ship in
|
||||||
|
this same archive: [`docs/bestille-en-kjoring.md`](docs/bestille-en-kjoring.md) (ordering a run) and
|
||||||
|
[`docs/ekspert-svar.md`](docs/ekspert-svar.md) (giving the verdict).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this is — and what it is not
|
||||||
|
|
||||||
|
It finds cost savings **inside** a single project: agents propose candidate measures, a
|
||||||
|
**deterministic validator** decides whether the numbers hold, a second agent (the "checker") gates
|
||||||
|
the *reasoning*, and a domain expert gives the final verdict. The system learns from those verdicts.
|
||||||
|
|
||||||
|
**There is no chat interface, and that is a design decision rather than a missing feature.**
|
||||||
|
|
||||||
|
The hosted surface is a task API — `POST /invocations` with a JSON body, `GET /readiness` — not a
|
||||||
|
conversation. The system is deliberately **not** exposed as a Microsoft Foundry agent via
|
||||||
|
`as_agent()`, because the validator, the cost-baseline anchoring, the checker gate and the savings
|
||||||
|
ledger all live *outside* the agent graph. Wrapping it as a chat agent would route requests around
|
||||||
|
precisely the components that make an answer trustworthy, and you would get a fluent assistant
|
||||||
|
instead of a validated result.
|
||||||
|
|
||||||
|
The dialogue with humans happens in two places instead, both file-shaped and both auditable:
|
||||||
|
|
||||||
|
- **before** a run, through a mandate file (what should be considered), and
|
||||||
|
- **after** a run, through a verdict file (what a qualified person decided).
|
||||||
|
|
||||||
|
If you want an interactive front end, build it on top of `POST /invocations` — but keep the verdict
|
||||||
|
step human. An approval loop that a model can close by itself is not an approval loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Who does what
|
||||||
|
|
||||||
|
The package has three distinct audiences. Only the first one is technical.
|
||||||
|
|
||||||
|
| Role | What they do | Where it is documented |
|
||||||
|
|---|---|---|
|
||||||
|
| **Platform operator** | Deploys the container into Microsoft Foundry, sets two environment variables, verifies with the offline preflight. One-time work. | This document, sections 3–5. |
|
||||||
|
| **Ordering domain expert** (*bestiller*) | Writes the mandate file: the objective, which approaches are worth trying, and why. The *why* is the part the system cannot read out of cost figures. | `docs/bestille-en-kjoring.md` |
|
||||||
|
| **Reviewing domain expert** (*fagperson*) | Receives a validated proposal and answers `approved` or `rejected` with a written rationale. That verdict is what the system learns from. | `docs/ekspert-svar.md` |
|
||||||
|
|
||||||
|
**The ordering expert steers what is *considered*, never what is *approved*.** If you ask for
|
||||||
|
something the numbers do not support, the deterministic validator rejects it and the rejection comes
|
||||||
|
back with its reason. That property is the point of the whole design; do not build around it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The process, end to end
|
||||||
|
|
||||||
|
1. **Prepare a knowledge base** for the project — cost documents, technical sources, prior
|
||||||
|
experience — as a document bundle. A working five-document example ships in
|
||||||
|
`shared/examples/bygg-energi-mikro`, and `docs/knowledge-base-recipe.md` describes how to build
|
||||||
|
your own.
|
||||||
|
2. **Order a run** with a mandate file (optional but recommended). See
|
||||||
|
`docs/bestille-en-kjoring.md`.
|
||||||
|
3. **Run it** — from the CLI, or by `POST /invocations` against the deployed container (section 5).
|
||||||
|
The run navigates the knowledge base, debates candidate measures, validates the numbers against
|
||||||
|
the project's actual cost baseline, and returns either a **validated proposal** or a
|
||||||
|
**rejection with a reason**. A rejection is a successful run: the negative outcome belongs to the
|
||||||
|
answer, not to the transport, and the API returns HTTP 200 for it.
|
||||||
|
4. **A domain expert judges the outcome** and writes a verdict — either inline during the run, or as
|
||||||
|
a JSON file dropped into an inbox directory afterwards, possibly days later. See
|
||||||
|
`docs/ekspert-svar.md`.
|
||||||
|
5. **Approved verdicts can be promoted into the knowledge base**, so the next run reads them as
|
||||||
|
context and does not re-propose something the organisation has already rejected. Promotion is
|
||||||
|
gated: only an approved verdict can be promoted, and raw agent output never enters the knowledge
|
||||||
|
base on its own.
|
||||||
|
|
||||||
|
Steps 4 and 5 are what make this a learning loop rather than a report generator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Prerequisites in your own tenant
|
||||||
|
|
||||||
|
You need, in Microsoft Foundry:
|
||||||
|
|
||||||
|
1. A **Foundry resource** with project management enabled and a **project** inside it. A Foundry
|
||||||
|
project is required — an Azure OpenAI resource (`kind: OpenAI`) is a different client surface and
|
||||||
|
will not work.
|
||||||
|
2. At least one **model deployment** (any current chat model; a small one is enough).
|
||||||
|
3. The **Foundry User** role (role definition id `53ca6127-db72-4b80-b1b0-d745d6d5456d`) for the
|
||||||
|
identity that will run it, assigned at project scope. Azure `Owner` and `Contributor` grant
|
||||||
|
management permissions only, **not** the data-plane permissions this needs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Configuration — the two variables that matter
|
||||||
|
|
||||||
|
**5.1 The project endpoint.** Resolved as the first non-empty of:
|
||||||
|
|
||||||
|
```
|
||||||
|
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT # yours; wins if set
|
||||||
|
FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside a hosted Foundry container the platform injects the second one and you need to set nothing.
|
||||||
|
Outside it — a laptop, a pipeline, a container you run yourself — set the first. The value looks
|
||||||
|
like `https://<resource>.services.ai.azure.com/api/projects/<project>`.
|
||||||
|
|
||||||
|
**5.2 The model map — this is the one that catches people.** The packaged
|
||||||
|
`src/portfolio_optimiser/data/model_map.json` ships **placeholders**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"azure": { "default": "REPLACE-WITH-FOUNDRY-DEPLOYMENT", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment names are tenant-specific, so they are deliberately never committed. The code
|
||||||
|
**fail-fasts** on a `REPLACE-WITH-` value rather than guessing. Point `PORTFOLIO_MODEL_MAP` at your
|
||||||
|
own file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"local": { "default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b" },
|
||||||
|
"azure": { "default": "my-deployment", "proposer": "my-deployment", "checker": "my-deployment" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If you skip this, the container starts and answers `/readiness` — and fails every invocation. That
|
||||||
|
is the single most likely first-deployment failure, which is why it has its own section.
|
||||||
|
|
||||||
|
**5.3 Verify before you pay for a model call:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m portfolio_optimiser.preflight --profile azure
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code 0 means every offline-checkable precondition holds: endpoint shape, model-map structure,
|
||||||
|
no placeholders left. It is **necessary but not sufficient** — RBAC (403), token or tenant problems
|
||||||
|
(401) and a well-formed but non-existent deployment (404) can only surface at the live call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deploying
|
||||||
|
|
||||||
|
The image must be built for x86_64 regardless of your workstation's architecture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -
|
||||||
|
```
|
||||||
|
|
||||||
|
If you unzipped this package rather than cloning it, build from the directory instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --platform linux/amd64 -t portfolio-optimiser .
|
||||||
|
```
|
||||||
|
|
||||||
|
`azure.yaml` describes the hosted-agent deployment (`azd`). The container serves port 8088 (override
|
||||||
|
with `PORT`), answers `GET /readiness`, accepts `POST /invocations`, and shuts down cleanly on
|
||||||
|
SIGTERM.
|
||||||
|
|
||||||
|
**The invocations payload** is whitelisted against the run signature — unknown fields are refused by
|
||||||
|
name with HTTP 400, never silently ignored:
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `project_id` | yes | The project to analyse. |
|
||||||
|
| `docs_dir` | yes | Source documents. |
|
||||||
|
| `verdict_input` | yes | The expert verdict for this run. |
|
||||||
|
| `bundle_dir` | no | Knowledge base to navigate. |
|
||||||
|
| `profile` | no | Defaults to `azure` in the hosted container. |
|
||||||
|
| `max_rounds` | no | Debate round cap. |
|
||||||
|
| `max_tokens` | no | **Per-invocation token ceiling.** Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. |
|
||||||
|
| `top_k` | no | Retrieval breadth. |
|
||||||
|
|
||||||
|
HTTP 400 means the request was malformed; 500 means the run itself failed, with `error_type` and
|
||||||
|
`error` in the body. A rejected proposal is **200** — see section 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Honest limits
|
||||||
|
|
||||||
|
- **This framework has never been run against a live model in our environment.** Everything above is
|
||||||
|
measured against tests, an offline simulation and container builds. The first live run in *your*
|
||||||
|
tenant is the first live run, full stop.
|
||||||
|
- The system is a **technical framework**. Data protection assessments, lawful basis and risk
|
||||||
|
ownership belong to whoever deploys it — see `README.md` and `SECURITY.md`.
|
||||||
|
- Readiness does not respond during CPU-bound stretches of a run (the optimisation solve).
|
||||||
|
- Chunked request bodies are not supported.
|
||||||
|
- Verdict promotion writes to the knowledge base non-atomically; it assumes a single writing process.
|
||||||
15
README.md
15
README.md
|
|
@ -490,6 +490,21 @@ and `top_k` optional. Unknown fields are refused by name (400), never silently d
|
||||||
credential from the environment at call time, so the same image runs hosted (managed identity)
|
credential from the environment at call time, so the same image runs hosted (managed identity)
|
||||||
and locally (`az login`) without rewiring.
|
and locally (`az login`) without rewiring.
|
||||||
|
|
||||||
|
### Handing it to someone else
|
||||||
|
|
||||||
|
`scripts/make-handover-package.sh` builds one archive a receiving organisation can deploy without
|
||||||
|
cloning this repository or having an account here:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive is `git archive HEAD` — tracked files only, which is the same tree the measured docker
|
||||||
|
build context uses, and the reason local-only files cannot enter it. [`DEPLOY.md`](DEPLOY.md) rides
|
||||||
|
along inside it and answers the receiver's first questions: what the three roles do, what the
|
||||||
|
process is end to end, why there is no chat interface, and the two environment variables that decide
|
||||||
|
whether the first deployment works. Gated by `tests/test_handover_package_loadbearing.py`.
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who
|
- [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who
|
||||||
|
|
|
||||||
33
scripts/make-handover-package.sh
Executable file
33
scripts/make-handover-package.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the external handover package (Fase 5): one archive a receiver deploys into their own
|
||||||
|
# Microsoft Foundry, without cloning this repository or having an account on our Forgejo.
|
||||||
|
#
|
||||||
|
# The archive is git's view of HEAD -- TRACKED FILES ONLY. That is deliberate and it is the whole
|
||||||
|
# exposure control: STATE.md is gitignored, *.local.md is gitignored, .env is gitignored, so they
|
||||||
|
# cannot enter the archive. A filter maintained here would be a second copy of that rule, and the
|
||||||
|
# second copy is the one that drifts (kø-(p)).
|
||||||
|
#
|
||||||
|
# It is also the SAME tree the measured docker build context uses (see the Dockerfile header:
|
||||||
|
# `git archive HEAD | docker build --platform linux/amd64 -`), so what the receiver deploys is what
|
||||||
|
# we measured -- never a hand-curated selection.
|
||||||
|
#
|
||||||
|
# Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/)
|
||||||
|
# Gated by tests/test_handover_package_loadbearing.py.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEST="${1:-dist}"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
|
||||||
|
# Version from pyproject.toml -- the one place the build stamps it. Read, never hardcoded: a second
|
||||||
|
# copy here would go stale at the next bump exactly like the README wheel filename did (Fase 3).
|
||||||
|
VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "make-handover-package: could not read version from pyproject.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT="$DEST/portfolio-optimiser-foundry-$VERSION.zip"
|
||||||
|
|
||||||
|
git archive --format=zip --output "$OUT" HEAD
|
||||||
|
|
||||||
|
echo "$OUT"
|
||||||
129
tests/test_handover_package_loadbearing.py
Normal file
129
tests/test_handover_package_loadbearing.py
Normal 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 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
|
||||||
|
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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue