From c228583b609aaa7f0e45ea1fcda4f26e121ad2c5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Nov 2026 11:32:32 +0100 Subject: [PATCH] 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