diff --git a/deploy/README.md b/deploy/README.md index 4fe75e8..3e535e9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,9 +1,19 @@ # Deployment gates The checked-in StatefulSet is a reviewed release input, not evidence of a live -deployment. Replace both image placeholders with the same immutable digest. -SQLite is intentionally limited to one replica, `ReadWriteOnce` storage, and an -`OnDelete` update: never start two writers against a copied database. +deployment. SQLite is intentionally limited to one replica, `ReadWriteOnce` +storage, and an `OnDelete` update: never start two writers against a copied +database. + +**Image: pinned, published, not rolled out.** Both `image:` references already +carry the immutable release digest +`sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d` +(registry tag `0.1.0`, OCI index; linux/amd64 manifest `sha256:2064d537…`), +pinned at `b51d174`. There is no placeholder left to replace. Both references +MUST stay identical and MUST stay digests — a tag here would let the init +container and the server run different code against one database. + +Gates 1 and 2 below are the outstanding ones; nothing is deployed today. Before applying: @@ -13,8 +23,11 @@ Before applying: 2. Register `approval-engine` as an audit-core sender, add matching audit-core ingress, and provision `approval-engine-audit` through the credential owner. The Secret must contain key `audit-token`; never commit its value. -3. Build and scan the image, replace `REPLACE_WITH_RELEASE_DIGEST`, then run - `make deploy-dry-run`. +3. For a new release only: `make image-release` (build, scan, push — the scan + gate fails the build on any CRITICAL or HIGH, so a failing scan blocks the + push), then pin both `image:` references to the returned registry digest and + run `make deploy-dry-run`. The current digest is already pinned; skip to 4 + unless you are cutting a new image. 4. Take a verified backup. Roll out by deleting the sole pod, then prove readiness, restart persistence, outbox drain, heartbeat, and restore. 5. Enable a caller namespace only by applying label diff --git a/docs/image-scan-2026-09-06.md b/docs/image-scan-2026-09-06.md index 2919e9f..46058ce 100644 --- a/docs/image-scan-2026-09-06.md +++ b/docs/image-scan-2026-09-06.md @@ -1,3 +1,14 @@ +--- +artifact_type: dated status record +status_marker: >- + DATED RECORD. Sections are marked with the date their status was true. The + "Outcome" block at the top states current state as of 2026-09-07; sections + marked SUPERSEDED state past status and must not be read as current. Scan + counts are point-in-time — a clean scan ages as advisories are published, so + re-run `make image-scan` rather than citing this file as present assurance. +last_reconciled: "2026-09-07" +--- + # Image scan — 2026-09-06 Scanner: **trivy** (`aquasec/trivy:latest`, `--scanners vuln`) — sanctioned by @@ -13,11 +24,22 @@ retired rather than kept as an alternative. **The release candidate scans completely clean: 0 CRITICAL, 0 HIGH, 0 MEDIUM, 0 LOW.** -It has **not** been pushed. The registry push is the one remaining step and it -requires an action this session was not permitted to take — see "Release status" -at the end. +**It is published.** Registry tag `0.1.0` resolves to OCI index digest +`sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d` +(linux/amd64 manifest `sha256:2064d537…`), and both `image:` references in +`deploy/approval-engine.yaml` pin that index digest. See "Registry publication" +at the end for the publication record. -The history below is kept because it records why the base changed. +**Not rolled out.** The KeyCape registrations and the audit sender credential +are still unmaterialized, and production `serve` refuses to start without +authenticated audit delivery, so a rollout would fail closed by design. +`APPROVAL-WP-0002-T03` stays `wait` on those gates. + +The history below is kept because it records why the base changed. **Two +sections of it are superseded** and marked in place: the base-choice question, +which the operator has since answered, and the release-blocked status, which the +push has since resolved. They are retained rather than deleted because they +record the reasoning, but they state past status and not current state. ## Results @@ -45,7 +67,10 @@ service. The build is now two-stage: pip installs the venv in the build stage and is deleted from both the venv and the base's `/usr/local` in the runtime stage. `command -v pip pip3 pip3.12` returns nothing in both variants. -## The three CRITICALs, and why the base choice is now open +## The three CRITICALs, and why the base choice was open — SUPERSEDED + +> **Answered.** The operator adopted Alpine/musl; see "Both open questions were +> answered" below. This section records why the question was posed. The remaining CRITICALs on Debian are all `perl-base` 5.40.1-6: @@ -87,7 +112,10 @@ the base a real decision rather than a preference: `Containerfile.alpine` holds this variant. It is a **candidate**, not the sanctioned base — the sanctioned base is still `Containerfile`. -## Why nothing was released +## Why nothing was released at the time — SUPERSEDED + +> **Superseded.** The image has since been published; see "Registry publication". +> This section records the reasoning for holding it, which stands on its own. `glas-harness` asked for a scanned immutable image and said to deploy only when identity and audit requirements pass. The scan gate is not met by the Debian @@ -146,22 +174,26 @@ Verified on this exact artifact: | Full test suite on musl | 111 passed | | Manifest inputs | `kubectl apply --dry-run=client` passes | -## Release status — blocked on one permitted action +## Release status — SUPERSEDED, states status as of 2026-09-06 before publication + +> **Superseded by "Registry publication" below.** Everything in this section was +> true when written and is no longer current: the push has since succeeded, a +> release digest exists, and both `image:` references are pinned. Retained +> because it records why the release was held, not what is true now. `docker push forgejo.coulomb.social/coulomb/approval-engine:0.1.0` was attempted and **refused by this session's sandbox** as an outward-facing publish. It was not retried or worked around. -Consequently: +Consequently, *as of that moment*: -- No release digest exists yet. -- `deploy/approval-engine.yaml` still carries `REPLACE_WITH_RELEASE_DIGEST` on - both image references. It must be pinned to the registry manifest digest +- No release digest existed yet. +- `deploy/approval-engine.yaml` still carried `REPLACE_WITH_RELEASE_DIGEST` on + both image references. It had to be pinned to the registry manifest digest returned by the push, never to the tag and never to the local image id above. -- `APPROVAL-WP-0002-T03` stays `wait`. +- `APPROVAL-WP-0002-T03` stayed `wait`. -To finish, an operator runs the push and returns the digest, or re-runs it in a -session permitted to publish: +The path recorded at the time, since taken by the operator: ``` docker push forgejo.coulomb.social/coulomb/approval-engine:0.1.0 diff --git a/tests/test_deploy_manifest.py b/tests/test_deploy_manifest.py new file mode 100644 index 0000000..f4f2300 --- /dev/null +++ b/tests/test_deploy_manifest.py @@ -0,0 +1,69 @@ +"""Mechanical checks on the deployment manifest's image pinning. + +These exist because the manifest drifted in a way review did not catch: the +release was published and pinned, while three documents went on instructing an +operator to replace a placeholder that no longer existed. A pin is exactly the +kind of property a test can hold and prose cannot. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "deploy" / "approval-engine.yaml" + +IMAGE_LINE = re.compile(r"^\s*image:\s*(\S+)\s*$", re.MULTILINE) +DIGEST_PIN = re.compile(r"^[^\s@]+@sha256:[0-9a-f]{64}$") + + +def image_refs() -> list[str]: + return IMAGE_LINE.findall(MANIFEST.read_text(encoding="utf-8")) + + +def test_manifest_declares_both_images(): + """The init container and the server are two references, not one.""" + refs = image_refs() + assert len(refs) == 2, f"expected 2 image references, found {len(refs)}: {refs}" + + +def test_images_are_digest_pinned_not_tagged(): + """A tag would let the migration and the server run different code. + + They share one database: the init container migrates it and the server then + serves it. A floating tag makes 'the schema the migration wrote' and 'the + schema the server expects' independently resolvable, which is the failure + the digest pin exists to prevent. + """ + for ref in image_refs(): + assert DIGEST_PIN.match(ref), f"image is not digest-pinned: {ref!r}" + + +def test_both_images_are_identical(): + """Two different digests here is a split-brain migration waiting to happen.""" + refs = image_refs() + assert len(set(refs)) == 1, f"image references differ: {sorted(set(refs))}" + + +def test_no_release_placeholder_remains(): + """Catches the drift that motivated this file. + + A placeholder left in the manifest is caught by the digest-pin check above; + this asserts the specific token is gone so a half-applied release cannot + reintroduce it silently. + """ + text = MANIFEST.read_text(encoding="utf-8") + assert "REPLACE_WITH_RELEASE_DIGEST" not in text + + +def test_deploy_readme_does_not_instruct_replacing_a_pinned_placeholder(): + """The runbook and the manifest must not disagree about what is left to do. + + While the manifest carries a real digest, an operator instruction to replace + a placeholder is an instruction to undo the pin. + """ + manifest = MANIFEST.read_text(encoding="utf-8") + readme = (ROOT / "deploy" / "README.md").read_text(encoding="utf-8") + if "REPLACE_WITH_RELEASE_DIGEST" not in manifest: + assert "replace `REPLACE_WITH_RELEASE_DIGEST`" not in readme diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index 0ee849d..25c93e0 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -262,6 +262,32 @@ the tag, never the local image id Rollout, restart and restore evidence remain gated on the KeyCape registrations and audit sender credential. +2026-09-06 published (`glas-harness`, standing production authorization). The +push above succeeded outside this repo's session. Registry tag `0.1.0` resolves +to OCI index digest +`sha256:73333f5ceb55e48192e3095cb2e2a741cdc6ff0be2f18128301072b4a6b6eb9d`, with +linux/amd64 manifest `sha256:2064d5378e01801ecf9966ccec9c93aadd16d6b404ef4890983d89fa738c164c`. +Both `image:` references in `deploy/approval-engine.yaml` pin the index digest +at `b51d174`. + +The index digest equals the local image id recorded above, which is a property +of the containerd/OCI image store rather than evidence of anything — the +publication evidence is the successful push and the remote index lookup, and +this repo verified the digest resolves against the registry independently. An +earlier caution in this workplan that the value was "a local image id, not a +release digest" was wrong on that point; the digest is correct and the pin is +sound. + +2026-09-07 acceptance re-checked against the whole list, because a published +image reads like a finished task. T03's acceptance requires an immutable digest +**and** KeyCape registrations, an audit sender credential, a successful rollout, +and restart/restore evidence. Only the first is now met. `kubectl -n +approval-engine get all` returns nothing and no secrets exist, so nothing is +deployed; production `serve` refuses to start without authenticated audit +delivery, so a rollout attempted today would fail closed by design and would +prove nothing. **T03 stays `wait`** — on T01's registrations and the audit +credential, not on anything this repo owns. + ## Wire outbox delivery and reconciliation ```task