From 0ef57537ac2abcb2991787af2ccbdfbfe94a0cc5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Nov 2026 11:25:46 +0100 Subject: [PATCH 1/2] INFD-WP-0010-T02: release candidate for present_existing with compact-sitting v14 Candidate files: review policy pin v14 (sha256:8911458f...), the nk-bind-executor present_existing registry row (daily limit 10), and an 8081 ingress peer for exactly sso + app.kubernetes.io/name=bounded-executor. The state-hub row is unchanged and outcome_read stays off. The image pin stays at the live f5588f49 because no owner-CI full-service image exists yet; the founder runbook (deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md) gates the release on it. Adds a side-effect-free present_existing probe and release tests. Nothing applied. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196 --- deploy/lifecycle/README.md | 10 + deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md | 219 ++++++++++++++++++ deploy/lifecycle/admission.candidate.json | 8 +- deploy/lifecycle/intake-submitters.json | 16 ++ deploy/lifecycle/registries.candidate.json | 2 +- deploy/lifecycle/service.candidate.json | 18 +- tests/test_present_existing_release.py | 89 +++++++ tools/present_existing_probe.py | 88 +++++++ ...nt-existing-and-proportionate-decisions.md | 13 ++ 9 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md create mode 100644 tests/test_present_existing_release.py create mode 100644 tools/present_existing_probe.py diff --git a/deploy/lifecycle/README.md b/deploy/lifecycle/README.md index 9a3c7cc..c9f1a9e 100644 --- a/deploy/lifecycle/README.md +++ b/deploy/lifecycle/README.md @@ -210,3 +210,13 @@ container checks pass. Both scans have zero HIGH/CRITICAL findings. Fresh schema-5 backup: 8ac7bbe79de7cdcff5ebc9fa8adc9c49498bf8a3298c8fbc9bd7501be1a379bb. Rollback, if needed: owner Helm prior revision 12 and Argo source dfcef87 together, retaining the current schema-5 database. Evidence: docs/evidence/2026-10-05-lifecycle-discovery/. + +## Present-existing release candidate — prepared 2026-11-01 (not applied) + +INFD-WP-0010-T02, together with access-engine compact-sitting v14. The candidate files carry the v14 review pin +(`sha256:8911458f…fc300fde`), the `nk-bind-executor` present_existing registry row (daily limit 10) and an 8081 +ingress peer for exactly `sso` + `app.kubernetes.io/name=bounded-executor`. The state-hub row is unchanged and +outcome_read stays off. **The image pin is still the live `f5588f49…`, because no owner-CI full-service image +exists yet. Do not sync this candidate until it is replaced:** the live image rejects the new registry row, +which would fail all intake. Founder runbook, verification and rollback: +[RELEASE-INFD-WP-0010-T02.md](RELEASE-INFD-WP-0010-T02.md). diff --git a/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md b/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md new file mode 100644 index 0000000..27e6ed3 --- /dev/null +++ b/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md @@ -0,0 +1,219 @@ +# Release runbook: present_existing for nk-bind-executor + compact-sitting v14 (INFD-WP-0010-T02) + +Prepared 2026-11-01 by the informed-decision owner agent. **Nothing in this file has been applied.** +Founder approval (2026-11-01, relayed by the net-kingdom coordinator): release present_existing together with +access-engine compact-sitting v14. Every command below is founder-run, with +`export KUBECONFIG=$HOME/.kube/config-railiance01`. + +## Status: blocked on the service image (gate G0) + +No CI image exists for current `main`. This repository has no Forgejo workflow that builds the full service +image. Its two workflows build single-purpose overlays on fixed base digests: + +- `.forgejo/workflows/purpose-overlay-image.yaml` swaps one module, `policy.py`, on base `7f5c6cb2…`, and + checks it against the pinned hash `9d7c1be4…`. Commit `80ef840` changed `policy.py` (now `912ad3a3…`), so + run 5 (head `e617bb5`, 2026-11-01T03:07Z) failed at that check. That is the intended behaviour. Even if the + hash were updated, the overlay could not carry this release: it touches nine modules (`intake.py`, + `intake_service.py`, `intake_approval.py`, `outcome.py` (new), `policy.py`, `requester.py`, `review.py`, + `store.py`, `workload.py`). +- `.forgejo/workflows/zlib-overlay-image.yaml` upgrades zlib only, on base `763c3640…`. + +The registry has no tag for `3b7031a`, `main` or `latest`. `docker buildx imagetools inspect` returns +not found for each. + +Before this release can go out, an owner-CI full-image build from the release source must exist. It needs +its own workflow, and it must keep zlib at `1.3.2-r1` or later. A plain rebuild of `Containerfile` uses the +pinned `python:3.12-alpine@sha256:b64631e0…` base and only adds `libuuid`. That would reintroduce the zlib +finding that the live image `f5588f49…` remediated. Then pin the image as in step 0. + +**Do not sync this candidate with the current image.** The live image `f5588f49…` accepts only the four basic +registry keys: `set(row) != {submitter_id, memo_classes, review_groups, daily_limit}`. It would reject the +executor row, so every intake request would fail with 503 `intake_configuration_unavailable`, State Hub's +included. It also does not send `approval_action`. + +## Pins + +| | Current, live (rollback target) | Candidate | +| --- | --- | --- | +| Argo `informed-decision` targetRevision | `312ab97e876e17341bf16f9e95397e14c2ac2720` | `` (step 0) | +| Service image | `…/informed-decision@sha256:f5588f496b1f267d07cdc9f5208e6f2f241f74b739c23924b03702dec13a5c23` | `` (G0) | +| Runtime ConfigMap | `informed-decision-runtime-6c221878d051` | `informed-decision-runtime-b488b96a0953` | +| Review policy pin | compact-sitting `v13` `sha256:31be5985c0c1e6b0c3a039ed7310d1e94daf523a94f6d926e6e8d759d3711873` | `v14` `sha256:8911458f36f1a60598aa19592a2d752a74dffe2617fd86238407ac87bc300fde` | +| Helm `flex-auth-informed-decision-sitting` (access-engine) | revision 13, image `6c6dc263…`, v13 policy path | access-engine's v14 release (`2de0f86`), prepared in parallel | +| Lifecycle policy pin | `informed-decision.lifecycle` v2 `da01e045…` | unchanged | +| Store schema | 5 | 5 (no migration) | + +Candidate source changes in this commit: + +- `admission.candidate.json`: the policy pin moves to v14, and the workload peer `{namespace: sso, app: bounded-executor}` + is added. `service.candidate.json` is re-rendered with `tools/render_deployment.py`. +- `informed-decision-review` NetworkPolicy: the 8081 ingress rule `from` now lists exactly two peers: + `state-hub/app.kubernetes.io/name=state-hub` (unchanged) and `kubernetes.io/metadata.name=sso` AND + `app.kubernetes.io/name=bounded-executor`. No other sso pod is admitted, and nothing new is admitted on 8080. +- `intake-submitters.json` (projected into `registries.candidate.json`): new row + `system:serviceaccount:sso:nk-bind-executor` → `nk-bind-executor`, memo class `bounded-execution`, review group + `net-kingdom-admins`, `daily_limit: 10`, `present_existing {approval_actor: service:nk-bind-executor, actions: + [bounded-execution]}`. The state-hub row is byte-identical, and `outcome_read` stays off (T05 waits for STATE-WP-0101). + +## Step 0: pin the image (after G0; owner agent or founder) + +```bash +cd ~/informed-decision +docker buildx imagetools inspect forgejo.coulomb.social/coulomb/informed-decision: # read the digest +# set "image" in deploy/lifecycle/admission.candidate.json to forgejo.coulomb.social/coulomb/informed-decision@ +python3 tools/render_deployment.py --input deploy/lifecycle/admission.candidate.json \ + --output deploy/lifecycle/service.candidate.json +.venv/bin/python -m pytest -q # expect 0 failures +python3 tools/smoke_container.py --image forgejo.coulomb.social/coulomb/informed-decision@ \ + --receipt docs/evidence/-present-existing-release/container.json --workload +git commit -am "INFD-WP-0010-T02: pin service image for the v14 release" && git push +RELEASE_SHA=$(git rev-parse HEAD) +``` + +Only the image line and the re-rendered `service.candidate.json` change. The runtime ConfigMap name stays +`…b488b96a0953` because the image is not part of runtime.json. + +## Step 1: preflight (read-only) + +```bash +export KUBECONFIG=$HOME/.kube/config-railiance01 +kubectl -n argocd get application informed-decision \ + -o jsonpath='{.spec.source.targetRevision} {.status.sync.status} {.status.health.status}{"\n"}' +# expect 312ab97e876e17341bf16f9e95397e14c2ac2720, Healthy (OutOfSync is the known orphan ConfigMap +# informed-decision-runtime-c81577741a09; prune stays off) +kubectl -n informed-decision get deploy informed-decision -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}' +helm -n flex-auth history flex-auth-informed-decision-sitting --max 3 # expect revision 13 deployed +kubectl -n sso get sa nk-bind-executor +kubectl -n informed-decision exec deploy/informed-decision -- \ + informed-decision-admin inspect --db /data/private/review.sqlite # record counts (schema 5) +# Server dry-run of the exact release source (no persistence): +git -C ~/informed-decision checkout --detach "$RELEASE_SHA" +kubectl apply --dry-run=server -k ~/informed-decision/deploy/lifecycle +kubectl diff -k ~/informed-decision/deploy/lifecycle # expect: image, runtime ConfigMap name/new ConfigMap, + # registry ConfigMap, review NetworkPolicy (+ Argo annotations) +git -C ~/informed-decision checkout main +``` + +## Step 2: backup (schema is unchanged, but the release convention still requires it) + +```bash +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +kubectl -n informed-decision exec deploy/informed-decision -- \ + informed-decision-admin backup --db /data/private/review.sqlite --output /data/private/review-$STAMP-pre-v14.sqlite +mkdir -p -m 0700 ~/informed-decision/.private-backups/$STAMP-pre-v14 +kubectl -n informed-decision exec deploy/informed-decision -- cat /data/private/review-$STAMP-pre-v14.sqlite \ + > ~/informed-decision/.private-backups/$STAMP-pre-v14/review.sqlite +chmod 0600 ~/informed-decision/.private-backups/$STAMP-pre-v14/review.sqlite +sha256sum ~/informed-decision/.private-backups/$STAMP-pre-v14/review.sqlite +``` + +## Step 3: one window, access-engine first, then informed-decision + +The service checks the PDP's provenance on every review call: `policy_package`, `policy_version`, +`policy_package_digest` and `matched_policy_version` must all equal its pin. Between the two steps, the +version mismatch makes human review reads and acts **fail closed**. No review is lost, but pages refuse. +Workload intake (`POST /v1/memos`) does not consult the PDP, so State Hub posing continues throughout. Keep +the gap to minutes. + +**3a. access-engine v14** follows access-engine's own release runbook (FLEX-WP-0040-T07, +`access-engine/docs/compact-sitting-v14-present-existing.md`, "Live steps"). It runs a Helm upgrade of +`flex-auth-informed-decision-sitting` with `--atomic`: a new image built from `2de0f86` or later, and +`--policy /opt/flex-auth/examples/informed-decision-present-existing-review/policy.md`. Check: + +```bash +helm -n flex-auth history flex-auth-informed-decision-sitting --max 2 # expect revision 14 deployed +kubectl -n flex-auth rollout status deploy/flex-auth-informed-decision-sitting --timeout=180s +``` + +If the `--atomic` upgrade rolls itself back, stop here. Do not run 3b. Nothing on the informed-decision side has changed. + +**3b. informed-decision**, through the manually pinned Argo application: + +```bash +kubectl -n argocd patch application informed-decision --type merge \ + -p "{\"spec\":{\"source\":{\"targetRevision\":\"$RELEASE_SHA\"}}}" +kubectl -n argocd patch application informed-decision --type merge \ + -p "{\"operation\":{\"initiatedBy\":{\"username\":\"founder\"},\"sync\":{\"revision\":\"$RELEASE_SHA\",\"prune\":false,\"syncOptions\":[\"ApplyOutOfSyncOnly=true\"]}}}" +kubectl -n argocd get application informed-decision \ + -o jsonpath='{.status.operationState.phase} {.status.sync.revision} {.status.health.status}{"\n"}' +kubectl -n informed-decision rollout status deploy/informed-decision --timeout=300s # Recreate: short restart +``` + +## Step 4: verification + +Possible without the executor: + +1. **Pins live.** Check the Deployment image and that its `configuration` volume names + `informed-decision-runtime-b488b96a0953`: + ```bash + kubectl -n informed-decision get deploy informed-decision \ + -o jsonpath='{.spec.template.spec.containers[0].image} {.spec.template.spec.volumes[?(@.name=="configuration")].configMap.name}{"\n"}' + kubectl -n informed-decision get networkpolicy informed-decision-review -o jsonpath='{.spec.ingress[1].from}{"\n"}' + # expect exactly the state-hub peer and the sso + app.kubernetes.io/name=bounded-executor peer + ``` +2. **Public surface.** `curl -s -o /dev/null -w '%{http_code}\n' https://decisions.coulomb.social/healthz` and + `/readyz` return 200. `/v1/memos` and `/v1/decisions` on the public origin return 404. +3. **v14 pin match (human, aal2).** Open `https://decisions.coulomb.social/` and one existing memo you were + presented. The page loads only if the PDP's v14 provenance equals the new pin. A refusal mentioning policy + or provenance means 3a and 3b disagree: roll back (step 5). +4. **Store unchanged.** Run the `informed-decision-admin inspect` command from step 1 again. Schema 5, and + the counts are no lower than the preflight. +5. **Executor path, synthetic and side-effect free.** Run this from a temporary pod that carries the + executor's identity and label. It is a founder-run kubectl write: a short-lived pod in sso, removed by + `--rm`. The probe pins a deliberately wrong binding digest, so even a successful approval read ends in a + 409. No memo is stored, no budget is spent, and nothing is created, entered or consumed in approval-engine. + ```bash + cd ~/informed-decision + IMAGE=forgejo.coulomb.social/coulomb/informed-decision@ + NAME=infd-probe-$(date -u +%H%M%S) # --overrides (merge patch) replaces the generated container: it carries name and image + OVR='{"spec":{"serviceAccountName":"nk-bind-executor","automountServiceAccountToken":false, + "securityContext":{"runAsNonRoot":true,"runAsUser":10001,"seccompProfile":{"type":"RuntimeDefault"}}, + "containers":[{"name":"'$NAME'","image":"'$IMAGE'","command":["python","-"],"stdin":true,"stdinOnce":true, + "securityContext":{"allowPrivilegeEscalation":false,"readOnlyRootFilesystem":true,"capabilities":{"drop":["ALL"]}}, + "volumeMounts":[{"name":"intake","mountPath":"/var/run/secrets/informed-decision-intake","readOnly":true}]}], + "volumes":[{"name":"intake","projected":{"sources":[{"serviceAccountToken":{"path":"token", + "audience":"informed-decision-intake","expirationSeconds":600}}]}}]}}' + .venv/bin/python tools/present_existing_probe.py --approval-id "$(python3 -c 'import uuid;print(uuid.uuid4())')" --program \ + | kubectl -n sso run "$NAME" --rm -i --restart=Never --image="$IMAGE" \ + --labels=app.kubernetes.io/name=bounded-executor,app.kubernetes.io/component=release-probe --overrides="$OVR" + ``` + Expected result for a random UUID: `503 {"error": "approval_unreadable"}`. Getting there proves the + NetworkPolicy admits the sso executor label on 8081, TokenReview accepts `nk-bind-executor` with audience + `informed-decision-intake`, the reloaded registry parses (so the state-hub row is also loaded), the + `present_existing` profile and envelope validation pass, and the reservation is released. + - If you know an approval id in approval-engine that no informed-decision memo has presented yet (for example + one created during NK-WP-0056 executor testing), use it in place of the random UUID. Expect a 409 with an + `approval_*` reason (`approval_digest_mismatch` or an earlier one). That proves the read through KeyCape + `approval:read` as well. + - Negative check: rerun with `"serviceAccountName":"default"` and expect `403 submitter_unknown`. + - Other outcomes: `401 workload_authentication_failed` points to the token or audience. A timeout points to the + NetworkPolicy (check the executor egress policy too). `503 intake_configuration_unavailable` means the + registry did not parse: roll back. + +Needs the executor (this is T02's own done-when): the executor creates an approval as `service:nk-bind-executor` and +presents it, the founder accepts at aal2 on `/review`, and the executor consumes it. That is the +NK-WP-0056 pilot run. It is not part of this release window. + +## Step 5: rollback to the current release + +The database is retained and stays on schema 5, so this is not a downgrade. Do both halves in the same window: + +```bash +kubectl -n argocd patch application informed-decision --type merge \ + -p '{"spec":{"source":{"targetRevision":"312ab97e876e17341bf16f9e95397e14c2ac2720"}}}' +kubectl -n argocd patch application informed-decision --type merge \ + -p '{"operation":{"initiatedBy":{"username":"founder"},"sync":{"revision":"312ab97e876e17341bf16f9e95397e14c2ac2720","prune":false,"syncOptions":["ApplyOutOfSyncOnly=true"]}}}' +kubectl -n informed-decision rollout status deploy/informed-decision --timeout=300s +helm -n flex-auth rollback flex-auth-informed-decision-sitting 13 --wait # access-engine's v13 release +``` + +This restores image `f5588f49…`, runtime ConfigMap `…6c221878d051` (v13 pin), the registry without the executor +row, and the NetworkPolicy without the sso peer. The new immutable ConfigMap `…b488b96a0953` is left in place +because prune is off. It is harmless. Any present-existing memo posed during the window stays in the store but +cannot be reviewed under v13 (`unknown_submitter`): it fails closed. Record it, and do not delete it. + +## Step 6: record + +Write `docs/evidence/-present-existing-release/` (pins, Argo revision, Helm revision, backup sha256, +probe outputs without tokens) and append a "Present-existing release" section to `deploy/lifecycle/README.md`. +Then update INFD-WP-0010-T02. diff --git a/deploy/lifecycle/admission.candidate.json b/deploy/lifecycle/admission.candidate.json index c0b7f2e..458daf1 100644 --- a/deploy/lifecycle/admission.candidate.json +++ b/deploy/lifecycle/admission.candidate.json @@ -3,8 +3,8 @@ "policy": { "origin": "http://flex-auth-informed-decision-sitting.flex-auth.svc.cluster.local:8080", "package": "informed-decision.compact-sitting", - "version": "v13", - "package_digest": "sha256:31be5985c0c1e6b0c3a039ed7310d1e94daf523a94f6d926e6e8d759d3711873", + "version": "v14", + "package_digest": "sha256:8911458f36f1a60598aa19592a2d752a74dffe2617fd86238407ac87bc300fde", "pod_name": "flex-auth-informed-decision-sitting" }, "keycape_egress_ips": [ @@ -34,6 +34,10 @@ { "namespace": "state-hub", "app": "state-hub" + }, + { + "namespace": "sso", + "app": "bounded-executor" } ], "api_egress": [ diff --git a/deploy/lifecycle/intake-submitters.json b/deploy/lifecycle/intake-submitters.json index 5972773..58906a6 100644 --- a/deploy/lifecycle/intake-submitters.json +++ b/deploy/lifecycle/intake-submitters.json @@ -8,5 +8,21 @@ "net-kingdom-admins" ], "daily_limit": 50 + }, + "system:serviceaccount:sso:nk-bind-executor": { + "submitter_id": "nk-bind-executor", + "memo_classes": [ + "bounded-execution" + ], + "review_groups": [ + "net-kingdom-admins" + ], + "daily_limit": 10, + "present_existing": { + "approval_actor": "service:nk-bind-executor", + "actions": [ + "bounded-execution" + ] + } } } diff --git a/deploy/lifecycle/registries.candidate.json b/deploy/lifecycle/registries.candidate.json index 60ad67b..9b7ebb2 100644 --- a/deploy/lifecycle/registries.candidate.json +++ b/deploy/lifecycle/registries.candidate.json @@ -10,7 +10,7 @@ }, "data": { "lifecycle-callers.json": "{\n \"system:serviceaccount:state-hub:state-hub\": {\n \"tenant\": \"tenant:platform\"\n }\n}\n", - "intake-submitters.json": "{\n \"system:serviceaccount:state-hub:state-hub\": {\n \"submitter_id\": \"state-hub\",\n \"memo_classes\": [\n \"decision-record\"\n ],\n \"review_groups\": [\n \"net-kingdom-admins\"\n ],\n \"daily_limit\": 50\n }\n}\n", + "intake-submitters.json": "{\n \"system:serviceaccount:state-hub:state-hub\": {\n \"submitter_id\": \"state-hub\",\n \"memo_classes\": [\n \"decision-record\"\n ],\n \"review_groups\": [\n \"net-kingdom-admins\"\n ],\n \"daily_limit\": 50\n },\n \"system:serviceaccount:sso:nk-bind-executor\": {\n \"submitter_id\": \"nk-bind-executor\",\n \"memo_classes\": [\n \"bounded-execution\"\n ],\n \"review_groups\": [\n \"net-kingdom-admins\"\n ],\n \"daily_limit\": 10,\n \"present_existing\": {\n \"approval_actor\": \"service:nk-bind-executor\",\n \"actions\": [\n \"bounded-execution\"\n ]\n }\n }\n}\n", "review-groups.json": "{\n \"net-kingdom-admins\": {\n \"subject\": \"uid=platform-root,ou=people,dc=netkingdom,dc=local\",\n \"display_name\": \"Platform reviewer\"\n }\n}\n" } } diff --git a/deploy/lifecycle/service.candidate.json b/deploy/lifecycle/service.candidate.json index c267584..5377d35 100644 --- a/deploy/lifecycle/service.candidate.json +++ b/deploy/lifecycle/service.candidate.json @@ -40,7 +40,7 @@ "apiVersion": "v1", "kind": "ConfigMap", "metadata": { - "name": "informed-decision-runtime-6c221878d051", + "name": "informed-decision-runtime-b488b96a0953", "namespace": "informed-decision", "annotations": { "informed-decision.coulomb.social/admission": "review-only; INFD-WP-0001-T08" @@ -48,7 +48,7 @@ }, "immutable": true, "data": { - "runtime.json": "{\"approval_origin\":\"http://approval-engine.approval-engine.svc.cluster.local:8080\",\"audit\":{\"origin\":\"http://audit-core.audit-core.svc.cluster.local:8080\",\"sender_token_file\":\"/var/run/secrets/informed-decision/audit/token\"},\"evidence_db\":\"/data/private/review.sqlite\",\"intake_attributes\":true,\"intake_delivery\":{\"hub_origin\":\"http://state-hub.state-hub.svc.cluster.local:8000\"},\"lifecycle_delivery\":true,\"lifecycle_policy\":{\"caller_token_file\":\"/var/run/secrets/informed-decision/policy/token\",\"origin\":\"http://flex-auth-informed-decision-lifecycle.flex-auth.svc.cluster.local:8080\",\"package\":\"informed-decision.lifecycle\",\"package_digest\":\"sha256:da01e045fc108adec6b489e5d51f7e57366793016c5a64776963780a6aae457c\",\"version\":\"v2\"},\"ordinary_approval_ids\":[\"2ce76d7f-01c3-4b63-8476-8d1230679769\",\"c2af4bcf-15b4-4305-aac1-acc7e4dcb099\",\"dc22666a-d5d7-46bc-9490-ebe9fe09dc38\"],\"policy\":{\"caller_token_file\":\"/var/run/secrets/informed-decision/policy/token\",\"origin\":\"http://flex-auth-informed-decision-sitting.flex-auth.svc.cluster.local:8080\",\"package\":\"informed-decision.compact-sitting\",\"package_digest\":\"sha256:31be5985c0c1e6b0c3a039ed7310d1e94daf523a94f6d926e6e8d759d3711873\",\"version\":\"v13\"},\"schema\":\"informed-decision.review-runtime.v1\",\"workload_api\":{\"intake\":{\"groups_file\":\"/workload-registry/review-groups.json\",\"secret_file\":\"/var/run/secrets/informed-decision/requester/client-secret\",\"submitters_file\":\"/workload-registry/intake-submitters.json\"},\"lifecycle_callers_file\":\"/workload-registry/lifecycle-callers.json\",\"token_review\":{\"ca_file\":\"/var/run/secrets/informed-decision/kubernetes/ca.crt\",\"origin\":\"https://kubernetes.default.svc:443\",\"token_file\":\"/var/run/secrets/informed-decision/kubernetes/token\"}}}" + "runtime.json": "{\"approval_origin\":\"http://approval-engine.approval-engine.svc.cluster.local:8080\",\"audit\":{\"origin\":\"http://audit-core.audit-core.svc.cluster.local:8080\",\"sender_token_file\":\"/var/run/secrets/informed-decision/audit/token\"},\"evidence_db\":\"/data/private/review.sqlite\",\"intake_attributes\":true,\"intake_delivery\":{\"hub_origin\":\"http://state-hub.state-hub.svc.cluster.local:8000\"},\"lifecycle_delivery\":true,\"lifecycle_policy\":{\"caller_token_file\":\"/var/run/secrets/informed-decision/policy/token\",\"origin\":\"http://flex-auth-informed-decision-lifecycle.flex-auth.svc.cluster.local:8080\",\"package\":\"informed-decision.lifecycle\",\"package_digest\":\"sha256:da01e045fc108adec6b489e5d51f7e57366793016c5a64776963780a6aae457c\",\"version\":\"v2\"},\"ordinary_approval_ids\":[\"2ce76d7f-01c3-4b63-8476-8d1230679769\",\"c2af4bcf-15b4-4305-aac1-acc7e4dcb099\",\"dc22666a-d5d7-46bc-9490-ebe9fe09dc38\"],\"policy\":{\"caller_token_file\":\"/var/run/secrets/informed-decision/policy/token\",\"origin\":\"http://flex-auth-informed-decision-sitting.flex-auth.svc.cluster.local:8080\",\"package\":\"informed-decision.compact-sitting\",\"package_digest\":\"sha256:8911458f36f1a60598aa19592a2d752a74dffe2617fd86238407ac87bc300fde\",\"version\":\"v14\"},\"schema\":\"informed-decision.review-runtime.v1\",\"workload_api\":{\"intake\":{\"groups_file\":\"/workload-registry/review-groups.json\",\"secret_file\":\"/var/run/secrets/informed-decision/requester/client-secret\",\"submitters_file\":\"/workload-registry/intake-submitters.json\"},\"lifecycle_callers_file\":\"/workload-registry/lifecycle-callers.json\",\"token_review\":{\"ca_file\":\"/var/run/secrets/informed-decision/kubernetes/ca.crt\",\"origin\":\"https://kubernetes.default.svc:443\",\"token_file\":\"/var/run/secrets/informed-decision/kubernetes/token\"}}}" } }, { @@ -231,7 +231,7 @@ { "name": "configuration", "configMap": { - "name": "informed-decision-runtime-6c221878d051", + "name": "informed-decision-runtime-b488b96a0953", "defaultMode": 292 } }, @@ -397,6 +397,18 @@ "app.kubernetes.io/name": "state-hub" } } + }, + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "sso" + } + }, + "podSelector": { + "matchLabels": { + "app.kubernetes.io/name": "bounded-executor" + } + } } ], "ports": [ diff --git a/tests/test_present_existing_release.py b/tests/test_present_existing_release.py new file mode 100644 index 0000000..50ae1ab --- /dev/null +++ b/tests/test_present_existing_release.py @@ -0,0 +1,89 @@ +"""INFD-WP-0010-T02 release candidate: registry row, network admission, policy pin and probe.""" +import importlib.util +import json +from pathlib import Path +import sys +import uuid + +from informed_decision.intake_service import IntakeError, configuration +from test_intake_present_existing import ACTOR, approval, setup # noqa: F401 (fixture) +from test_intake_service import request + +ROOT = Path(__file__).parents[1] +LIFECYCLE = ROOT / 'deploy' / 'lifecycle' + + +def _tool(name): + spec = importlib.util.spec_from_file_location(name, ROOT / 'tools' / f'{name}.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_registry_rows_and_projection(): + submitters, _ = configuration(LIFECYCLE / 'intake-submitters.json', LIFECYCLE / 'review-groups.json') + assert submitters['system:serviceaccount:state-hub:state-hub'] == { + 'submitter_id': 'state-hub', 'memo_classes': ['decision-record'], + 'review_groups': ['net-kingdom-admins'], 'daily_limit': 50} # unchanged; outcome_read waits on T05 + assert submitters['system:serviceaccount:sso:nk-bind-executor'] == { + 'submitter_id': 'nk-bind-executor', 'memo_classes': ['bounded-execution'], + 'review_groups': ['net-kingdom-admins'], 'daily_limit': 10, + 'present_existing': {'approval_actor': 'service:nk-bind-executor', 'actions': ['bounded-execution']}} + assert len(submitters) == 2 + projection = json.loads((LIFECYCLE / 'registries.candidate.json').read_text())['data'] + for name, raw in projection.items(): + assert (LIFECYCLE / name).read_text() == raw + + +def test_candidate_render_pins_v14_and_admits_only_the_executor_on_8081(): + render = _tool('render_deployment').render + admission = json.loads((LIFECYCLE / 'admission.candidate.json').read_text()) + rendered = render(admission) + assert rendered == json.loads((LIFECYCLE / 'service.candidate.json').read_text()) + config = json.loads(next(i for i in rendered['items'] if i['kind'] == 'ConfigMap')['data']['runtime.json']) + assert config['policy']['version'] == 'v14' + assert config['policy']['package_digest'] == ( + 'sha256:8911458f36f1a60598aa19592a2d752a74dffe2617fd86238407ac87bc300fde') + netpol = next(i for i in rendered['items'] if i['metadata']['name'] == 'informed-decision-review') + private = [r for r in netpol['spec']['ingress'] if r['ports'] == [{'protocol': 'TCP', 'port': 8081}]] + assert len(private) == 1 + assert private[0]['from'] == [ + {'namespaceSelector': {'matchLabels': {'kubernetes.io/metadata.name': ns}}, + 'podSelector': {'matchLabels': {'app.kubernetes.io/name': app}}} + for ns, app in (('state-hub', 'state-hub'), ('sso', 'bounded-executor'))] + + +def _ledger_rows(store): + with store._transaction() as db: + return db.execute('SELECT COUNT(*) FROM intake_ledger').fetchone()[0] + + +def test_probe_envelope_is_refused_after_a_read_and_leaves_nothing(setup): # noqa: F811 + service, store, created, reads, current = setup + probe = _tool('present_existing_probe') + approval_id = str(uuid.uuid4()) + current['body'] = approval(id=approval_id, **{'binding.actor': 'informed-decision'}) + status, body = request(service, probe.build(approval_id, '20261101000000'), token='executor-token') + assert (status, body.get('error')) == (409, 'approval_actor_mismatch') + assert reads == [approval_id] and created == [] and _ledger_rows(store) == 0 + + +def test_probe_envelope_with_unreadable_approval(setup): # noqa: F811 + service, store, created, reads, current = setup + probe = _tool('present_existing_probe') + service.reader = lambda approval_id: (_ for _ in ()).throw(IntakeError(503, 'approval_unreadable')) + status, body = request(service, probe.build(str(uuid.uuid4()), '20261101000001'), token='executor-token') + assert (status, body.get('error')) == (503, 'approval_unreadable') + assert created == [] and _ledger_rows(store) == 0 + + +def test_probe_program_is_self_contained_python(capsys, monkeypatch): + probe = _tool('present_existing_probe') + monkeypatch.setattr(sys, 'argv', ['probe', '--approval-id', str(uuid.uuid4()), '--program', '--stamp', '20261101000002']) + probe.main() + program = capsys.readouterr().out + compile(program, 'probe', 'exec') + namespace = {} + exec(program.split('\n', 1)[0], namespace) # first line only: the envelope literal + assert json.loads(namespace['ENVELOPE'])['memo_class'] == 'bounded-execution' + assert probe.WORKLOAD.endswith(':8081/v1/memos') and 'Bearer' in program diff --git a/tools/present_existing_probe.py b/tools/present_existing_probe.py new file mode 100644 index 0000000..6f26c6f --- /dev/null +++ b/tools/present_existing_probe.py @@ -0,0 +1,88 @@ +"""Build one synthetic present_existing intake envelope (INFD-WP-0010-T02 release check). + +The envelope pins an approval id the caller names and a deliberately wrong binding +digest, so a successful approval read always ends in a 409 refusal and no memo is +stored, no budget is spent and no approval is created, entered or consumed. It only +prints JSON; posting it is a separate, founder-run step (see +deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md). The content is synthetic and secret-free. +""" + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import sys + +ACTOR = "service:nk-bind-executor" +SUBMITTER = "nk-bind-executor" +PROFILE_ACTIONS = ["bounded-execution"] +WRONG_DIGEST = "sha256:" + "0" * 64 +WORKLOAD = "http://informed-decision-workload.informed-decision.svc.cluster.local:8081/v1/memos" +TOKEN_FILE = "/var/run/secrets/informed-decision-intake/token" + +# Runs inside the founder's probe pod (python from the service image). Prints status and the +# refusal body only; never prints the token. +POSTER = """import json, urllib.error, urllib.request +token = open(%r).read().strip() +request = urllib.request.Request(%r, data=ENVELOPE.encode(), method="POST", + headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"}) +try: + response = urllib.request.urlopen(request, timeout=20) + print(response.status, response.read().decode()[:400]) +except urllib.error.HTTPError as error: + print(error.code, error.read().decode()[:400]) +""" % (TOKEN_FILE, WORKLOAD) + + +def build(approval_id, stamp): + memo_id = "probe-" + stamp + item_id = memo_id + "-packet" + content = ("Synthetic release probe for the present_existing intake profile. " + "It is refused by design and must never be shown for review.\n") + packet_hash = "sha256:" + hashlib.sha256(content.encode()).hexdigest() + memo = { + "id": memo_id, "version": 1, "sealed": False, "locale": "en", + "question": "Synthetic release probe: is this refused before review?", + "requested_act": "approve", "binding_level": "organizational", "step_kind": "approve", + "brief": "Synthetic present_existing release probe; never bound.", + "binding": {"justification": None, "terms": None, + "principal": {"id": "pending-human-session", "kind": "person", "role": "reviewer", + "display_name": "Named reviewer in net-kingdom-admins", "identifiers": []}, + "target": {"environment": "prod", "id": "probe:" + memo_id, "kind": "bounded-execution", + "label": "synthetic release probe", "requires_new_bind": False}}, + "packet": [{"item_id": item_id, "label": item_id, "hash": packet_hash}], + "highlights": [], + "approval_id": approval_id, "approval_binding_digest": WRONG_DIGEST, + } + return { + "kind": "informed-decision.memo-intake", "version": 1, + "idempotency_key": memo_id, "memo_class": "bounded-execution", + "review_group": "net-kingdom-admins", "drafted_by": "informed-decision-release-probe", + "approval_intent": {"action": "bounded-execution", "principal": "admin", + "purpose": "Synthetic present_existing release probe", + "target": {"admission_id": "PROBE-" + stamp, "admission_digest": WRONG_DIGEST}}, + "memo": memo, + "packet": [{"item_id": item_id, "media_type": "text/plain", "content": content}], + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--approval-id", required=True, help="existing approval UUID, or a random UUID") + parser.add_argument("--program", action="store_true", + help="print a self-contained python program that posts the envelope (for `python -`)") + parser.add_argument("--stamp", default=datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")) + args = parser.parse_args() + envelope = build(args.approval_id, args.stamp) + # Offline self-check against the same validator intake uses. + from informed_decision.intake import PresentExisting, SubmitterGrant, validate_submission + validate_submission(envelope, submitter_id=SUBMITTER, + grant=SubmitterGrant(SUBMITTER, frozenset({"bounded-execution"}), + frozenset({"net-kingdom-admins"})), + present_existing=PresentExisting(ACTOR, frozenset(PROFILE_ACTIONS))) + raw = json.dumps(envelope, separators=(",", ":")) + sys.stdout.write("ENVELOPE = %r\n%s" % (raw, POSTER) if args.program else raw + "\n") + + +if __name__ == "__main__": + main() diff --git a/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md b/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md index cc89627..8657175 100644 --- a/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md +++ b/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md @@ -95,6 +95,19 @@ prepared `approval:read` (`82b471f`, source only); the sitting-requester checks consume (commit `12311d1`). The v14 pin is not in the candidate files: by this repo's release convention the pin moves together with the image in the founder-run release commit. +**2026-11-01, release candidate (founder approval relayed by the net-kingdom coordinator).** KeyCape +`approval:read` is live (key-cape `82b471f`, hub decision `21a557a9`). Candidate files now carry the v14 pin +(`admission.candidate.json`, re-rendered `service.candidate.json`; runtime ConfigMap `…b488b96a0953`), the +`system:serviceaccount:sso:nk-bind-executor` present_existing row (`bounded-execution`, `net-kingdom-admins`, +daily limit 10), and an 8081 ingress peer for exactly `sso` + `app.kubernetes.io/name=bounded-executor`. The state-hub +row is unchanged and outcome_read stays off (T05). The founder runbook covers the order relative to access-engine v14, +verification (including the side-effect-free `tools/present_existing_probe.py`) and rollback to Argo `312ab97` / Helm +revision 13: `deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md`. Tests: 704 passed, 83 skipped (`.venv`). +**Blocked on the image:** no owner CI workflow builds the full service image. Only the policy.py and zlib overlay +workflows exist; purpose-overlay run 5 failed by design on the changed `policy.py` hash. The image pin is therefore +still the live `f5588f49…`, and the candidate must not be synced until the pin is replaced by an owner-CI full image +that keeps zlib ≥ 1.3.2-r1. Nothing applied. + Then here: the policy pin, a registry row, a release, and a native proof with synthetic BEA content. Optional: for `bounded-execution`, require a packet item whose digest equals `target.admission_digest`, once the admission digest's canonical form is confirmed. From c228583b609aaa7f0e45ea1fcda4f26e121ad2c5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Nov 2026 11:32:32 +0100 Subject: [PATCH 2/2] INFD-WP-0010-T02: owner-CI workflow for the full service image .forgejo/workflows/service-image.yaml builds Containerfile from the pushed commit's tarball and pushes forgejo.coulomb.social/coulomb/informed-decision :main-, then reports the immutable digest. Before pushing it checks the zlib floor, the runtime smoke (tools/zlib_overlay/check_runtime.py) and that the installed modules equal the commit's source. Containerfile builds the wheel in-image (hash-pinned setuptools in requirements-build.lock) and installs the zlib overlay's hash-pinned, Alpine-signed zlib-1.3.2-r1 package offline, failing below 1.3.2-r1, so the live image's zlib fix cannot regress. Overlay workflows are unchanged. Local build: 13/13 container checks, zlib 1.3.2-r1, source manifest match. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196 --- .dockerignore | 10 +- .forgejo/workflows/service-image.yaml | 98 +++++++++++++++++++ Containerfile | 23 ++++- Makefile | 2 +- deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md | 10 +- requirements-build.lock | 4 + tests/test_service_image_build.py | 52 ++++++++++ tools/service_image/installed_manifest.py | 13 +++ ...nt-existing-and-proportionate-decisions.md | 4 + 9 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 .forgejo/workflows/service-image.yaml create mode 100644 requirements-build.lock create mode 100644 tests/test_service_image_build.py create mode 100644 tools/service_image/installed_manifest.py diff --git a/.dockerignore b/.dockerignore index 114b831..213d8d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,11 @@ ** !Containerfile !requirements.lock -!dist/ -!dist/informed_decision-*.whl +!requirements-build.lock +!pyproject.toml +!informed_decision/ +!informed_decision/** +**/__pycache__ +**/*.pyc +!deploy/zlib-overlay/zlib-1.3.2-r1.apk +!deploy/zlib-overlay/APKINDEX.tar.gz diff --git a/.forgejo/workflows/service-image.yaml b/.forgejo/workflows/service-image.yaml new file mode 100644 index 0000000..db78793 --- /dev/null +++ b/.forgejo/workflows/service-image.yaml @@ -0,0 +1,98 @@ +name: Build and Publish Service Image + +# Full informed-decision service image (INFD-WP-0010-T02 release path). Modelled on the fleet's +# canonical image-publish pattern (activity-core, access-engine, state-hub memo-dispatch): CI +# builds from a tarball of the pushed commit, never from a workstation working tree, so the +# artifact's provenance is a forge revision. The wheel is built inside the image from source. +# +# zlib: the Containerfile installs the same hash-pinned, Alpine-signed zlib-1.3.2-r1 package the +# zlib overlay uses (deploy/zlib-overlay), offline, and fails the build unless zlib >= 1.3.2-r1. +# This job re-checks that in the built image before anything is pushed. +# +# The purpose-overlay and zlib-overlay workflows are separate and unchanged. Publication is not +# deployment: pins move by digest in deploy/lifecycle/ under the founder-run release runbook. + +on: + push: + branches: + - main + paths: + - ".forgejo/workflows/service-image.yaml" + - "Containerfile" + - ".dockerignore" + - "requirements.lock" + - "requirements-build.lock" + - "pyproject.toml" + - "informed_decision/**" + - "deploy/zlib-overlay/zlib-1.3.2-r1.apk" + - "deploy/zlib-overlay/APKINDEX.tar.gz" + - "tools/service_image/**" + - "tools/zlib_overlay/check_runtime.py" + workflow_dispatch: + +concurrency: + group: informed-decision-service-image + cancel-in-progress: false + +permissions: + contents: read + +env: + REGISTRY: forgejo.coulomb.social + IMAGE_NAME: coulomb/informed-decision + DOCKER_HOST: tcp://127.0.0.1:2375 + +jobs: + build-and-push: + runs-on: container-build + steps: + - name: Build, check and push image + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + REF="${GITHUB_SHA:?full source revision required}" + echo "${REF}" | grep -Eq '^[0-9a-f]{40}$' + test "${GITHUB_REPOSITORY}" = "coulomb/informed-decision" + if [ -z "${REGISTRY_USER:-}" ] || [ -z "${REGISTRY_TOKEN:-}" ]; then + echo "Admitted repository registry publication capability is unavailable; no borrowed credential is permitted." >&2 + exit 2 + fi + SHORT="$(printf '%s' "${REF}" | cut -c1-7)" + mkdir -p buildctx "${HOME}/bin" + wget -qO /tmp/repo.tar.gz \ + "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${REF}.tar.gz" + tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1 + wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + | tar xz --strip-components=1 -C "${HOME}/bin" docker/docker + export PATH="${HOME}/bin:${PATH}" + echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + IMAGE="${REGISTRY}/${IMAGE_NAME}:main-${SHORT}" + docker build --label "org.opencontainers.image.revision=${REF}" \ + -f buildctx/Containerfile -t "${IMAGE}" buildctx + # Checks before publication: zlib floor, runtime compression/crypto smoke, exact source. + docker run --rm --network none --read-only --entrypoint sh "${IMAGE}" \ + -c "apk info --exists 'zlib>=1.3.2-r1' >/dev/null" + docker run --rm -i --network none --read-only --entrypoint python "${IMAGE}" - \ + < buildctx/tools/zlib_overlay/check_runtime.py > /tmp/runtime.json + grep -q '"zlib_runtime_version": "1.3.2' /tmp/runtime.json + grep -q '"uid": 10001' /tmp/runtime.json + docker run --rm -i --network none --read-only --entrypoint python "${IMAGE}" - \ + < buildctx/tools/service_image/installed_manifest.py > /tmp/installed.txt + (cd buildctx/informed_decision && find . -name '*.py' -not -path '*/__pycache__/*' \ + | LC_ALL=C sort | xargs sha256sum) > /tmp/source.txt + diff /tmp/source.txt /tmp/installed.txt + docker push "${IMAGE}" + echo "pushed ${IMAGE} from ${REF}" + + - name: Report immutable digest + run: | + set -eu + export PATH="${HOME}/bin:${PATH}" + SHORT="$(printf '%s' "${GITHUB_SHA}" | cut -c1-7)" + IMAGE="${REGISTRY}/${IMAGE_NAME}:main-${SHORT}" + # Deployments pin by digest, never by tag -- print it for the release step. + docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}" + echo "source_revision=${GITHUB_SHA}" + echo "candidate_only=true; the founder-run release runbook pins this digest" diff --git a/Containerfile b/Containerfile index aeda084..401b826 100644 --- a/Containerfile +++ b/Containerfile @@ -1,15 +1,36 @@ # Base matches the approved Approval Engine Alpine family; scan each candidate. +# Built by owner CI (.forgejo/workflows/service-image.yaml) from a tarball of the pushed commit; the +# wheel is built inside the image from source, so no workstation dist/ is involved. +FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a AS wheel +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1 +COPY requirements-build.lock /src/requirements-build.lock +RUN pip install --no-cache-dir --require-hashes --only-binary=:all: -r /src/requirements-build.lock +COPY pyproject.toml /src/pyproject.toml +COPY informed_decision /src/informed_decision +RUN pip wheel --no-cache-dir --no-deps --no-build-isolation --wheel-dir /dist /src + FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a AS build ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/opt/venv/bin:$PATH RUN python -m venv /opt/venv COPY requirements.lock /build/requirements.lock RUN pip install --no-cache-dir --require-hashes --only-binary=:all: -r /build/requirements.lock -COPY dist/informed_decision-*.whl /build/ +COPY --from=wheel /dist/ /build/ RUN pip install --no-cache-dir --no-deps --no-index /build/informed_decision-*.whl FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/opt/venv/bin:$PATH +# zlib >= 1.3.2-r1 (INFD-WP-0009-T05 remediation, live since 2026-10-11 in f5588f49): the same +# hash-pinned, Alpine-signed package the zlib overlay installs, upgraded offline. The build fails +# if the result is older; a newer zlib already in the base is left as is. +COPY deploy/zlib-overlay/zlib-1.3.2-r1.apk deploy/zlib-overlay/APKINDEX.tar.gz /tmp/infd-zlib/x86_64/ +RUN echo '63aeea03c15a2f9018f81805cfc8aa926bdf5cd68921f22149c2fbb5d0ee9f47 /tmp/infd-zlib/x86_64/zlib-1.3.2-r1.apk' | sha256sum -c - \ + && echo '24fc2ccb08f3125429a38b31bd6c76a7b394c6236df0975760dc8259e50dfbd5 /tmp/infd-zlib/x86_64/APKINDEX.tar.gz' | sha256sum -c - \ + && apk verify /tmp/infd-zlib/x86_64/zlib-1.3.2-r1.apk /tmp/infd-zlib/x86_64/APKINDEX.tar.gz \ + && apk upgrade --no-logfile --no-network --no-cache --no-preupgrade --repositories-file /dev/null --repository /tmp/infd-zlib zlib \ + && apk info --exists 'zlib>=1.3.2-r1' \ + && rm -rf /tmp/infd-zlib RUN apk add --no-cache 'libuuid>=2.42.3-r1' \ + && apk info --exists 'zlib>=1.3.2-r1' \ && addgroup -S -g 10001 informed \ && adduser -S -u 10001 -G informed -H informed COPY --from=build /opt/venv /opt/venv diff --git a/Makefile b/Makefile index 45416f6..763323a 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ IMAGE ?= informed-decision:local package: uv build --wheel -image-build: package +image-build: docker build -f Containerfile -t $(IMAGE) . test: diff --git a/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md b/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md index 27e6ed3..160cd2e 100644 --- a/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md +++ b/deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md @@ -26,6 +26,14 @@ its own workflow, and it must keep zlib at `1.3.2-r1` or later. A plain rebuild pinned `python:3.12-alpine@sha256:b64631e0…` base and only adds `libuuid`. That would reintroduce the zlib finding that the live image `f5588f49…` remediated. Then pin the image as in step 0. +**Update, same day:** the full-image workflow now exists in source: `.forgejo/workflows/service-image.yaml`. It +builds `Containerfile` from the pushed commit's tarball, with the wheel built in-image. It installs the overlay's +hash-pinned zlib-1.3.2-r1 package offline and enforces `zlib>=1.3.2-r1`. It checks the runtime smoke and that the +installed source matches the commit before pushing `forgejo.coulomb.social/coulomb/informed-decision:main-`, +then prints the digest in "Report immutable digest". A local build of the same Containerfile passed 13/13 container +checks (`tools/smoke_container.py --workload`), with zlib 1.3.2-r1 and the installed modules identical to source. +The gate is now: the push runs this workflow, then step 0 pins the digest it reports. + **Do not sync this candidate with the current image.** The live image `f5588f49…` accepts only the four basic registry keys: `set(row) != {submitter_id, memo_classes, review_groups, daily_limit}`. It would reject the executor row, so every intake request would fail with 503 `intake_configuration_unavailable`, State Hub's @@ -59,7 +67,7 @@ Candidate source changes in this commit: ```bash cd ~/informed-decision -docker buildx imagetools inspect forgejo.coulomb.social/coulomb/informed-decision: # read the digest +docker buildx imagetools inspect forgejo.coulomb.social/coulomb/informed-decision:main- # or the CI "Report immutable digest" line # set "image" in deploy/lifecycle/admission.candidate.json to forgejo.coulomb.social/coulomb/informed-decision@ python3 tools/render_deployment.py --input deploy/lifecycle/admission.candidate.json \ --output deploy/lifecycle/service.candidate.json diff --git a/requirements-build.lock b/requirements-build.lock new file mode 100644 index 0000000..8a6d06d --- /dev/null +++ b/requirements-build.lock @@ -0,0 +1,4 @@ +# Build backend for the in-image wheel stage (Containerfile). Hash-pinned; pyproject requires setuptools>=68. +# Hash from https://pypi.org/pypi/setuptools/80.9.0/json (setuptools-80.9.0-py3-none-any.whl). +setuptools==80.9.0 \ + --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 diff --git a/tests/test_service_image_build.py b/tests/test_service_image_build.py new file mode 100644 index 0000000..7a9e8e7 --- /dev/null +++ b/tests/test_service_image_build.py @@ -0,0 +1,52 @@ +"""Static checks for the owner-CI full service image (.forgejo/workflows/service-image.yaml). + +The built image itself is checked in CI (zlib floor, runtime smoke, installed source manifest) +and locally with tools/smoke_container.py; these tests keep the inputs consistent. +""" +import hashlib +import json +from pathlib import Path +import re + +import yaml + +ROOT = Path(__file__).parents[1] +ZLIB = ROOT / 'deploy' / 'zlib-overlay' + + +def test_containerfile_installs_the_overlay_zlib_package_and_enforces_the_floor(): + text = (ROOT / 'Containerfile').read_text() + pins = json.loads((ZLIB / 'pins.json').read_text())['files'] + for name in ('zlib-1.3.2-r1.apk', 'APKINDEX.tar.gz'): + actual = hashlib.sha256((ZLIB / name).read_bytes()).hexdigest() + assert actual == pins[name] + assert f"echo '{actual} /tmp/infd-zlib/x86_64/{name}' | sha256sum -c -" in text + assert '--no-network' in text and "apk info --exists 'zlib>=1.3.2-r1'" in text + # Every stage uses the same pinned base; the wheel is built from source, not a workstation dist/. + bases = re.findall(r'^FROM (\S+)', text, re.M) + assert len(bases) == 3 and len(set(bases)) == 1 and '@sha256:' in bases[0] + assert 'COPY dist/' not in text and 'COPY --from=wheel /dist/' in text + + +def test_build_context_admits_exactly_the_inputs(): + ignore = (ROOT / '.dockerignore').read_text().split() + assert ignore[0] == '**' + for path in ('Containerfile', 'requirements.lock', 'requirements-build.lock', 'pyproject.toml', + 'informed_decision/**', 'deploy/zlib-overlay/zlib-1.3.2-r1.apk', + 'deploy/zlib-overlay/APKINDEX.tar.gz'): + assert '!' + path in ignore + build_lock = (ROOT / 'requirements-build.lock').read_text() + assert re.search(r'^setuptools==[\d.]+ \\\n\s+--hash=sha256:[0-9a-f]{64}$', build_lock, re.M) + + +def test_workflow_builds_from_the_pushed_commit_and_reports_the_digest(): + flow = yaml.safe_load((ROOT / '.forgejo' / 'workflows' / 'service-image.yaml').read_text()) + on = flow.get('on', flow.get(True)) + assert on['push']['branches'] == ['main'] + steps = flow['jobs']['build-and-push']['steps'] + script = steps[0]['run'] + assert 'archive/${REF}.tar.gz' in script and ':main-${SHORT}' in script + assert script.index("zlib>=1.3.2-r1") < script.index('docker push') + assert script.index('diff /tmp/source.txt /tmp/installed.txt') < script.index('docker push') + assert steps[1]['name'] == 'Report immutable digest' + assert flow['env']['IMAGE_NAME'] == 'coulomb/informed-decision' diff --git a/tools/service_image/installed_manifest.py b/tools/service_image/installed_manifest.py new file mode 100644 index 0000000..7fa8142 --- /dev/null +++ b/tools/service_image/installed_manifest.py @@ -0,0 +1,13 @@ +"""Print `sha256 ./relative/path` for the installed informed_decision modules (C-sorted). + +Run inside the service image (`docker run -i --entrypoint python IMAGE - < this file`). CI compares +the output with `sha256sum` over the pushed commit's informed_decision/*.py: the image must carry +exactly the source it was built from. Opens no database and no network. +""" +import hashlib +from pathlib import Path + +ROOT = Path('/opt/venv/lib/python3.12/site-packages/informed_decision') +rows = sorted('./' + str(p.relative_to(ROOT)) for p in ROOT.rglob('*.py') if '__pycache__' not in p.parts) +for rel in rows: + print(hashlib.sha256((ROOT / rel).read_bytes()).hexdigest() + ' ' + rel) diff --git a/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md b/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md index 8657175..0cd4da3 100644 --- a/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md +++ b/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md @@ -107,6 +107,10 @@ revision 13: `deploy/lifecycle/RELEASE-INFD-WP-0010-T02.md`. Tests: 704 passed, workflows exist; purpose-overlay run 5 failed by design on the changed `policy.py` hash. The image pin is therefore still the live `f5588f49…`, and the candidate must not be synced until the pin is replaced by an owner-CI full image that keeps zlib ≥ 1.3.2-r1. Nothing applied. +Same day, step 0 in source: `.forgejo/workflows/service-image.yaml` builds the full image from the pushed commit and +publishes `main-`. The `Containerfile` builds the wheel in-image and installs the overlay's hash-pinned +zlib-1.3.2-r1 offline, failing the build below 1.3.2-r1. A local build passed 13/13 container checks. The image pin +waits for the digest from CI. Then here: the policy pin, a registry row, a release, and a native proof with synthetic BEA content. Optional: for `bounded-execution`, require a packet item whose digest equals `target.admission_digest`, once the admission