Skip to content

Commit 116254e

Browse files
fix(webapp): stamp caller-supplied imageReference on deploy initialize
Add an optional imageReference to the deployment initialize API and honor it only on single-tenant installs that set DEPLOY_IMAGE_OVERRIDE, and only when it shares the override's registry/repository. Lets a pre-built-image deploy flow stamp a deterministic image instead of depending on the webapp pod's boot-time override snapshot, fixing stale-image COULD_NOT_FIND_TASK. PLT-1059 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 89ab528 commit 116254e

4 files changed

Lines changed: 213 additions & 5 deletions

File tree

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import { tryCatch } from "@trigger.dev/core";
1616
import { getRegistryConfig } from "../registryConfig.server";
1717
import { DeploymentService } from "./deployment.server";
1818
import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server";
19+
import {
20+
ImageReferenceMismatchError,
21+
resolveOverrideImageRef,
22+
} from "./initializeDeployment/resolveDeploymentImageRef";
1923
import { errAsync } from "neverthrow";
2024

2125
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
@@ -200,11 +204,27 @@ export class InitializeDeploymentService extends BaseService {
200204
const [imageRefError, imageRefResult] = await tryCatch(
201205
(async () => {
202206
if (env.DEPLOY_IMAGE_OVERRIDE) {
203-
return {
204-
imageRef: env.DEPLOY_IMAGE_OVERRIDE,
205-
isEcr: false,
206-
repoCreated: false,
207-
};
207+
// The override is the opt-in switch for the pre-built-image
208+
// flow. When the caller supplies its own canonical
209+
// imageReference (constrained to the override's
210+
// registry/repository), honor it so the stamped image is
211+
// deterministic instead of a function of this pod's boot-time
212+
// override snapshot.
213+
try {
214+
return {
215+
imageRef: resolveOverrideImageRef({
216+
override: env.DEPLOY_IMAGE_OVERRIDE,
217+
clientImageReference: payload.imageReference,
218+
}),
219+
isEcr: false,
220+
repoCreated: false,
221+
};
222+
} catch (error) {
223+
if (error instanceof ImageReferenceMismatchError) {
224+
throw new ServiceValidationError(error.message);
225+
}
226+
throw error;
227+
}
208228
}
209229

210230
return getDeploymentImageRef({
@@ -226,6 +246,12 @@ export class InitializeDeploymentService extends BaseService {
226246
type: payload.type,
227247
cause: imageRefError.message,
228248
});
249+
// Surface a deliberate validation failure (e.g. a client
250+
// imageReference that doesn't match the override repository) with
251+
// its specific message rather than the generic fallback.
252+
if (imageRefError instanceof ServiceValidationError) {
253+
throw imageRefError;
254+
}
229255
throw new ServiceValidationError("Failed to get deployment image ref");
230256
}
231257

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Pure resolution of the deployment image reference for single-tenant installs
3+
* that set `DEPLOY_IMAGE_OVERRIDE`. Kept free of env/DB imports so it can be
4+
* unit-tested directly (mirrors the `createDeploymentWithNextVersion` helper).
5+
*
6+
* Background: the override is the opt-in switch for the "pre-built canonical
7+
* image" flow. When a caller (e.g. a self-hosted deploy hook that has already
8+
* built and pushed the image) supplies its own `imageReference`, we honor it
9+
* instead of the webapp pod's boot-time override snapshot so the stamped image
10+
* is deterministic and not a function of pod rollout timing. To bound the
11+
* supply-chain surface, the caller-supplied reference must share the override's
12+
* registry + repository; the tag (version) and/or digest may differ.
13+
*/
14+
15+
export class ImageReferenceMismatchError extends Error {
16+
readonly name = "ImageReferenceMismatchError";
17+
readonly clientRepository: string;
18+
readonly overrideRepository: string;
19+
20+
constructor(args: { clientRepository: string; overrideRepository: string }) {
21+
super(
22+
`Client imageReference repository "${args.clientRepository}" does not match the configured DEPLOY_IMAGE_OVERRIDE repository "${args.overrideRepository}"`
23+
);
24+
this.clientRepository = args.clientRepository;
25+
this.overrideRepository = args.overrideRepository;
26+
}
27+
}
28+
29+
/**
30+
* Split an image reference into its repository (registry host + path) and its
31+
* tag/digest. Tolerates a `registry:port` host (the tag is only the segment
32+
* after the last `:` that follows the last `/`) and a trailing `@sha256:...`
33+
* digest.
34+
*/
35+
export function parseImageRef(imageRef: string): {
36+
repository: string;
37+
tag?: string;
38+
digest?: string;
39+
} {
40+
let rest = imageRef;
41+
let digest: string | undefined;
42+
43+
const atIndex = rest.indexOf("@");
44+
if (atIndex !== -1) {
45+
digest = rest.slice(atIndex + 1);
46+
rest = rest.slice(0, atIndex);
47+
}
48+
49+
const lastSlash = rest.lastIndexOf("/");
50+
const lastColon = rest.lastIndexOf(":");
51+
52+
// A colon denotes a tag only when it comes after the last path separator;
53+
// otherwise it is the `registry:port` host separator and there is no tag.
54+
if (lastColon > lastSlash) {
55+
return {
56+
repository: rest.slice(0, lastColon),
57+
tag: rest.slice(lastColon + 1),
58+
digest,
59+
};
60+
}
61+
62+
return { repository: rest, digest };
63+
}
64+
65+
/**
66+
* Resolve the image reference to stamp on a deployment when
67+
* `DEPLOY_IMAGE_OVERRIDE` is set.
68+
*
69+
* - No caller-supplied reference -> use the override verbatim (prior behavior).
70+
* - Caller-supplied reference -> require the same registry/repository as the
71+
* override and use it (so the tag/digest can move ahead deterministically).
72+
*
73+
* Throws {@link ImageReferenceMismatchError} when the repositories differ.
74+
*/
75+
export function resolveOverrideImageRef(args: {
76+
override: string;
77+
clientImageReference?: string;
78+
}): string {
79+
const { override, clientImageReference } = args;
80+
81+
if (!clientImageReference) {
82+
return override;
83+
}
84+
85+
const overrideRepository = parseImageRef(override).repository;
86+
const clientRepository = parseImageRef(clientImageReference).repository;
87+
88+
if (overrideRepository !== clientRepository) {
89+
throw new ImageReferenceMismatchError({ clientRepository, overrideRepository });
90+
}
91+
92+
return clientImageReference;
93+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
ImageReferenceMismatchError,
4+
parseImageRef,
5+
resolveOverrideImageRef,
6+
} from "../app/v3/services/initializeDeployment/resolveDeploymentImageRef";
7+
8+
describe("parseImageRef", () => {
9+
it("splits registry/repository and tag", () => {
10+
expect(parseImageRef("registry.example.com/acme/tasks:0.0.106")).toEqual({
11+
repository: "registry.example.com/acme/tasks",
12+
tag: "0.0.106",
13+
digest: undefined,
14+
});
15+
});
16+
17+
it("treats a registry:port host as part of the repository, not a tag", () => {
18+
expect(parseImageRef("localhost:5001/tasks")).toEqual({
19+
repository: "localhost:5001/tasks",
20+
tag: undefined,
21+
digest: undefined,
22+
});
23+
});
24+
25+
it("handles registry:port host with a tag", () => {
26+
expect(parseImageRef("localhost:5001/tasks:0.0.106")).toEqual({
27+
repository: "localhost:5001/tasks",
28+
tag: "0.0.106",
29+
digest: undefined,
30+
});
31+
});
32+
33+
it("strips a trailing @sha256 digest", () => {
34+
expect(
35+
parseImageRef(
36+
"123456789012.dkr.ecr.us-east-1.amazonaws.com/acme/tasks:0.0.107@sha256:" + "a".repeat(64)
37+
)
38+
).toEqual({
39+
repository: "123456789012.dkr.ecr.us-east-1.amazonaws.com/acme/tasks",
40+
tag: "0.0.107",
41+
digest: "sha256:" + "a".repeat(64),
42+
});
43+
});
44+
});
45+
46+
describe("resolveOverrideImageRef", () => {
47+
const override = "registry.example.com/acme/tasks:0.0.104";
48+
49+
it("returns the override verbatim when no client imageReference is supplied", () => {
50+
expect(resolveOverrideImageRef({ override })).toBe(override);
51+
});
52+
53+
it("returns the client imageReference when it shares the override registry/repository", () => {
54+
const clientImageReference = "registry.example.com/acme/tasks:0.0.106";
55+
expect(resolveOverrideImageRef({ override, clientImageReference })).toBe(clientImageReference);
56+
});
57+
58+
it("allows the client imageReference to add a digest to the same tag", () => {
59+
const clientImageReference = "registry.example.com/acme/tasks:0.0.104@sha256:" + "b".repeat(64);
60+
expect(resolveOverrideImageRef({ override, clientImageReference })).toBe(clientImageReference);
61+
});
62+
63+
it("allows the client imageReference to differ by both tag and digest", () => {
64+
const clientImageReference = "registry.example.com/acme/tasks:0.0.106@sha256:" + "c".repeat(64);
65+
expect(resolveOverrideImageRef({ override, clientImageReference })).toBe(clientImageReference);
66+
});
67+
68+
it("throws when the client imageReference repository differs from the override", () => {
69+
const clientImageReference = "evil.example.com/attacker/image:latest";
70+
expect(() => resolveOverrideImageRef({ override, clientImageReference })).toThrow(
71+
ImageReferenceMismatchError
72+
);
73+
});
74+
75+
it("throws when only the registry host differs (same path)", () => {
76+
const clientImageReference = "evil.example.com/acme/tasks:0.0.106";
77+
expect(() => resolveOverrideImageRef({ override, clientImageReference })).toThrow(
78+
ImageReferenceMismatchError
79+
);
80+
});
81+
});

packages/core/src/v3/schemas/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,14 @@ const InitializeDeploymentRequestBodyBase = z.object({
667667
isLocalBuild: z.boolean().optional(),
668668
triggeredVia: DeploymentTriggeredVia.optional(),
669669
buildId: z.string().optional(),
670+
/**
671+
* Caller-supplied canonical image reference. Only honored by single-tenant
672+
* installs that set `DEPLOY_IMAGE_OVERRIDE`, and only when it shares the
673+
* override's registry/repository. Lets a pre-built-image deploy flow stamp a
674+
* deterministic image instead of depending on the webapp pod's boot-time
675+
* override snapshot. Ignored by stock multi-tenant builds.
676+
*/
677+
imageReference: z.string().optional(),
670678
});
671679
type BaseOutput = z.output<typeof InitializeDeploymentRequestBodyBase>;
672680

0 commit comments

Comments
 (0)