mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
perf(docker): take the orphan-reaping pull and pack chmod off the image path (#21013)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - `docker.yml` builds and signs the standard Core image for every master push. Paperclip Cloud deploys a Core commit only after this image is published and attested. > - A master commit takes 29 min (p50) and 56 min (p90) to reach a published image. Every minute here delays a staging fix. > - The run logs show where the time goes. The exact-source gate (about 14 min) is the critical path today. The build legs finish about 3 min before it. After the gate, `merge-and-push` spends about 95 s to pull the 4.9 GB amd64 image for one probe. > - This pull request moves that probe into the build legs, which run before the gate. It also removes a duplicate 187 MB layer from the amd64 image. > - The benefit is about 95–100 s less from gate to signed image on every master commit, with the same proof. arm64 is now also probed. ## Linked Issues or Issue Description **What existing behavior does this improve?** The time from a commit landing on master to the signed standard image (`ghcr.io/paperclipai/paperclip:sha-<full>` plus its attestation). **Subsystem affected** `.github/workflows/docker.yml`, `Dockerfile`, `scripts/standard-image-contract.mjs`. **Current behavior** Measured on run 43133035209 (sha42fd790e, total 999 s): - Push 01:13:47. The slowest exact-source job, `General tests (server (7/10))`, ends 01:27:08. `Cloud source verified v1` waits 20 s for a runner and ends 01:27:44. The watcher sees it at its next 30 s poll, 01:27:58. - `build-and-push (amd64)` ends 01:24:52 and arm64 ends 01:21:59. Both have slack before the gate. Run 43134250912 shows the same shape: the gate opened 01:44:37 and amd64 ended 01:41:53. - `merge-and-push` starts 01:28:03 and pushes the manifest at 01:28:34. Then "Verify PID 1 reaps orphaned processes" pulls the full amd64 image (01:28:34 to 01:30:09, 95 s). "Verify the immutable standard image" runs the same probe again on the same bytes (5 s). The attestation ends 01:30:22. Last 60 master pushes (2026-11-11 20:17Z to 2026-11-12 03:21Z): 43 cancelled or superseded, 14 succeeded, 2 failed, 1 in progress. Push to first published image that contains the commit: p50 29.4 min, p90 55.5 min, max 85.6 min. A successful run takes 22.5 min (p50) from push. The median gap between pushes is 4.5 min. Where the amd64 "Build and push by digest" step spends its 582 s: | Time | Phase | What happens | | --- | --- | --- | | 0–8 s | Load | Dockerfile frontend, cache manifest import | | 8–54 s | Pull cached layers (46 s) | `deps`, `rust-chef`, `runner-deps`, `runner-build` and `base` are **all cache hits**. The `build` stage starts `FROM runner-build` and copies `deps`, so BuildKit downloads and extracts them: a 932 MB node_modules layer (13 s download, 24.5 s extract) and about 650 MB of Rust `target/` | | 54–108 s | Per-commit build (54 s) | `COPY . .` 10 s, mtime normalization 11 s, UI 5 s, plugin SDK 3 s, server tsc 18.5 s, runtime bundle 5.6 s | | 108–206 s | `runner-provider-pack` (98 s) | Rebuilt every commit: it is `FROM build` and stamps the commit. pnpm install about 25 s, pack 9 s, **Kimi smoke 37 s (amd64 only)**, archive 28 s | | parallel | `production` | Tool layer is a cache hit but pulls 1.11 GB (11 s). `COPY --from=build /app` 24 s | | 206–294 s | Pack copy and chmod | `COPY` pack 7.5 s. `chmod -R a+rX` plus UID check **71 s** (arm64: 20.5 s) | | 294–420 s | Image export (126 s) | 115 s compressing new layers: `/app` 1.40 GB, pack 1.01 GB, pack archives 1.06 GB, chmod copy 0.19 GB (compressed). Push 10 s | | 420–582 s | Cache export (172 s) | `mode=max`: 152 s "preparing" (compressing the per-commit layers of `build` and `runner-provider-pack`, which no later commit can reuse), 20 s upload | What rebuilds every commit, and why: only the `build` stage and everything after it. `PAPERCLIP_BUILD_COMMIT` is already declared after the dependency layers, `CLI_TOOLS_CACHE_EPOCH` is weekly, and dependency install already copies manifests first. The commit ARG is used by the UI build, the server stamp, and the provider pack, so those must rebuild. A pnpm `--mount=type=cache` gives nothing here: the runners are ephemeral, and cache mounts are not exported to the registry cache. Why amd64 is slower than arm64: both legs run on runs-on AWS 32-core instances (m6a.8xlarge and m6g.8xlarge), not on `ubuntu-latest`. The difference is content, not CPU. amd64 has a larger provider pack with the Kimi smoke test (37 s), a 187 MB chmod copy layer (arm64: 0 MB), a 4.9 GB image (arm64: 2.9 GB), and a 172 s cache export (arm64: 100 s). **Proposed behavior** Run the PID 1 probe in each build leg, on its own architecture, against the digest that leg pushed. Before signing, require that every Linux image in the standard index is one of those probed digests. Set the provider pack modes in the stage that writes the pack. **Reason and benefit** The probe pull leaves the post-gate path. The leg has about 3 min of slack before the gate. The chmod change removes about 50 s and 187 MB from the amd64 leg, which offsets most of the probe pull that the leg now does. **Breaking changes** None. Tags, digests, attestation subject, and signer identity are unchanged. **Additional context** The concurrency proposal and the build-in-PR plan are below, in "Risks". They are not implemented. ## What Changed - `docker.yml` `build-and-push`: new step "Verify PID 1 reaps orphaned processes". It runs `scripts/assert-orphan-reaping.sh` in `ghcr.io/...@<pushed digest>` with `--platform` set to the leg platform. It runs before "Export digest", so `merge-and-push` only receives probed digests. - `docker.yml` `merge-and-push`: removed the two `docker run` probes (tag and digest). "Resolve standard image digest" now runs `standard-image-contract.mjs --resolve "$GITHUB_SHA" --verified-digests /tmp/digests`. - `standard-image-contract.mjs`: `--verified-digests <dir>` reads each probed leg digest from the registry, checks that its bytes hash to that digest, and collects its Linux image digests. It fails unless every Linux image in the standard index is in that set. Registry content is addressed by digest, so this proves that the signed index names exactly the probed bytes, without a second pull. - `Dockerfile`: `chmod -R a+rX /provider-pack` now runs in the same `RUN` that writes the pack. `COPY` keeps the modes. The production stage keeps the readability check as UID 65534. - Tests: new contract tests (membership, bare manifest legs, digest mismatch, empty set, lookup by digest). New workflow test: the probe runs between the build and "Export digest", and the publisher does not `docker run`. Updated the provider-pack Dockerfile test. - `doc/standard-image-contract.md`: describes the per-leg probe and the membership check. Expected savings, per master commit: | Change | Saving | Confidence | | --- | --- | --- | | Probe moved out of `merge-and-push` | 95–100 s from gate to attestation (measured: 95 s pull + 5 s second probe) | High | | Pack chmod in its own stage | About 50 s and 187 MB in the amd64 leg (71 s vs 20.5 s on arm64, where the chmod layer is empty) | Medium-high on size, medium on seconds | | Net amd64 leg | About +10 to +45 s (probe pull estimated at 60–95 s, minus about 50 s). Still before the gate | Medium | ## Verification - `node --test scripts/standard-image-contract.test.mjs`: 10 pass (2 new). - `node --test` on `.github/scripts/tests/{cloud-readiness,docker-canary-promotion,post-merge-runner-routing,lockfile-refresh-workflows}.test.mjs`, `scripts/__tests__/release-verify-workflow.test.mjs`, `scripts/reuse-canary-image.test.mjs`, `scripts/standard-image-contract.test.mjs`, `scripts/cloud-source-verification.test.mjs`: 484 pass. - `vitest run server/src/__tests__/docker-build-stamp.test.ts server/src/__tests__/container-init-reaping.test.ts`: 22 pass. Red check: with the `origin/master` `Dockerfile` and `docker.yml`, the 2 new or changed assertions fail. - Real registry, read only: `node scripts/standard-image-contract.mjs --resolve42fd790e80--verified-digests <dir>` with the two leg digests from run 43133035209 returns the published index digest. With only the arm64 leg digest, it fails: "linux/amd64 image sha256:0f0642e6… did not pass the build-leg orphan-reaping probe". - `actionlint .github/workflows/docker.yml`: the same 6 shellcheck findings as `origin/master`, none new. - Shell syntax check (`sh -n`) of the changed pack `RUN`. Not exercised: the workflow itself (it runs only on master pushes, and I did not dispatch it). No local `docker build`: the Docker daemon is not running on this machine, and a full build does not fit a few minutes. The leg probe pull time is an estimate from the publisher runner. ## Risks - The leg now pulls its own image (4.9 GB amd64). AWS legs have about 175 GB free. Hosted tag builds run "Free runner disk" first, and the old publisher pulled the same image on a hosted runner for tag builds. - If the registry returns a leg index in an unexpected form, the resolve step fails closed and no master image is signed. The real-registry check above used today's exact shapes (OCI index with one platform image and one attestation manifest per leg). - Tag builds (`v*`, `nightly/v*`, `beta/v*`) keep the probe, now per leg. They have no standard-image signing step, and the manifest list is made only from probed digests. ### Deliberately not changed - **The exact-source gate.** It is a deliberate "never publish an unverified image" rule (`watch-release-source.mjs --require-proof` and `--check-proof`, `doc/cloud-build-readiness.md`). I kept it. The owner levers are: the slowest `cloud-readiness` shard (`server (7/10)`, 12 min, sets the gate), the 20 s runner wait for `Cloud source verified v1`, and the watcher's 30 s poll (15 s average delay). I did not shorten the poll because the org API rate limit was exhausted today. - **Cache export (172 s amd64, 100 s arm64).** `mode=max` compresses about 2 GB of per-commit pack layers every build. A fix: a separate cache-only build of a small aggregate stage (`deps`, `runner-build`, the production tool layer) with `mode=max`, and the main build with `cache-from` only. Not done here: the legs are not on the master critical path today, and I cannot measure it without a master run. It is worth doing for tag builds or if the gate gets faster. - **Cached layer pull (46 s).** The `build` stage starts `FROM runner-build`, so it downloads about 650 MB of Rust `target/` that it deletes at the end. The server build's `prepare:runner-vendor` uses that target, so this needs a separate change. - **Provider pack per commit (98 s amd64).** It rebuilds because it is `FROM build` and stamps the commit. Its 1.06 GB transport archives also duplicate the 1.01 GB pack inside the image. Keying the pack on its own inputs is a product-contract change. ### Concurrency proposal (not implemented) Today one run holds `docker-${{ github.ref }}` for 17–23 min. Most of that is waiting for the gate, not building. A newer commit waits for that run before its build starts (run 43134250912 waited 6.4 min). Proposal: 1. Remove the workflow-level group. Add job-level groups: `build-and-push` uses `docker-build-${{ github.ref }}-${{ matrix.arch }}` with `cancel-in-progress: false`. At most one build per architecture runs and the newest commit replaces the pending one. So not every commit is built. 2. `merge-and-push` uses `docker-publish-${{ github.ref }}` with `cancel-in-progress: false`. It skips publication when a newer master commit already has a signed image, so the image order stays monotonic. 3. A superseded run must not keep its source watcher alive, or the API load grows with each push. Fold the early watcher into the gate job, or end it when its build job is cancelled. Effect: the newest commit starts building at push, so its image publishes at about gate time plus 45 s, instead of after the previous run ends. That removes the 0–17 min queue wait, which is most of the gap between the p50 of 29 min and the gate time of about 14 min. ### Plan: build in PR CI and promote on merge (not in this PR) What must change: 1. Turn on the GitHub merge queue for `master`. The queue tests a `gh-readonly-queue/...` commit and then fast-forwards `master` to that exact commit, so the SHA stamped in the image (`PAPERCLIP_BUILD_COMMIT`, `build-info.json`, the provider pack `runnerSourceRevision`, the UI bundle) is the final master SHA. 2. On `merge_group`: build both architectures with **no secrets and no registry credentials**, because the code under test is not yet on master. Write each image as an OCI layout artifact. A second job that runs no PR code pushes those artifacts by digest to a staging repository, keyed by the merge-group SHA. 3. Run `cloud-readiness` on `merge_group` for the same SHA, so exact-source proof exists before merge. 4. On the master push, `docker.yml` looks up the staged digests for `github.sha`, checks that the tree and SHA match, retags them as `sha-<full>`, and signs with the same identity (`docker.yml@refs/heads/master`). If no staged image exists, it falls back to the current build. Guarantees to keep: never publish or sign an image without exact-source proof for that SHA. The signer identity and the attested digest stay the same. No secrets in any job that runs non-CODEOWNER code. The PID 1 probe runs on both architectures before signing. Superseded queue entries do not publish. Expected saving: from master push to signed image, about 1–2 min (lookup, retag, attest) instead of 29 min p50 and 56 min p90 today. The work moves before the merge, so the merge itself waits for the queue (about the current gate time of 14 min). A staging fix then reaches Cloud about 25 min sooner at the median. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used via Claude Code with tool use (shell, file edits, read-only GitHub API and registry reads). Default reasoning mode. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5820d43197
commit
f8e1fb0278
7 files changed
+185
-45
No files matched your search
@@ -311,6 +311,29 @@ jobs:
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.arch }},mode=max
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# PID 1 must be an init that reaps adopted orphans. With node there, the
|
||||
# orphans agent runs leave behind are never wait()ed and pin as zombies
|
||||
# until the cgroup pid limit is exhausted and every fork() in the
|
||||
# container fails.
|
||||
#
|
||||
# Probed here, on this leg's own architecture, against the exact digest
|
||||
# just pushed. On master this runs while the exact-source gate is still
|
||||
# pending, so the image pull (~95 s for amd64) is off the publication
|
||||
# path, and arm64 is exercised too (the publisher runner is x86_64 only).
|
||||
# Only a probed digest is exported, and merge-and-push signs an index
|
||||
# only when its every Linux image is one of these probed digests.
|
||||
- name: Verify PID 1 reaps orphaned processes
|
||||
env:
|
||||
# Through the environment, not interpolated into the script body.
|
||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$IMAGE_DIGEST"
|
||||
echo "Verifying orphan reaping in $IMAGE_NAME@$IMAGE_DIGEST ($PLATFORM)"
|
||||
docker run --rm -i --pull always --platform "$PLATFORM" "$IMAGE_NAME@$IMAGE_DIGEST" sh -s < scripts/assert-orphan-reaping.sh
|
||||
|
||||
# The digest is the only thing `merge` needs from this job. Carried as an
|
||||
# empty file named for it, which is the upstream pattern — the name is
|
||||
# the payload, so several legs can upload without colliding on content.
|
||||
@@ -368,8 +391,8 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
# Checked out for `packages/db` (schema labels) and the orphan-reaping
|
||||
# script the verification step pipes in.
|
||||
# Checked out for `packages/db` (schema labels) and the source-proof and
|
||||
# standard-image contract scripts.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
@@ -444,37 +467,20 @@ jobs:
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
# PID 1 must be an init that reaps adopted orphans. With node there, the
|
||||
# orphans agent runs leave behind are never wait()ed and pin as zombies
|
||||
# until the cgroup pid limit is exhausted and every fork() in the
|
||||
# container fails. Run against the pushed manifest rather than a local
|
||||
# build: the legs push by digest, so nothing is loaded into this
|
||||
# runner's daemon.
|
||||
- name: Verify PID 1 reaps orphaned processes
|
||||
env:
|
||||
# Through the environment, not interpolated into the script body, so
|
||||
# the tag text is data rather than shell.
|
||||
IMAGE_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="$(printf '%s\n' "$IMAGE_TAGS" | head -n 1)"
|
||||
test -n "$image"
|
||||
echo "Verifying orphan reaping in $image"
|
||||
docker run --rm -i --pull always "$image" sh -s < scripts/assert-orphan-reaping.sh
|
||||
# The orphan-reaping probe ran in each build leg against the digest that
|
||||
# leg pushed; /tmp/digests holds only probed digests. Pulling the image
|
||||
# again here cost ~95 s after the source gate, on the publication path.
|
||||
|
||||
# This generic contract is useful to any downstream image composer. Only
|
||||
# canonical master pushes own the full-SHA tag and its signed provenance.
|
||||
# Capture exact registry bytes; both the smoke and attestation use their
|
||||
# digest, so later tag movement cannot change what was verified.
|
||||
# Capture exact registry bytes and sign that digest, so later tag movement
|
||||
# cannot change what was verified. Registry content is addressed by
|
||||
# digest: the resolve step fails unless every Linux image in the index is
|
||||
# one the build legs probed.
|
||||
- name: Resolve standard image digest
|
||||
id: standard
|
||||
if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
run: node scripts/standard-image-contract.mjs --resolve "$GITHUB_SHA"
|
||||
- name: Verify the immutable standard image
|
||||
if: steps.standard.outputs.digest != ''
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.standard.outputs.digest }}
|
||||
run: docker run --rm -i --pull always "ghcr.io/paperclipai/paperclip@$IMAGE_DIGEST" sh -s < scripts/assert-orphan-reaping.sh
|
||||
run: node scripts/standard-image-contract.mjs --resolve "$GITHUB_SHA" --verified-digests /tmp/digests
|
||||
- name: Attest the standard production image
|
||||
if: steps.standard.outputs.digest != ''
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
|
||||
|
||||
+11
-4
@@ -164,6 +164,12 @@ RUN rm -rf packages/paperclip-runner/runner/target
|
||||
FROM build AS runner-provider-pack
|
||||
# Unstamped local builds remain usable, but cannot qualify a remote pack.
|
||||
# Never invent a source revision to make an unqualified pack look verified.
|
||||
# Managed deployments can remap node's UID at startup, so the pack must stay
|
||||
# readable by any UID. Grant that here, in the same layer that writes the
|
||||
# files: COPY keeps these modes. A `chmod -R` after the COPY into production
|
||||
# rewrote every changed file into a second copy of the pack (187 MB compressed
|
||||
# and ~50 s of build time on amd64), and the production stage still checks
|
||||
# readability as an unrelated UID below.
|
||||
RUN mkdir -p /provider-pack /provider-pack-transport \
|
||||
&& if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \
|
||||
PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack \
|
||||
@@ -171,7 +177,8 @@ RUN mkdir -p /provider-pack /provider-pack-transport \
|
||||
&& mv /provider-pack.archives /provider-pack.archive.json /provider-pack-transport/; \
|
||||
else \
|
||||
echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote OpenCode/ACPX execution"; \
|
||||
fi
|
||||
fi \
|
||||
&& chmod -R a+rX /provider-pack
|
||||
|
||||
FROM base AS production
|
||||
ARG USER_UID=1000
|
||||
@@ -202,10 +209,10 @@ COPY --chown=0:0 --from=runner-provider-pack /provider-pack /opt/paperclip-runne
|
||||
COPY --from=runner-provider-pack /provider-pack-transport/ /opt/paperclip-runner/
|
||||
# Managed deployments can remap node's UID at startup. This immutable pack
|
||||
# contains public code and integrity metadata, never credentials; it must remain
|
||||
# readable afterward.
|
||||
# readable afterward. The runner-provider-pack stage grants a+rX before this
|
||||
# COPY, which preserves modes.
|
||||
# Keep it root-owned and verify access as an unrelated unprivileged UID.
|
||||
RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \
|
||||
&& if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \
|
||||
RUN if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \
|
||||
gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \
|
||||
fi
|
||||
ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack
|
||||
|
||||
@@ -5,9 +5,11 @@ image at `ghcr.io/paperclipai/paperclip:sha-<full-40-character-SHA>`.
|
||||
Existing short-SHA, version and channel tags continue to work. Other refs and
|
||||
manual dispatches do not write the canonical full-SHA tag.
|
||||
|
||||
The Docker workflow validates the index contains exactly one Linux amd64 and
|
||||
one Linux arm64 image, verifies orphan reaping against its immutable digest,
|
||||
and signs that digest using GitHub artifact attestations. The signer is
|
||||
Each native build leg verifies orphan reaping by running the exact image digest
|
||||
it pushed on its own architecture. The Docker workflow then validates the index
|
||||
contains exactly one Linux amd64 and one Linux arm64 image, requires both to be
|
||||
digests the build legs verified, and signs the index digest using GitHub
|
||||
artifact attestations. The signer is
|
||||
`paperclipai/paperclip/.github/workflows/docker.yml@refs/heads/master`.
|
||||
Consumers must verify the signed source SHA, repository identity and ref,
|
||||
then compose from the verified digest, never from the mutable lookup tag.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import { appendFile, readdir, readFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const indexMediaTypes = ["application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"];
|
||||
const sha256 = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
|
||||
/** Validate the multi-platform index before it becomes a signed build contract. */
|
||||
export function standardImageDigest(bytes) {
|
||||
const index = JSON.parse(bytes.toString("utf8"));
|
||||
assert.equal(index.schemaVersion, 2);
|
||||
assert.ok(["application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"].includes(index.mediaType));
|
||||
assert.ok(indexMediaTypes.includes(index.mediaType));
|
||||
assert.ok(Array.isArray(index.manifests));
|
||||
for (const architecture of ["amd64", "arm64"]) {
|
||||
const matches = index.manifests.filter(item => item.platform?.os === "linux" && item.platform?.architecture === architecture);
|
||||
@@ -15,13 +18,44 @@ export function standardImageDigest(bytes) {
|
||||
assert.match(matches[0].digest, /^sha256:[a-f0-9]{64}$/);
|
||||
assert.ok(Number.isSafeInteger(matches[0].size) && matches[0].size > 0);
|
||||
}
|
||||
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
return sha256(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform image digests covered by the build legs' native PID 1 probe. Each
|
||||
* leg probes the digest it pushed; that is a per-leg index (image plus
|
||||
* provenance) or, without provenance, the image manifest itself. `legs` maps
|
||||
* each probed digest to its registry bytes.
|
||||
*/
|
||||
export function verifiedImageDigests(legs) {
|
||||
const verified = new Set();
|
||||
for (const [digest, bytes] of legs) {
|
||||
assert.match(digest, /^sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(sha256(bytes), digest, "Verified image bytes do not match their digest");
|
||||
const manifest = JSON.parse(bytes.toString("utf8"));
|
||||
if (!indexMediaTypes.includes(manifest.mediaType)) { verified.add(digest); continue; }
|
||||
for (const item of manifest.manifests ?? []) {
|
||||
if (item.platform?.os === "linux" && /^sha256:[a-f0-9]{64}$/.test(item.digest ?? "")) verified.add(item.digest);
|
||||
}
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
|
||||
/** Sign only an index whose every Linux image passed the build-leg probe. */
|
||||
export function assertIndexUsesVerifiedImages(indexBytes, verified) {
|
||||
standardImageDigest(indexBytes);
|
||||
const index = JSON.parse(indexBytes.toString("utf8"));
|
||||
for (const item of index.manifests.filter(entry => entry.platform?.os === "linux")) {
|
||||
assert.ok(verified.has(item.digest),
|
||||
`linux/${item.platform.architecture} image ${item.digest} did not pass the build-leg orphan-reaping probe`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read registry bytes directly; CLI stdout formatting is not artifact content. */
|
||||
export async function resolveStandardImageDigest(sha, fetchImpl = fetch, {
|
||||
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
missing = "error",
|
||||
verifiedDigests,
|
||||
} = {}) {
|
||||
assert.match(sha ?? "", /^[a-f0-9]{40}$/);
|
||||
const request = async (url, headers, consume, allowNotFound = false) => {
|
||||
@@ -55,10 +89,10 @@ export async function resolveStandardImageDigest(sha, fetchImpl = fetch, {
|
||||
};
|
||||
const { token } = await request("https://ghcr.io/token?service=ghcr.io&scope=repository:paperclipai/paperclip:pull", {}, response => response.json());
|
||||
assert.ok(typeof token === "string" && token, "Missing public pull token");
|
||||
const manifest = await request(`https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${sha}`, {
|
||||
authorization: `Bearer ${token}`, accept: "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
const readManifest = (reference, accept, allowNotFound) => request(`https://ghcr.io/v2/paperclipai/paperclip/manifests/${reference}`, {
|
||||
authorization: `Bearer ${token}`, accept,
|
||||
}, async response => {
|
||||
assert.ok(response.body, "Standard image index has no body");
|
||||
assert.ok(response.body, "Registry manifest has no body");
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
@@ -67,21 +101,47 @@ export async function resolveStandardImageDigest(sha, fetchImpl = fetch, {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
length += next.value.length;
|
||||
if (length > 1024 * 1024) throw new RangeError("Standard index exceeds its size limit");
|
||||
if (length > 1024 * 1024) throw new RangeError("Registry manifest exceeds its size limit");
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally { await reader.cancel(); }
|
||||
return { raw: Buffer.concat(chunks), expected: response.headers.get("docker-content-digest") };
|
||||
}, true);
|
||||
}, allowNotFound);
|
||||
const manifest = await readManifest(`sha-${sha}`, indexMediaTypes.join(", "), true);
|
||||
if (manifest === null) return null;
|
||||
const { raw, expected } = manifest;
|
||||
const digest = standardImageDigest(raw);
|
||||
assert.equal(digest, expected, "Registry digest does not match the index bytes");
|
||||
if (verifiedDigests) {
|
||||
// The build legs ran the PID 1 probe on these digests. Registry content is
|
||||
// addressed by digest, so membership proves the signed index names exactly
|
||||
// the probed images without pulling them again here.
|
||||
assert.ok(verifiedDigests.length > 0, "No build leg reported a probed image");
|
||||
const legs = [];
|
||||
for (const legDigest of verifiedDigests) {
|
||||
assert.match(legDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
const leg = await readManifest(legDigest, [...indexMediaTypes, "application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json"].join(", "), false);
|
||||
legs.push([legDigest, leg.raw]);
|
||||
}
|
||||
assertIndexUsesVerifiedImages(raw, verifiedImageDigests(legs));
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
/** Digests the build legs probed, carried as empty files named for each digest. */
|
||||
async function readVerifiedDigests(directory) {
|
||||
const names = await readdir(directory);
|
||||
return names.map(name => {
|
||||
assert.match(name, /^[a-f0-9]{64}$/, `Unexpected verified digest entry: ${name}`);
|
||||
return `sha256:${name}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const digest = process.argv[2] === "--resolve" ? await resolveStandardImageDigest(process.argv[3]) : standardImageDigest(await readFile(process.argv[2]));
|
||||
const verifiedFlag = process.argv.indexOf("--verified-digests");
|
||||
const verifiedDigests = verifiedFlag > 0 ? await readVerifiedDigests(process.argv[verifiedFlag + 1]) : undefined;
|
||||
const digest = process.argv[2] === "--resolve" ? await resolveStandardImageDigest(process.argv[3], fetch, { verifiedDigests }) : standardImageDigest(await readFile(process.argv[2]));
|
||||
if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `digest=${digest}\n`);
|
||||
console.log(digest);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { standardImageDigest, resolveStandardImageDigest } from "./standard-image-contract.mjs";
|
||||
import { standardImageDigest, resolveStandardImageDigest, verifiedImageDigests, assertIndexUsesVerifiedImages } from "./standard-image-contract.mjs";
|
||||
import { isNode24ImageTag } from "./node-image-policy.mjs";
|
||||
|
||||
test("Node image policy accepts Node 24 major tags and exact security pins", () => {
|
||||
@@ -91,3 +91,50 @@ test("optional lookup falls back only for a missing manifest", async () => {
|
||||
assert.equal(calls, status === 503 ? 5 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
const digestOf = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
const image = name => Buffer.from(JSON.stringify({ schemaVersion: 2, mediaType: "application/vnd.oci.image.manifest.v1+json", name }));
|
||||
// The build legs push by digest with provenance, so each leg digest is an index
|
||||
// of one platform image plus its attestation manifest. The merged standard
|
||||
// index names those same child digests.
|
||||
function legFixture() {
|
||||
const amd64 = digestOf(image("amd64")), arm64 = digestOf(image("arm64"));
|
||||
const attestation = arch => ({ platform: { os: "unknown", architecture: "unknown" }, digest: digestOf(image(`att-${arch}`)), size: 50,
|
||||
annotations: { "vnd.docker.reference.type": "attestation-manifest" } });
|
||||
const leg = (arch, digest) => index([{ platform: { os: "linux", architecture: arch }, digest, size: 100 }, attestation(arch)]);
|
||||
const legs = new Map([[digestOf(leg("amd64", amd64)), leg("amd64", amd64)], [digestOf(leg("arm64", arm64)), leg("arm64", arm64)]]);
|
||||
const standard = index([{ platform: { os: "linux", architecture: "arm64" }, digest: arm64, size: 100 }, attestation("arm64"),
|
||||
{ platform: { os: "linux", architecture: "amd64" }, digest: amd64, size: 100 }, attestation("amd64")]);
|
||||
return { amd64, legs, standard };
|
||||
}
|
||||
|
||||
test("signing requires every Linux image in the index to have passed a build-leg probe", () => {
|
||||
const { amd64, legs, standard } = legFixture();
|
||||
assert.doesNotThrow(() => assertIndexUsesVerifiedImages(standard, verifiedImageDigests(legs)));
|
||||
const onlyArm = new Map([...legs].filter(([, bytes]) => !bytes.toString().includes(amd64)));
|
||||
assert.throws(() => assertIndexUsesVerifiedImages(standard, verifiedImageDigests(onlyArm)), /linux\/amd64 image .* orphan-reaping probe/);
|
||||
// A leg that pushed a bare image manifest verifies exactly that digest.
|
||||
const bare = image("amd64");
|
||||
assert.deepEqual([...verifiedImageDigests(new Map([[digestOf(bare), bare]]))], [digestOf(bare)]);
|
||||
// Bytes must hash to the digest the leg reported.
|
||||
assert.throws(() => verifiedImageDigests(new Map([[digestOf(bare), image("other")]])), /do not match their digest/);
|
||||
});
|
||||
|
||||
test("lookup with verified digests reads each leg by digest and rejects an unprobed index", async () => {
|
||||
const { legs, standard } = legFixture();
|
||||
const expected = standardImageDigest(standard);
|
||||
const requested = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
if (url.includes("/token?")) return Response.json({ token: "public-fixture" });
|
||||
const reference = url.split("/manifests/")[1];
|
||||
requested.push(reference);
|
||||
if (reference.startsWith("sha-")) return new Response(standard, { headers: { "docker-content-digest": expected } });
|
||||
assert.match(options.headers.accept, /image\.manifest\.v1\+json/);
|
||||
return new Response(legs.get(reference), { headers: { "docker-content-digest": reference } });
|
||||
};
|
||||
const legDigests = [...legs.keys()];
|
||||
assert.equal(await resolveStandardImageDigest("a".repeat(40), fetchImpl, { verifiedDigests: legDigests }), expected);
|
||||
assert.deepEqual(requested, [`sha-${"a".repeat(40)}`, ...legDigests]);
|
||||
await assert.rejects(resolveStandardImageDigest("a".repeat(40), fetchImpl, { verifiedDigests: legDigests.slice(0, 1) }), /orphan-reaping probe/);
|
||||
await assert.rejects(resolveStandardImageDigest("a".repeat(40), fetchImpl, { verifiedDigests: [] }), /No build leg/);
|
||||
});
|
||||
@@ -135,6 +135,21 @@ describe("orphan-reaping probe", () => {
|
||||
expect(source).toContain("scripts/assert-orphan-reaping.sh");
|
||||
});
|
||||
|
||||
it("probes each pushed platform digest before it can be published or signed", () => {
|
||||
// The probe runs in every native build leg, before that leg exports the
|
||||
// digest merge-and-push assembles. Publication then signs only an index
|
||||
// whose Linux images are those probed digests, instead of pulling the
|
||||
// image again after the exact-source gate.
|
||||
const leg = dockerWorkflow.split("\n build-and-push:\n")[1].split("\n publication_verification:\n")[0];
|
||||
const probe = leg.indexOf("- name: Verify PID 1 reaps orphaned processes");
|
||||
expect(probe).toBeGreaterThan(leg.indexOf("- name: Build and push by digest"));
|
||||
expect(probe).toBeLessThan(leg.indexOf("- name: Export digest"));
|
||||
expect(leg).toContain('--platform "$PLATFORM" "$IMAGE_NAME@$IMAGE_DIGEST" sh -s < scripts/assert-orphan-reaping.sh');
|
||||
const publisher = dockerWorkflow.split("\n merge-and-push:\n")[1].split("\n promote_canary_channel:\n")[0];
|
||||
expect(publisher).toContain('standard-image-contract.mjs --resolve "$GITHUB_SHA" --verified-digests /tmp/digests');
|
||||
expect(publisher).not.toContain("docker run");
|
||||
});
|
||||
|
||||
it("fails closed when the orphan is never adopted by PID 1", () => {
|
||||
// A probe whose grandchild is not reparented onto PID 1 proves nothing, so
|
||||
// it must error rather than report a pass it did not observe.
|
||||
|
||||
@@ -118,7 +118,10 @@ describe("Standard image remote provider pack", () => {
|
||||
expect(pack).toContain("Skipping remote provider pack");
|
||||
expect(production).toContain("--from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack");
|
||||
expect(production).not.toContain("--chown=node:node --from=runner-provider-pack");
|
||||
expect(production).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack");
|
||||
// Modes are granted in the layer that writes the pack. A recursive chmod
|
||||
// after the COPY duplicates every changed file into another image layer.
|
||||
expect(pack).toMatch(/build-provider-pack\.mjs[\s\S]*&& chmod -R a\+rX \/provider-pack$/m);
|
||||
expect(production).not.toMatch(/chmod -R [^\n]*\/opt\/paperclip-runner\/provider-pack/);
|
||||
expect(production).toContain("gosu 65534:65534 node");
|
||||
expect(production).toContain("Object.values(manifest.payload.artifacts)");
|
||||
expect(production).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack");
|
||||
|
||||
Reference in new issue
Block a user