From c6c0a431bf8b3a66f43bbe0def3fa6c73209b935 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 27 Aug 2026 12:13:20 -0500 Subject: [PATCH] ci(runner): reuse Daytona images by content --- .github/workflows/runner-full-stack-e2e.yml | 25 ++- doc/plans/2026-08-26-runner-full-stack-e2e.md | 15 +- docker/daytona-runner/Dockerfile | 7 + docker/daytona-runner/README.md | 12 +- package.json | 1 + tests/runner-e2e/README.md | 32 ++-- tests/runner-e2e/daytona-image-content.ts | 144 ++++++++++++++++++ tests/runner-e2e/daytona-image.test.ts | 93 ++++++++++- 8 files changed, 309 insertions(+), 20 deletions(-) create mode 100644 tests/runner-e2e/daytona-image-content.ts diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 235e6743cec..a9a41135d3a 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -46,6 +46,7 @@ jobs: matrix: ${{ steps.catalog.outputs.matrix }} needs_daytona: ${{ steps.catalog.outputs.needs_daytona }} execution_ids: ${{ steps.catalog.outputs.execution_ids }} + daytona_image_content_id: ${{ steps.daytona_image_content.outputs.content_id }} steps: - uses: actions/checkout@v7 @@ -60,6 +61,10 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Compute Daytona image content ID + id: daytona_image_content + run: echo "content_id=$(pnpm --silent test:e2e:runner:image-id)" >> "$GITHUB_OUTPUT" + - name: Validate selectors and emit matrix id: catalog env: @@ -128,6 +133,8 @@ jobs: id-token: write outputs: image: ${{ steps.image.outputs.image }} + source_revision: ${{ steps.image.outputs.source_revision }} + content_id: ${{ steps.image.outputs.content_id }} steps: - uses: actions/checkout@v7 @@ -156,19 +163,24 @@ jobs: id: image env: NEEDS_DAYTONA: ${{ needs.catalog.outputs.needs_daytona }} - IMAGE_TAG: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-git-${{ github.sha }} + IMAGE_CONTENT_ID: ${{ needs.catalog.outputs.daytona_image_content_id }} + IMAGE_TAG: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-${{ needs.catalog.outputs.daytona_image_content_id }} run: | set -euo pipefail if [ "$NEEDS_DAYTONA" != true ]; then echo "image=" >> "$GITHUB_OUTPUT" + echo "source_revision=" >> "$GITHUB_OUTPUT" + echo "content_id=" >> "$GITHUB_OUTPUT" exit 0 fi + [[ "$IMAGE_CONTENT_ID" =~ ^[0-9a-f]{64}$ ]] identity="^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/runner-full-stack-e2e.yml@" if docker buildx imagetools inspect "$IMAGE_TAG" >/dev/null 2>&1; then digest="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')" else docker buildx build \ --platform linux/amd64 \ + --build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${IMAGE_CONTENT_ID}" \ --build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${GITHUB_SHA}" \ --file docker/daytona-runner/Dockerfile \ --tag "$IMAGE_TAG" \ @@ -198,6 +210,10 @@ jobs: # configuration here without creating a redundant container. image_config="$(docker image inspect "$immutable" \ --format '{{json .}}')" + published_content_id="$(jq -r '.Config.Labels["io.paperclip.runner.content-id"] // empty' <<< "$image_config")" + source_revision="$(jq -r '.Config.Labels["org.opencontainers.image.revision"] // empty' <<< "$image_config")" + test "$published_content_id" = "$IMAGE_CONTENT_ID" + [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] jq -e \ '.Architecture == "amd64" and .Os == "linux" and @@ -205,6 +221,8 @@ jobs: (.Config.Env | any(startswith("PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=")))' \ <<< "$image_config" >/dev/null echo "image=$immutable" >> "$GITHUB_OUTPUT" + echo "source_revision=$source_revision" >> "$GITHUB_OUTPUT" + echo "content_id=$published_content_id" >> "$GITHUB_OUTPUT" test: name: ${{ matrix.executionId }} @@ -236,6 +254,11 @@ jobs: - name: Build native remote provider pack if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-')) + env: + # Reused images retain the source revision that was embedded in their + # provider pack. Matching it here lets the server reuse that exact + # preinstalled pack instead of uploading a duplicate to the lease. + PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }} run: pnpm --filter @paperclipai/paperclip-runner build:provider-pack - name: Install pinned legacy Claude CLI diff --git a/doc/plans/2026-08-26-runner-full-stack-e2e.md b/doc/plans/2026-08-26-runner-full-stack-e2e.md index 36307592ea7..a0ee1cd3c74 100644 --- a/doc/plans/2026-08-26-runner-full-stack-e2e.md +++ b/doc/plans/2026-08-26-runner-full-stack-e2e.md @@ -128,11 +128,16 @@ schedulable profile/environment/case jobs for a full run. The paid matrix uses `fail-fast:false`, `max-parallel:16`, and 25-minute local or 40-minute Daytona job limits that cover the scenario deadline plus one fresh-harness retry. -When Daytona is selected, one image job reuses a verified digest for the tested -SHA or builds `linux/amd64` from `docker/daytona-runner/Dockerfile`, pushes -`e2e-git-`, signs with Cosign/OIDC, verifies anonymous pull, checks runner -contract/transport metadata, and exposes the immutable digest to every Daytona -cell. +When Daytona is selected, one image job computes an audited content ID from the +`linux/amd64` platform, Dockerfile, root package/lock/build configuration, +dependency patches, eval-kernel package, and runner package. It reuses the +verified `e2e-content-` image when that dependency closure is unchanged, +or builds and publishes it otherwise. The tested Git SHA remains separate image +provenance. Reuse reads that original revision back into the controller-side +provider-pack build so its manifest remains byte-identical to the preinstalled +pack. The job signs with Cosign/OIDC, verifies anonymous pull, checks the content +label and runner contract/transport metadata, and exposes the immutable digest +to every Daytona cell. Every execution uploads a 30-day sanitized bundle. The final job always downloads evidence, merges Playwright blob reports into HTML/JUnit, stages the sanitized diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index e31f822a4fd..cf9c1427b03 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -32,6 +32,13 @@ RUN pnpm --filter @paperclipai/paperclip-runner build:typescript \ # runner-specific addition is /usr/local/bin/paperclip-runnerd below. FROM daytonaio/sandbox:0.8.0 +ARG PAPERCLIP_RUNNER_CONTENT_ID +ARG PAPERCLIP_RUNNER_SOURCE_REVISION +RUN test -n "${PAPERCLIP_RUNNER_CONTENT_ID}" \ + && test -n "${PAPERCLIP_RUNNER_SOURCE_REVISION}" +LABEL io.paperclip.runner.content-id="${PAPERCLIP_RUNNER_CONTENT_ID}" \ + org.opencontainers.image.revision="${PAPERCLIP_RUNNER_SOURCE_REVISION}" + USER root ENV PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT=/opt/paperclip-runner/provider-pack diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index 8cee4b5202c..79b106ed9fa 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -18,17 +18,19 @@ The fleet image is currently amd64-only because the pinned Cursor and GitHub CLI checksums cover amd64. ```bash +content_id="$(pnpm --silent test:e2e:runner:image-id)" docker buildx build \ --platform linux/amd64 \ + --build-arg PAPERCLIP_RUNNER_CONTENT_ID="${content_id}" \ --build-arg PAPERCLIP_RUNNER_SOURCE_REVISION="$(git rev-parse HEAD)" \ - --tag paperclip-daytona-runner:dev \ + --tag "paperclip-daytona-runner:e2e-content-${content_id}" \ --load \ --file docker/daytona-runner/Dockerfile \ . docker run --rm --platform linux/amd64 \ --entrypoint paperclip-runnerd \ - paperclip-daytona-runner:dev \ + "paperclip-daytona-runner:e2e-content-${content_id}" \ --build-metadata ``` @@ -36,6 +38,12 @@ The metadata must advertise `dial_ws_loopback`, `dial_wss`, and `listen_ws`. The explicit entrypoint is needed only for this local probe because Daytona's base image uses its own long-running sandbox entrypoint. +`test:e2e:runner:image-id` hashes the audited Docker build dependency closure +and target platform. Git commits that do not change those inputs reuse the same +content tag. `PAPERCLIP_RUNNER_SOURCE_REVISION` remains the full Git SHA that +built the first published copy and is retained as provenance rather than cache +identity. + ## Use in Paperclip Publish the image to a registry Daytona can pull, or use the environment diff --git a/package.json b/package.json index 76ffc1edfa2..5dc9a34c754 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack", "test:e2e": "pnpm run preflight:workspace-links && npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:runner": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/launch.ts", + "test:e2e:runner:image-id": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/daytona-image-content.ts", "test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts", "test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json", "test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts", diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 7335962e3dd..708f98e9767 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -104,23 +104,37 @@ Use the immutable digest printed by the `Publish verified Daytona image` job, or publish the current source locally: ```bash -image="ghcr.io/paperclipai/paperclip-daytona-runner:e2e-git-$(git rev-parse HEAD)" -docker buildx build \ - --platform linux/amd64 \ - --build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=$(git rev-parse HEAD)" \ - --file docker/daytona-runner/Dockerfile \ - --tag "$image" \ - --push \ - . +content_id="$(pnpm --silent test:e2e:runner:image-id)" +source_revision="$(git rev-parse HEAD)" +image="ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-${content_id}" +if ! docker buildx imagetools inspect "$image" >/dev/null 2>&1; then + docker buildx build \ + --platform linux/amd64 \ + --build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${content_id}" \ + --build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${source_revision}" \ + --file docker/daytona-runner/Dockerfile \ + --tag "$image" \ + --push \ + . +fi docker buildx imagetools inspect "$image" ``` +The content ID hashes the audited image inputs, including the Dockerfile, +platform, root package/lock/build configuration, dependency patches, +`paperclip-eval-kernel`, and `paperclip-runner`. Changes elsewhere in the +repository keep the same tag and reuse the already signed image. The Git SHA is +stored separately as image provenance. CI reads that provenance back from a +reused image when it builds the controller-side provider pack, preserving the +exact manifest match required to avoid restaging the pack into Daytona. + Resolve the manifest digest and set `PAPERCLIP_E2E_DAYTONA_IMAGE` to `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:...`. The repository workflow signs that digest with Cosign/OIDC and verifies that it is publicly pullable, includes the provider pack, and advertises `dial_ws_loopback`, `dial_wss`, and `listen_ws`. The GHCR package must be configured as public; -the image job deliberately fails its anonymous-pull check otherwise. +the image job deliberately fails its anonymous-pull check otherwise. Existing +content tags are never rebuilt or overwritten by the workflow. ## Evidence and cleanup diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts new file mode 100644 index 00000000000..430bd38597e --- /dev/null +++ b/tests/runner-e2e/daytona-image-content.ts @@ -0,0 +1,144 @@ +import { createHash } from "node:crypto"; +import { lstat, readdir, readFile, readlink } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = path.resolve(import.meta.dirname, "../.."); + +export const DAYTONA_IMAGE_CONTENT_SCHEMA = + "paperclip-daytona-runner-image-content/v1"; +export const DAYTONA_IMAGE_PLATFORM = "linux/amd64"; + +// This is the audited dependency closure of docker/daytona-runner/Dockerfile. +// Keep it conservative: a false positive only rebuilds the image, while a +// missing input could incorrectly reuse an incompatible paid-test image. +export const DAYTONA_IMAGE_INPUT_PATHS = [ + ".dockerignore", + ".npmrc", + "docker/daytona-runner/Dockerfile", + "package.json", + "patches", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "scripts/link-plugin-dev-sdk.mjs", + "tsconfig.base.json", + "packages/paperclip-eval-kernel", + "packages/paperclip-runner", +] as const; + +const ignoredDirectoryPaths = new Set([ + "packages/paperclip-runner/dist", + "packages/paperclip-runner/runner/target", +]); + +export interface DaytonaImageContentOptions { + repositoryRoot?: string; + inputPaths?: readonly string[]; + platform?: string; +} + +function compareNames(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function normalizedRelativePath(value: string): string { + return value.split(path.sep).join("/"); +} + +function shouldIgnore(relativePath: string): boolean { + if (ignoredDirectoryPaths.has(relativePath)) return true; + return relativePath.split("/").includes("node_modules"); +} + +function updateRecord( + hash: ReturnType, + kind: string, + relativePath: string, + payload = "", +): void { + hash.update(kind); + hash.update("\0"); + hash.update(relativePath); + hash.update("\0"); + hash.update(payload); + hash.update("\0"); +} + +async function hashEntry( + hash: ReturnType, + root: string, + relativePath: string, +): Promise { + const normalizedPath = normalizedRelativePath(relativePath); + if (shouldIgnore(normalizedPath)) return; + + const absolutePath = path.resolve(root, relativePath); + const relativeFromRoot = path.relative(root, absolutePath); + if ( + relativeFromRoot.startsWith(`..${path.sep}`) || + relativeFromRoot === ".." || + path.isAbsolute(relativeFromRoot) + ) { + throw new Error( + `Daytona image input escapes repository root: ${relativePath}`, + ); + } + + const stats = await lstat(absolutePath); + if (stats.isDirectory()) { + updateRecord(hash, "directory", normalizedPath); + const entries = await readdir(absolutePath, { withFileTypes: true }); + entries.sort((left, right) => compareNames(left.name, right.name)); + for (const entry of entries) { + await hashEntry(hash, root, path.join(relativePath, entry.name)); + } + return; + } + if (stats.isSymbolicLink()) { + updateRecord(hash, "symlink", normalizedPath, await readlink(absolutePath)); + return; + } + if (stats.isFile()) { + const executable = + (stats.mode & 0o111) === 0 ? "non-executable" : "executable"; + updateRecord(hash, "file", normalizedPath, executable); + hash.update(await readFile(absolutePath)); + hash.update("\0"); + return; + } + throw new Error(`Unsupported Daytona image input type: ${relativePath}`); +} + +export async function computeDaytonaImageContentId( + options: DaytonaImageContentOptions = {}, +): Promise { + const root = path.resolve(options.repositoryRoot ?? repositoryRoot); + const inputPaths = [ + ...(options.inputPaths ?? DAYTONA_IMAGE_INPUT_PATHS), + ].sort(compareNames); + const hash = createHash("sha256"); + updateRecord( + hash, + "contract", + DAYTONA_IMAGE_CONTENT_SCHEMA, + options.platform ?? DAYTONA_IMAGE_PLATFORM, + ); + for (const inputPath of inputPaths) { + await hashEntry(hash, root, inputPath); + } + return hash.digest("hex"); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + computeDaytonaImageContentId() + .then((contentId) => process.stdout.write(`${contentId}\n`)) + .catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); +} + +export const DAYTONA_IMAGE_CONTENT_SCRIPT = fileURLToPath(import.meta.url); diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index caeca3f554b..3c6193b907a 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -1,16 +1,22 @@ -import { readFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { + computeDaytonaImageContentId, + DAYTONA_IMAGE_INPUT_PATHS, +} from "./daytona-image-content.js"; const repositoryRoot = path.resolve(import.meta.dirname, "../.."); describe("runner E2E Daytona image contract", () => { it("builds runnerd and the provider pack and verifies every required transport", async () => { - const [dockerfile, workflow] = await Promise.all([ + const [dockerfile, dockerignore, workflow] = await Promise.all([ readFile( path.join(repositoryRoot, "docker/daytona-runner/Dockerfile"), "utf8", ), + readFile(path.join(repositoryRoot, ".dockerignore"), "utf8"), readFile( path.join( repositoryRoot, @@ -33,9 +39,23 @@ describe("runner E2E Daytona image contract", () => { for (const transport of ["dial_ws_loopback", "dial_wss", "listen_ws"]) { expect(dockerfile).toContain(transport); } - expect(dockerfile).toContain('metadata="$(paperclip-runnerd --build-metadata)"'); + expect(dockerfile).toContain( + 'metadata="$(paperclip-runnerd --build-metadata)"', + ); expect(dockerfile).toContain("provider-pack.json"); + expect(dockerfile).toContain("io.paperclip.runner.content-id"); + expect(dockerfile).toContain("org.opencontainers.image.revision"); + expect(dockerignore).toContain("**/node_modules"); + expect(dockerignore).toContain("packages/paperclip-runner/dist"); + expect(dockerignore).toContain("packages/paperclip-runner/runner/target"); expect(workflow).toContain("--platform linux/amd64"); + expect(workflow).toContain( + "e2e-content-${{ needs.catalog.outputs.daytona_image_content_id }}", + ); + expect(workflow).toContain( + '--build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${IMAGE_CONTENT_ID}"', + ); + expect(workflow).not.toContain("e2e-git-${{ github.sha }}"); expect(workflow).toContain("cosign sign --yes"); expect(workflow).toContain("docker image inspect"); expect(workflow).toContain('.Config.User == "daytona"'); @@ -46,6 +66,73 @@ describe("runner E2E Daytona image contract", () => { expect(workflow).toContain( "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack", ); + expect(workflow).toContain( + "PAPERCLIP_RUNNER_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}", + ); expect(workflow).toContain("anonymous_config"); }); + + it("hashes the audited image dependency closure rather than the repository revision", async () => { + for (const requiredPath of [ + ".dockerignore", + "docker/daytona-runner/Dockerfile", + "pnpm-lock.yaml", + "patches", + "packages/paperclip-eval-kernel", + "packages/paperclip-runner", + ]) { + expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(requiredPath); + } + + const contentId = await computeDaytonaImageContentId(); + expect(contentId).toMatch(/^[0-9a-f]{64}$/); + }); + + it("changes only when a selected image input or target platform changes", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "paperclip-daytona-image-id-"), + ); + try { + await mkdir(path.join(root, "image-input")); + await writeFile( + path.join(root, "image-input", "runner.ts"), + "version one\n", + ); + const baseline = await computeDaytonaImageContentId({ + repositoryRoot: root, + inputPaths: ["image-input"], + }); + + await writeFile( + path.join(root, "unrelated.txt"), + "does not enter the image\n", + ); + expect( + await computeDaytonaImageContentId({ + repositoryRoot: root, + inputPaths: ["image-input"], + }), + ).toBe(baseline); + + await writeFile( + path.join(root, "image-input", "runner.ts"), + "version two\n", + ); + expect( + await computeDaytonaImageContentId({ + repositoryRoot: root, + inputPaths: ["image-input"], + }), + ).not.toBe(baseline); + expect( + await computeDaytonaImageContentId({ + repositoryRoot: root, + inputPaths: ["image-input"], + platform: "linux/arm64", + }), + ).not.toBe(baseline); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); });