Skip to content

Commit b48e0d3

Browse files
committed
fix(webapp): verify deployment image exists before finalizing
1 parent 9f01e31 commit b48e0d3

5 files changed

Lines changed: 360 additions & 0 deletions

File tree

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+
Verify a deployment's image exists in the registry before marking it deployed, so a deploy whose image wasn't pushed fails instead of silently breaking runs (can be turned off via `DEPLOY_IMAGE_VERIFICATION_ENABLED=0` for setups that push images out of band)

apps/webapp/app/env.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,9 @@ const EnvironmentSchema = z
553553
// log-only mode before enforcement.
554554
DEPRECATE_V3_CLI_DEPLOYS_ENABLED: z.string().default("0"),
555555

556+
// Verify the deploy image exists before promoting. Disable for out-of-band/air-gapped push. ECR only.
557+
DEPLOY_IMAGE_VERIFICATION_ENABLED: BoolEnv.default(true),
558+
556559
OBJECT_STORE_BASE_URL: z.string().optional(),
557560
OBJECT_STORE_BUCKET: z.string().optional(),
558561
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getEcrAuthToken, isEcrRegistry } from "../getDeploymentImageRef.server"
1616
import { tryCatch } from "@trigger.dev/core";
1717
import { getRegistryConfig, type RegistryConfig } from "../registryConfig.server";
1818
import { ComputeTemplateCreationService } from "./computeTemplateCreation.server";
19+
import { ecrImageExists } from "./verifyDeploymentImage.server";
1920

2021
export class FinalizeDeploymentV2Service extends BaseService {
2122
public async call(
@@ -75,6 +76,11 @@ export class FinalizeDeploymentV2Service extends BaseService {
7576
});
7677
}
7778

79+
// The CLI claims the image is already in the registry (local build, or a
80+
// self-hosted setup). Verify before promoting so we never mark a
81+
// deployment DEPLOYED when nothing was actually pushed.
82+
await this.#assertImagePullable(deployment, body);
83+
7884
await this.#createTemplateIfNeeded(deployment, id, authenticatedEnv, writer);
7985
return finalizeService.call(authenticatedEnv, id, body);
8086
}
@@ -142,10 +148,44 @@ export class FinalizeDeploymentV2Service extends BaseService {
142148
pushedImage: pushResult.image,
143149
});
144150

151+
// Belt and suspenders: confirm the push actually landed before promoting.
152+
await this.#assertImagePullable(deployment, body);
153+
145154
await this.#createTemplateIfNeeded(deployment, id, authenticatedEnv, writer);
146155
return finalizeService.call(authenticatedEnv, id, body);
147156
}
148157

158+
async #assertImagePullable(
159+
deployment: { imageReference: string | null; type: string | null },
160+
body: FinalizeDeploymentRequestBody
161+
): Promise<void> {
162+
if (!env.DEPLOY_IMAGE_VERIFICATION_ENABLED) {
163+
return;
164+
}
165+
166+
if (!deployment.imageReference) {
167+
return;
168+
}
169+
170+
const registryConfig = getRegistryConfig(deployment.type === "MANAGED");
171+
172+
const result = await ecrImageExists({
173+
imageReference: deployment.imageReference,
174+
imageDigest: body.imageDigest,
175+
registryConfig,
176+
});
177+
178+
if (result === "missing") {
179+
throw new ServiceValidationError(
180+
"Deployment image was not found in the registry. It may not have been pushed (for example a local build without a push, or a push to a different registry). Aborting the deploy to avoid promoting a version that cannot start."
181+
);
182+
}
183+
184+
// "unknown" (non-ECR registry, unparseable ref, or an API/permission error)
185+
// is logged inside ecrImageExists - proceed, since a verifier failure must
186+
// not become a deploy outage.
187+
}
188+
149189
async #createTemplateIfNeeded(
150190
deployment: { imageReference: string | null; worker: { project: { id: string } } | null },
151191
deploymentFriendlyId: string,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { BatchGetImageCommand, type BatchGetImageCommandOutput } from "@aws-sdk/client-ecr";
2+
import { tryCatch } from "@trigger.dev/core";
3+
import { logger } from "~/services/logger.server";
4+
import {
5+
type AssumeRoleConfig,
6+
createEcrClient,
7+
isEcrRegistry,
8+
parseEcrRegistryDomain,
9+
} from "../getDeploymentImageRef.server";
10+
import { type RegistryConfig } from "../registryConfig.server";
11+
12+
const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
13+
14+
export type ImageLookupResult = "found" | "missing" | "unknown";
15+
16+
/**
17+
* Split a stored ECR image reference into repository + tag.
18+
*
19+
* Trust boundary: the ref is platform-generated, but we still bind the lookup to
20+
* our configured host (region/account come from the env host) and only parse refs
21+
* that sit under it. Returns null otherwise.
22+
*/
23+
export function parseEcrImageReference(
24+
imageReference: string,
25+
registryHost: string
26+
): { repositoryName: string; tag: string } | null {
27+
const prefix = `${registryHost}/`;
28+
if (!imageReference.startsWith(prefix)) {
29+
return null;
30+
}
31+
32+
// namespace/projectRef:tag, optionally @sha256:... which we drop here
33+
const remainder = imageReference.slice(prefix.length).split("@")[0];
34+
const lastColon = remainder.lastIndexOf(":");
35+
36+
if (lastColon <= 0) {
37+
return null;
38+
}
39+
40+
const repositoryName = remainder.slice(0, lastColon);
41+
const tag = remainder.slice(lastColon + 1);
42+
43+
if (!repositoryName || !tag || tag.includes("/")) {
44+
return null;
45+
}
46+
47+
return { repositoryName, tag };
48+
}
49+
50+
export function interpretBatchGetImageResponse(
51+
response: BatchGetImageCommandOutput
52+
): ImageLookupResult {
53+
if (response.images && response.images.length > 0) {
54+
return "found";
55+
}
56+
57+
if (response.failures?.some((failure) => failure.failureCode === "ImageNotFound")) {
58+
return "missing";
59+
}
60+
61+
// No image and no explicit not-found failure (some other failure code) -
62+
// we can't say it's missing, so don't block the deploy on it.
63+
return "unknown";
64+
}
65+
66+
type BatchGetImageInput = {
67+
region: string;
68+
assumeRole?: AssumeRoleConfig;
69+
registryId?: string;
70+
repositoryName: string;
71+
imageIds: { imageTag?: string; imageDigest?: string }[];
72+
};
73+
74+
type BatchGetImageSender = (input: BatchGetImageInput) => Promise<BatchGetImageCommandOutput>;
75+
76+
const sendBatchGetImage: BatchGetImageSender = async ({
77+
region,
78+
assumeRole,
79+
registryId,
80+
repositoryName,
81+
imageIds,
82+
}) => {
83+
const ecr = await createEcrClient({ region, assumeRole });
84+
return ecr.send(
85+
new BatchGetImageCommand({
86+
repositoryName,
87+
registryId,
88+
imageIds,
89+
// We only care whether the manifest exists, not its contents.
90+
acceptedMediaTypes: [
91+
"application/vnd.docker.distribution.manifest.v2+json",
92+
"application/vnd.oci.image.manifest.v1+json",
93+
"application/vnd.oci.image.index.v1+json",
94+
"application/vnd.docker.distribution.manifest.list.v2+json",
95+
],
96+
})
97+
);
98+
};
99+
100+
/**
101+
* Pre-promotion backstop: check the deployment image actually exists in ECR.
102+
*
103+
* Returns "unknown" for non-ECR registries or any error we can't read as a
104+
* definitive miss - callers treat "unknown" as "proceed", so a verifier failure
105+
* never becomes a deploy outage. `_send` is a test seam.
106+
*/
107+
export async function ecrImageExists(
108+
{
109+
imageReference,
110+
imageDigest,
111+
registryConfig,
112+
}: {
113+
imageReference: string;
114+
imageDigest?: string;
115+
registryConfig: RegistryConfig;
116+
},
117+
_send: BatchGetImageSender = sendBatchGetImage
118+
): Promise<ImageLookupResult> {
119+
if (!isEcrRegistry(registryConfig.host)) {
120+
return "unknown";
121+
}
122+
123+
const parsed = parseEcrImageReference(imageReference, registryConfig.host);
124+
125+
if (!parsed) {
126+
logger.warn("Could not parse deployment image reference for verification", { imageReference });
127+
return "unknown";
128+
}
129+
130+
const { accountId, region } = parseEcrRegistryDomain(registryConfig.host);
131+
132+
// imageDigest is supplied by the CLI request body - validate before trusting it.
133+
// Prefer it when valid (catches a tag that resolves to a different image), else
134+
// fall back to the platform-generated tag.
135+
const validDigest =
136+
imageDigest && SHA256_DIGEST.test(imageDigest.trim()) ? imageDigest.trim() : undefined;
137+
const imageId = validDigest ? { imageDigest: validDigest } : { imageTag: parsed.tag };
138+
139+
const [error, response] = await tryCatch(
140+
_send({
141+
region,
142+
assumeRole: registryConfig.ecrAssumeRoleArn
143+
? {
144+
roleArn: registryConfig.ecrAssumeRoleArn,
145+
externalId: registryConfig.ecrAssumeRoleExternalId,
146+
}
147+
: undefined,
148+
registryId: accountId,
149+
repositoryName: parsed.repositoryName,
150+
imageIds: [imageId],
151+
})
152+
);
153+
154+
if (error) {
155+
logger.error("Failed to verify deployment image in ECR", {
156+
imageReference,
157+
repositoryName: parsed.repositoryName,
158+
error: error.message,
159+
});
160+
return "unknown";
161+
}
162+
163+
return interpretBatchGetImageResponse(response);
164+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
ecrImageExists,
4+
interpretBatchGetImageResponse,
5+
parseEcrImageReference,
6+
} from "~/v3/services/verifyDeploymentImage.server";
7+
import { type RegistryConfig } from "~/v3/registryConfig.server";
8+
9+
const ECR_HOST = "123456789012.dkr.ecr.us-east-1.amazonaws.com";
10+
const ecrConfig: RegistryConfig = { host: ECR_HOST, namespace: "deployments-test" };
11+
12+
describe("parseEcrImageReference", () => {
13+
it("splits repository and tag for a ref under the configured host", () => {
14+
const ref = `${ECR_HOST}/deployments-test/proj_abc:20240101.1.prod.a1b2c3d4`;
15+
expect(parseEcrImageReference(ref, ECR_HOST)).toEqual({
16+
repositoryName: "deployments-test/proj_abc",
17+
tag: "20240101.1.prod.a1b2c3d4",
18+
});
19+
});
20+
21+
it("drops a trailing @sha256 digest", () => {
22+
const ref = `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4@sha256:${"a".repeat(64)}`;
23+
expect(parseEcrImageReference(ref, ECR_HOST)).toEqual({
24+
repositoryName: "deployments-test/proj_abc",
25+
tag: "v1.prod.a1b2c3d4",
26+
});
27+
});
28+
29+
it("returns null when the ref is not under the configured host (trust boundary)", () => {
30+
const ref = "evil.example.com/whatever/proj_abc:v1";
31+
expect(parseEcrImageReference(ref, ECR_HOST)).toBeNull();
32+
});
33+
34+
it("returns null when there is no tag", () => {
35+
expect(parseEcrImageReference(`${ECR_HOST}/deployments-test/proj_abc`, ECR_HOST)).toBeNull();
36+
});
37+
38+
it("returns null when the tag segment contains a slash", () => {
39+
// a stray colon earlier in the path must not be treated as the tag separator
40+
expect(parseEcrImageReference(`${ECR_HOST}/ns:weird/proj_abc`, ECR_HOST)).toBeNull();
41+
});
42+
});
43+
44+
describe("interpretBatchGetImageResponse", () => {
45+
it("returns found when an image is present", () => {
46+
expect(interpretBatchGetImageResponse({ images: [{}] } as any)).toBe("found");
47+
});
48+
49+
it("returns missing on an ImageNotFound failure", () => {
50+
expect(
51+
interpretBatchGetImageResponse({ failures: [{ failureCode: "ImageNotFound" }] } as any)
52+
).toBe("missing");
53+
});
54+
55+
it("returns unknown when there is neither an image nor a not-found failure", () => {
56+
expect(interpretBatchGetImageResponse({ failures: [{ failureCode: "Other" }] } as any)).toBe(
57+
"unknown"
58+
);
59+
expect(interpretBatchGetImageResponse({} as any)).toBe("unknown");
60+
});
61+
});
62+
63+
describe("ecrImageExists", () => {
64+
it("returns unknown for a non-ECR registry without calling the registry", async () => {
65+
let called = false;
66+
const result = await ecrImageExists(
67+
{
68+
imageReference: "registry.digitalocean.com/trigger-deployments/proj_abc:v1",
69+
registryConfig: { host: "registry.digitalocean.com", namespace: "trigger-deployments" },
70+
},
71+
async () => {
72+
called = true;
73+
return {} as any;
74+
}
75+
);
76+
expect(result).toBe("unknown");
77+
expect(called).toBe(false);
78+
});
79+
80+
it("returns found when the image exists", async () => {
81+
const result = await ecrImageExists(
82+
{
83+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
84+
registryConfig: ecrConfig,
85+
},
86+
async () => ({ images: [{}] }) as any
87+
);
88+
expect(result).toBe("found");
89+
});
90+
91+
it("returns missing when the registry reports ImageNotFound", async () => {
92+
const result = await ecrImageExists(
93+
{
94+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
95+
registryConfig: ecrConfig,
96+
},
97+
async () => ({ failures: [{ failureCode: "ImageNotFound" }] }) as any
98+
);
99+
expect(result).toBe("missing");
100+
});
101+
102+
it("returns unknown (fails open) when the registry call throws", async () => {
103+
const result = await ecrImageExists(
104+
{
105+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
106+
registryConfig: ecrConfig,
107+
},
108+
async () => {
109+
throw new Error("AccessDenied");
110+
}
111+
);
112+
expect(result).toBe("unknown");
113+
});
114+
115+
it("queries by digest when a valid digest is supplied", async () => {
116+
const digest = `sha256:${"b".repeat(64)}`;
117+
let seen: any;
118+
await ecrImageExists(
119+
{
120+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
121+
imageDigest: digest,
122+
registryConfig: ecrConfig,
123+
},
124+
async (input) => {
125+
seen = input;
126+
return { images: [{}] } as any;
127+
}
128+
);
129+
expect(seen.imageIds).toEqual([{ imageDigest: digest }]);
130+
});
131+
132+
it("falls back to the tag when the supplied digest is malformed", async () => {
133+
let seen: any;
134+
await ecrImageExists(
135+
{
136+
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
137+
imageDigest: "not-a-digest",
138+
registryConfig: ecrConfig,
139+
},
140+
async (input) => {
141+
seen = input;
142+
return { images: [{}] } as any;
143+
}
144+
);
145+
expect(seen.imageIds).toEqual([{ imageTag: "v1.prod.a1b2c3d4" }]);
146+
});
147+
});

0 commit comments

Comments
 (0)