Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 <image>:<tag>` 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}"

Expand Down Expand Up @@ -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/``.
Expand Down
87 changes: 87 additions & 0 deletions cdk/test/constructs/agent-image-pins.test.ts
Original file line number Diff line number Diff line change
@@ -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=<registry ref>`. */
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=<stage>` 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 <stage> 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');
});
});
Loading