Skip to content

Commit 52e768b

Browse files
authored
Merge branch 'main' into posthog-proxy-app-analytics
2 parents b2f9b66 + 94b30fc commit 52e768b

6 files changed

Lines changed: 286 additions & 4 deletions

File tree

.server-changes/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,16 @@ The body text (below the frontmatter) is a one-line description of the change. K
4242

4343
These entries are public-facing - they ship verbatim in user-visible release notes. A few rules to keep them clean:
4444

45+
- **Write for the user, not the reviewer.** Lead with what the user notices or has to do. If a reader who doesn't know the codebase can't tell what changed for them, rewrite it.
4546
- **One sentence is usually enough.** The body is the bullet in the changelog. If you need a paragraph, you're probably describing the implementation rather than the change.
4647
- **Describe behavior, not implementation.** Skip internal scopes, middleware names, library specifics, framework internals. Users care about what's different for them, not how it's wired.
4748
- **Never name internal tools or infra.** Observability stacks, internal services, infra components, monitoring backends, CI surfaces, AWS specifics - none of these belong in user-facing notes.
4849

50+
Before / after:
51+
52+
-_"The image verification step now parses the manifest's layer media types and returns a new result the finalizer rejects."_ (describes the wiring; a user can't act on it)
53+
-_"Deploying with an outdated CLI could produce an image that fails to start on every run. These deploys are now stopped before going live, with a message asking you to upgrade the CLI and re-deploy."_ (what the user sees and does)
54+
4955
## Lifecycle
5056

5157
1. Engineer adds a `.server-changes/` file in their PR
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deploying with an outdated CLI could produce an image that fails to start on every run. These deploys are now stopped before going live, with a message asking you to upgrade the CLI and re-deploy.

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ pnpm run changeset:add
104104

105105
When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
106106

107+
**Write the description for users, not maintainers.** Both changesets and `.server-changes/` notes ship verbatim in user-visible release notes. Lead with what changed *for the user* - one plain sentence describing behavior, not implementation, and never naming internal tools or infra. The full writing guidance in `.server-changes/README.md` applies to changesets too.
108+
107109
## Dependency Pinning
108110

109111
Zod is pinned to a single version across the entire monorepo (currently `3.25.76`). When adding zod to a new or existing package, use the **exact same version** as the rest of the repo - never a different version or a range. Mismatched zod versions cause runtime type incompatibilities (e.g., schemas from one package can't be used as body validators in another).

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ export class FinalizeDeploymentV2Service extends BaseService {
186186
);
187187
}
188188

189+
if (result === "nonconformant") {
190+
throw new ServiceValidationError(
191+
"Deployment image is not runnable: it contains zstd-compressed layers inside a Docker (v2s2) manifest, which the container runtime cannot pull. This typically comes from an outdated CLI version. Please upgrade to the latest trigger.dev CLI and re-deploy."
192+
);
193+
}
194+
189195
// Fail closed: if we can't confirm the image is present, don't promote a version
190196
// that might not start. Set DEPLOY_IMAGE_VERIFICATION_ENABLED=0 for out-of-band pushes.
191197
if (result === "unknown") {

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

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@aws-sdk/client-ecr";
66
import { tryCatch } from "@trigger.dev/core";
77
import pRetry, { AbortError } from "p-retry";
8+
import { z } from "zod";
89
import { logger } from "~/services/logger.server";
910
import {
1011
type AssumeRoleConfig,
@@ -16,7 +17,85 @@ import { type RegistryConfig } from "../registryConfig.server";
1617

1718
const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
1819

19-
export type ImageLookupResult = "found" | "missing" | "unknown";
20+
export type ImageLookupResult = "found" | "missing" | "unknown" | "nonconformant";
21+
22+
// A zstd layer carried in a Docker (v2s2) manifest rather than an OCI manifest is
23+
// unpullable by cri-o/containerd/podman. OCI zstd (...tar+zstd) is fine - only this
24+
// Docker media type is rejected. An outdated CLI that predates OCI-media-type output
25+
// can emit it when reusing zstd layers from a prior build or the registry cache.
26+
const UNPULLABLE_LAYER_MEDIA_TYPE = "application/vnd.docker.image.rootfs.diff.tar.zstd";
27+
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({
36+
layers: z.array(z.object({ mediaType: z.string().optional() })).optional(),
37+
manifests: z.array(z.object({ digest: z.string() })).optional(),
38+
});
39+
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;
51+
}
52+
53+
let json: unknown;
54+
try {
55+
json = JSON.parse(rawManifest);
56+
} catch {
57+
return empty;
58+
}
59+
60+
const parsed = ManifestSchema.safeParse(json);
61+
if (!parsed.success) {
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+
}
95+
}
96+
97+
return false;
98+
}
2099

21100
/**
22101
* Split a stored ECR image reference into repository + tag.
@@ -86,8 +165,10 @@ const sendBatchGetImage: BatchGetImageSender = async ({
86165
imageIds,
87166
}) => {
88167
const ecr = await createEcrClient({ region, assumeRole });
89-
// No acceptedMediaTypes: only the single-manifest types are valid enum values, and
90-
// 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.
91172
return ecr.send(new BatchGetImageCommand({ repositoryName, registryId, imageIds }));
92173
};
93174

@@ -183,5 +264,46 @@ export async function ecrImageExists(
183264
return "unknown";
184265
}
185266

186-
return interpretBatchGetImageResponse(response);
267+
const result = interpretBatchGetImageResponse(response);
268+
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)) {
301+
logger.error("Deployment image has a runtime-incompatible layer media type", {
302+
imageReference,
303+
repositoryName: parsed.repositoryName,
304+
});
305+
return "nonconformant";
306+
}
307+
308+
return "found";
187309
}

apps/webapp/test/verifyDeploymentImage.test.ts

Lines changed: 140 additions & 0 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`;
@@ -61,6 +84,73 @@ describe("interpretBatchGetImageResponse", () => {
6184
});
6285
});
6386

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+
});
95+
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+
])
102+
);
103+
expect(result.hasUnpullableLayer).toBe(false);
104+
});
105+
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]);
110+
});
111+
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]
133+
);
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]
145+
);
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);
151+
});
152+
});
153+
64154
describe("ecrImageExists", () => {
65155
it("returns unknown for a non-ECR registry without calling the registry", async () => {
66156
let called = false;
@@ -174,4 +264,54 @@ describe("ecrImageExists", () => {
174264
);
175265
expect(seen.imageIds).toEqual([{ imageTag: "v1.prod.a1b2c3d4" }]);
176266
});
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+
});
177317
});

0 commit comments

Comments
 (0)