diff --git a/agent/Dockerfile b/agent/Dockerfile index 80d2cee1..8318c26f 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -1,10 +1,28 @@ ARG TARGETPLATFORM=linux/arm64 ARG GH_VERSION=2.93.0 -FROM --platform=$TARGETPLATFORM jdxcode/mise:latest AS mise +# Every external image is pinned by DIGEST, not by tag. +# +# A tag is a moving pointer. When one moves, the layer that copies from it changes, +# and so does every layer built after it — here that is the ~400 MB apt install, the +# node install, and the dependency sync. The image is then rebuilt and re-pushed in +# full for a commit that touched only, say, a doc. That is slow everywhere and +# painful on a modest uplink, and it also means two builds of the same commit can +# produce different images, which is the more serious half of the problem. +# +# `mise:latest` was the worst case: a tag with no version at all, republished +# whenever upstream feels like it. Pinning is what makes a rebuild depend on OUR +# changes rather than on upstream's release schedule. +# +# Each digest is a multi-platform index, so `--platform=$TARGETPLATFORM` still +# selects the right architecture — verified to include linux/arm64. +# +# To bump: `docker buildx imagetools inspect :` and copy the Digest. +# Keep the tag alongside the digest; it documents what the digest is meant to be. +FROM --platform=$TARGETPLATFORM jdxcode/mise:latest@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe AS mise # Build gh with a patched Go toolchain; upstream packages can lag Go CVE fixes. -FROM --platform=$TARGETPLATFORM golang:1.26.4-bookworm AS gh-builder +FROM --platform=$TARGETPLATFORM golang:1.26.4-bookworm@sha256:b305420a68d0f229d91eb3b3ed9e519fcf2cf5461da4bef997bf927e8c0bfd2b AS gh-builder ARG GH_VERSION RUN GOPROXY=direct GOBIN=/out go install "github.com/cli/cli/v2/cmd/gh@v${GH_VERSION}" @@ -75,8 +93,10 @@ RUN npm install -g npm@latest && \ node "${CLAUDE_NPM_ROOT}/install.cjs" && \ claude --version -# Install uv (fast Python package manager) — pinned for reproducibility -COPY --from=ghcr.io/astral-sh/uv:0.11.14 /uv /usr/local/bin/uv +# Install uv (fast Python package manager) — pinned by digest, not just version: +# a published tag can be re-pushed, and this COPY sits above the dependency sync, +# so a silent move here invalidates it. See the digest note at the top of the file. +COPY --from=ghcr.io/astral-sh/uv:0.11.14@sha256:1025398289b62de8269e70c45b91ffa37c373f38118d7da036fb8bb8efc85d97 /uv /usr/local/bin/uv # Install Python dependencies via uv. Build context is repo root (set in # ``cdk/src/stacks/agent.ts``) so source paths are prefixed with ``agent/``. diff --git a/cdk/test/constructs/agent-image-pins.test.ts b/cdk/test/constructs/agent-image-pins.test.ts new file mode 100644 index 00000000..c0e9fe9d --- /dev/null +++ b/cdk/test/constructs/agent-image-pins.test.ts @@ -0,0 +1,87 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const dockerfile = readFileSync( + join(__dirname, '..', '..', '..', 'agent', 'Dockerfile'), 'utf-8', +); + +/** Every line that pulls an image from a registry: `FROM …` and `COPY --from=`. */ +function externalImageRefs(): string[] { + const refs: string[] = []; + for (const raw of dockerfile.split('\n')) { + const line = raw.trim(); + if (line.startsWith('#')) continue; + const from = /^FROM\s+(?:--platform=\S+\s+)?(\S+)/.exec(line); + // A `COPY --from=` names an earlier build stage, not a registry: no + // slash, no colon, no digest. Only registry refs are pins we control. + const copy = /^COPY\s+--from=(\S*[/:@]\S*)/.exec(line); + const ref = from?.[1] ?? copy?.[1]; + // `FROM AS …` also re-uses a local stage name; same exclusion. + if (ref && /[/:@]/.test(ref)) refs.push(ref); + } + return refs; +} + +describe('agent image base pins', () => { + test('every external image is pinned by DIGEST, not by tag', () => { + // A tag is a moving pointer. When one moves, the layer that copies from it + // changes and so does every layer built after it — here that is a ~400 MB apt + // install, a node install and a dependency sync. The image is then rebuilt and + // re-pushed in full for a commit that touched only a doc, which is slow + // everywhere and painful on a modest uplink. + // + // The more serious half: two builds of the same commit can produce different + // images. `mise:latest` was the worst case — a tag with no version at all, + // republished at upstream's convenience. + const unpinned = externalImageRefs().filter((r) => !r.includes('@sha256:')); + expect(unpinned).toEqual([]); + }); + + test('a pinned ref keeps its human-readable tag alongside the digest', () => { + // `image@sha256:…` alone is valid but opaque. Keeping `image:tag@sha256:…` + // documents what the digest is meant to BE, so a reviewer can tell an + // intentional version bump from a digest refresh of the same version. + for (const ref of externalImageRefs()) { + expect(ref).toMatch(/^[^@]+:[^@:]+@sha256:[a-f0-9]{64}$/); + } + }); + + test('finds the refs it claims to check, so passing cannot mean "found nothing"', () => { + // Guards the parser: a regex that silently matched nothing would make both + // assertions above vacuously true. + const refs = externalImageRefs(); + expect(refs.length).toBeGreaterThanOrEqual(4); + expect(refs.some((r) => r.includes('mise'))).toBe(true); + expect(refs.some((r) => r.includes('golang'))).toBe(true); + expect(refs.some((r) => r.includes('python'))).toBe(true); + expect(refs.some((r) => r.includes('astral-sh/uv'))).toBe(true); + }); + + test('build-stage references are not mistaken for registry images', () => { + // `COPY --from=mise` and `COPY --from=gh-builder` name local stages, which + // cannot and must not be digest-pinned. If the parser counted those, the pin + // assertion would fail for an unfixable reason. + const refs = externalImageRefs(); + expect(refs).not.toContain('mise'); + expect(refs).not.toContain('gh-builder'); + }); +});