Skip to content

Commit 81ec73d

Browse files
committed
fix: inspect multi-arch index children in deploy image conformance check
1 parent d791040 commit 81ec73d

2 files changed

Lines changed: 226 additions & 48 deletions

File tree

apps/webapp/app/v3/services/verifyDeploymentImage.server.ts

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,76 @@ export type ImageLookupResult = "found" | "missing" | "unknown" | "nonconformant
2525
// can emit it when reusing zstd layers from a prior build or the registry cache.
2626
const UNPULLABLE_LAYER_MEDIA_TYPE = "application/vnd.docker.image.rootfs.diff.tar.zstd";
2727

28-
// Lenient: we only need layer media types. A manifest list / OCI index has no top-level
29-
// layers[], so it just parses to `layers: undefined` and is treated as conformant.
30-
const ImageManifestSchema = z.object({
28+
// Nested indexes are exotic (index -> per-platform image manifests is depth 1); cap the
29+
// walk so a pathological manifest can't fan out unbounded.
30+
const MAX_MANIFEST_DEPTH = 3;
31+
32+
// Lenient: we only read what we need. An image manifest carries layers[]; a manifest list
33+
// / OCI index carries manifests[] (per-platform child pointers). Both optional so either
34+
// shape parses cleanly.
35+
const ManifestSchema = z.object({
3136
layers: z.array(z.object({ mediaType: z.string().optional() })).optional(),
37+
manifests: z.array(z.object({ digest: z.string() })).optional(),
3238
});
3339

34-
function manifestHasUnpullableLayers(imageManifest: string | undefined): boolean {
35-
if (!imageManifest) {
36-
return false;
40+
// Inspect one raw manifest: does it directly carry an unpullable layer, and (if it's an
41+
// index) which child manifests should be walked. Fails open to empty on anything we can't
42+
// parse, so an unreadable manifest never blocks a deploy.
43+
export function inspectManifest(rawManifest: string | undefined): {
44+
hasUnpullableLayer: boolean;
45+
childDigests: string[];
46+
} {
47+
const empty = { hasUnpullableLayer: false, childDigests: [] };
48+
49+
if (!rawManifest) {
50+
return empty;
3751
}
3852

3953
let json: unknown;
4054
try {
41-
json = JSON.parse(imageManifest);
55+
json = JSON.parse(rawManifest);
4256
} catch {
43-
return false; // fail open on a manifest we can't read
57+
return empty;
4458
}
4559

46-
const parsed = ImageManifestSchema.safeParse(json);
60+
const parsed = ManifestSchema.safeParse(json);
4761
if (!parsed.success) {
48-
return false; // fail open
62+
return empty;
63+
}
64+
65+
return {
66+
hasUnpullableLayer:
67+
parsed.data.layers?.some((layer) => layer.mediaType === UNPULLABLE_LAYER_MEDIA_TYPE) ?? false,
68+
childDigests: parsed.data.manifests?.map((child) => child.digest) ?? [],
69+
};
70+
}
71+
72+
// Walk a manifest and, for a multi-arch index, its child manifests (fetched by digest).
73+
// Returns true if any layer anywhere in the tree uses the unpullable media type. A child
74+
// that can't be fetched (undefined) is treated as conformant - fail open.
75+
export async function treeHasUnpullableLayer(
76+
rawManifest: string | undefined,
77+
fetchManifest: (digest: string) => Promise<string | undefined>,
78+
depth = 0
79+
): Promise<boolean> {
80+
const { hasUnpullableLayer, childDigests } = inspectManifest(rawManifest);
81+
82+
if (hasUnpullableLayer) {
83+
return true;
84+
}
85+
86+
if (depth >= MAX_MANIFEST_DEPTH) {
87+
return false;
88+
}
89+
90+
for (const digest of childDigests) {
91+
const childManifest = await fetchManifest(digest);
92+
if (await treeHasUnpullableLayer(childManifest, fetchManifest, depth + 1)) {
93+
return true;
94+
}
4995
}
5096

51-
return parsed.data.layers?.some((layer) => layer.mediaType === UNPULLABLE_LAYER_MEDIA_TYPE) ?? false;
97+
return false;
5298
}
5399

54100
/**
@@ -88,13 +134,7 @@ export function parseEcrImageReference(
88134
export function interpretBatchGetImageResponse(
89135
response: BatchGetImageCommandOutput
90136
): ImageLookupResult {
91-
const image = response.images?.[0];
92-
if (image) {
93-
// Present but built with a runtime-incompatible layer media type - promoting it
94-
// would 100%-fail every run at pull time, so treat it as a distinct failure.
95-
if (manifestHasUnpullableLayers(image.imageManifest)) {
96-
return "nonconformant";
97-
}
137+
if (response.images && response.images.length > 0) {
98138
return "found";
99139
}
100140

@@ -125,8 +165,10 @@ const sendBatchGetImage: BatchGetImageSender = async ({
125165
imageIds,
126166
}) => {
127167
const ecr = await createEcrClient({ region, assumeRole });
128-
// No acceptedMediaTypes: only the single-manifest types are valid enum values, and
129-
// we only care whether the image exists, not its manifest format.
168+
// Intentionally no acceptedMediaTypes: ECR returns the manifest as stored - which we
169+
// rely on for the layer-media-type check - and omitting it avoids a multi-arch index
170+
// being reported as a failure (i.e. misread as missing). BatchGetImage populates
171+
// imageManifest by default when the image exists; the check fails open if it's absent.
130172
return ecr.send(new BatchGetImageCommand({ repositoryName, registryId, imageIds }));
131173
};
132174

@@ -224,12 +266,44 @@ export async function ecrImageExists(
224266

225267
const result = interpretBatchGetImageResponse(response);
226268

227-
if (result === "nonconformant") {
269+
if (result !== "found") {
270+
return result;
271+
}
272+
273+
// Image exists - now confirm the runtime can actually pull it. Follow index children by
274+
// digest so multi-arch deploys are covered, not just single image manifests.
275+
const fetchManifest = async (digest: string): Promise<string | undefined> => {
276+
const [fetchError, childResponse] = await tryCatch(
277+
_send({
278+
region,
279+
assumeRole,
280+
registryId: accountId,
281+
repositoryName: parsed.repositoryName,
282+
imageIds: [{ imageDigest: digest }],
283+
})
284+
);
285+
286+
if (fetchError) {
287+
logger.warn("Could not fetch child manifest for conformance check", {
288+
imageReference,
289+
digest,
290+
error: fetchError.message,
291+
});
292+
return undefined; // fail open on this child
293+
}
294+
295+
return childResponse.images?.[0]?.imageManifest;
296+
};
297+
298+
const topManifest = response.images?.[0]?.imageManifest;
299+
300+
if (await treeHasUnpullableLayer(topManifest, fetchManifest)) {
228301
logger.error("Deployment image has a runtime-incompatible layer media type", {
229302
imageReference,
230303
repositoryName: parsed.repositoryName,
231304
});
305+
return "nonconformant";
232306
}
233307

234-
return result;
308+
return "found";
235309
}

apps/webapp/test/verifyDeploymentImage.test.ts

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,37 @@ import { RepositoryNotFoundException } from "@aws-sdk/client-ecr";
22
import { describe, expect, it } from "vitest";
33
import {
44
ecrImageExists,
5+
inspectManifest,
56
interpretBatchGetImageResponse,
67
parseEcrImageReference,
8+
treeHasUnpullableLayer,
79
} from "~/v3/services/verifyDeploymentImage.server";
810
import { type RegistryConfig } from "~/v3/registryConfig.server";
911

1012
const ECR_HOST = "123456789012.dkr.ecr.us-east-1.amazonaws.com";
1113
const ecrConfig: RegistryConfig = { host: ECR_HOST, namespace: "deployments-test" };
1214

15+
const DIGEST_A = `sha256:${"a".repeat(64)}`;
16+
const DIGEST_B = `sha256:${"b".repeat(64)}`;
17+
const ZSTD_DOCKER = "application/vnd.docker.image.rootfs.diff.tar.zstd";
18+
19+
const imageManifest = (layerMediaTypes: string[]) =>
20+
JSON.stringify({
21+
schemaVersion: 2,
22+
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
23+
layers: layerMediaTypes.map((mediaType) => ({ mediaType, digest: DIGEST_A })),
24+
});
25+
26+
const indexManifest = (childDigests: string[]) =>
27+
JSON.stringify({
28+
schemaVersion: 2,
29+
mediaType: "application/vnd.oci.image.index.v1+json",
30+
manifests: childDigests.map((digest) => ({
31+
digest,
32+
mediaType: "application/vnd.oci.image.manifest.v1+json",
33+
})),
34+
});
35+
1336
describe("parseEcrImageReference", () => {
1437
it("splits repository and tag for a ref under the configured host", () => {
1538
const ref = `${ECR_HOST}/deployments-test/proj_abc:20240101.1.prod.a1b2c3d4`;
@@ -59,41 +82,72 @@ describe("interpretBatchGetImageResponse", () => {
5982
);
6083
expect(interpretBatchGetImageResponse({} as any)).toBe("unknown");
6184
});
85+
});
6286

63-
const manifestWith = (layerMediaTypes: string[]) =>
64-
JSON.stringify({
65-
schemaVersion: 2,
66-
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
67-
layers: layerMediaTypes.map((mediaType) => ({ mediaType, digest: `sha256:${"a".repeat(64)}` })),
68-
});
87+
describe("inspectManifest", () => {
88+
it("flags an unpullable zstd layer in an image manifest", () => {
89+
const result = inspectManifest(
90+
imageManifest(["application/vnd.docker.image.rootfs.diff.tar.gzip", ZSTD_DOCKER])
91+
);
92+
expect(result.hasUnpullableLayer).toBe(true);
93+
expect(result.childDigests).toEqual([]);
94+
});
6995

70-
it("returns nonconformant when any layer is a zstd layer in a Docker manifest", () => {
71-
const imageManifest = manifestWith([
72-
"application/vnd.docker.image.rootfs.diff.tar.gzip",
73-
"application/vnd.docker.image.rootfs.diff.tar.zstd",
74-
]);
75-
expect(interpretBatchGetImageResponse({ images: [{ imageManifest }] } as any)).toBe(
76-
"nonconformant"
96+
it("passes OCI zstd and gzip layers (runtime-supported media types)", () => {
97+
const result = inspectManifest(
98+
imageManifest([
99+
"application/vnd.oci.image.layer.v1.tar+gzip",
100+
"application/vnd.oci.image.layer.v1.tar+zstd",
101+
])
77102
);
103+
expect(result.hasUnpullableLayer).toBe(false);
78104
});
79105

80-
it("returns found for OCI zstd layers (the runtime-supported media type)", () => {
81-
const imageManifest = manifestWith([
82-
"application/vnd.oci.image.layer.v1.tar+gzip",
83-
"application/vnd.oci.image.layer.v1.tar+zstd",
84-
]);
85-
expect(interpretBatchGetImageResponse({ images: [{ imageManifest }] } as any)).toBe("found");
106+
it("returns child digests for an index and does not flag it directly", () => {
107+
const result = inspectManifest(indexManifest([DIGEST_A, DIGEST_B]));
108+
expect(result.hasUnpullableLayer).toBe(false);
109+
expect(result.childDigests).toEqual([DIGEST_A, DIGEST_B]);
86110
});
87111

88-
it("returns found (does not block) when the manifest is absent, unparseable, or an index", () => {
89-
expect(interpretBatchGetImageResponse({ images: [{}] } as any)).toBe("found");
90-
expect(interpretBatchGetImageResponse({ images: [{ imageManifest: "not json" }] } as any)).toBe(
91-
"found"
112+
it("fails open (empty) when the manifest is absent or unparseable", () => {
113+
expect(inspectManifest(undefined)).toEqual({ hasUnpullableLayer: false, childDigests: [] });
114+
expect(inspectManifest("not json")).toEqual({ hasUnpullableLayer: false, childDigests: [] });
115+
});
116+
});
117+
118+
describe("treeHasUnpullableLayer", () => {
119+
const neverFetch = async () => undefined;
120+
121+
it("detects an unpullable layer in a flat image manifest", async () => {
122+
expect(await treeHasUnpullableLayer(imageManifest([ZSTD_DOCKER]), neverFetch)).toBe(true);
123+
});
124+
125+
it("follows an index and detects an unpullable layer in a child", async () => {
126+
const children: Record<string, string> = {
127+
[DIGEST_A]: imageManifest(["application/vnd.oci.image.layer.v1.tar+gzip"]),
128+
[DIGEST_B]: imageManifest([ZSTD_DOCKER]),
129+
};
130+
const result = await treeHasUnpullableLayer(
131+
indexManifest([DIGEST_A, DIGEST_B]),
132+
async (digest) => children[digest]
92133
);
93-
const index = JSON.stringify({ schemaVersion: 2, manifests: [{ digest: "sha256:x" }] });
94-
expect(interpretBatchGetImageResponse({ images: [{ imageManifest: index }] } as any)).toBe(
95-
"found"
134+
expect(result).toBe(true);
135+
});
136+
137+
it("returns false for an index whose children are all conformant", async () => {
138+
const children: Record<string, string> = {
139+
[DIGEST_A]: imageManifest(["application/vnd.oci.image.layer.v1.tar+zstd"]),
140+
[DIGEST_B]: imageManifest(["application/vnd.docker.image.rootfs.diff.tar.gzip"]),
141+
};
142+
const result = await treeHasUnpullableLayer(
143+
indexManifest([DIGEST_A, DIGEST_B]),
144+
async (digest) => children[digest]
96145
);
146+
expect(result).toBe(false);
147+
});
148+
149+
it("fails open when a child manifest can't be fetched", async () => {
150+
expect(await treeHasUnpullableLayer(indexManifest([DIGEST_A]), neverFetch)).toBe(false);
97151
});
98152
});
99153

@@ -210,4 +264,54 @@ describe("ecrImageExists", () => {
210264
);
211265
expect(seen.imageIds).toEqual([{ imageTag: "v1.prod.a1b2c3d4" }]);
212266
});
267+
268+
// Resolve a manifest by tag or digest against a fixture map, mimicking BatchGetImage.
269+
const sendFrom =
270+
(byRef: Record<string, string>) =>
271+
async (input: any): Promise<any> => {
272+
const id = input.imageIds[0];
273+
const manifest = byRef[id.imageDigest ?? id.imageTag];
274+
return manifest ? { images: [{ imageManifest: manifest }] } : { images: [{}] };
275+
};
276+
277+
it("returns nonconformant for a single-arch image with an unpullable zstd layer", async () => {
278+
const result = await ecrImageExists(
279+
{
280+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
281+
registryConfig: ecrConfig,
282+
},
283+
sendFrom({ "v1.prod.a1b2c3d4": imageManifest([ZSTD_DOCKER]) })
284+
);
285+
expect(result).toBe("nonconformant");
286+
});
287+
288+
it("returns nonconformant when a multi-arch index has an unpullable child", async () => {
289+
const result = await ecrImageExists(
290+
{
291+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
292+
registryConfig: ecrConfig,
293+
},
294+
sendFrom({
295+
"v1.prod.a1b2c3d4": indexManifest([DIGEST_A, DIGEST_B]),
296+
[DIGEST_A]: imageManifest(["application/vnd.oci.image.layer.v1.tar+gzip"]),
297+
[DIGEST_B]: imageManifest([ZSTD_DOCKER]),
298+
})
299+
);
300+
expect(result).toBe("nonconformant");
301+
});
302+
303+
it("returns found when a multi-arch index's children are all conformant", async () => {
304+
const result = await ecrImageExists(
305+
{
306+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
307+
registryConfig: ecrConfig,
308+
},
309+
sendFrom({
310+
"v1.prod.a1b2c3d4": indexManifest([DIGEST_A, DIGEST_B]),
311+
[DIGEST_A]: imageManifest(["application/vnd.oci.image.layer.v1.tar+zstd"]),
312+
[DIGEST_B]: imageManifest(["application/vnd.docker.image.rootfs.diff.tar.gzip"]),
313+
})
314+
);
315+
expect(result).toBe("found");
316+
});
213317
});

0 commit comments

Comments
 (0)