Skip to content
Open
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
36 changes: 31 additions & 5 deletions apps/webapp/app/v3/services/initializeDeployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { tryCatch } from "@trigger.dev/core";
import { getRegistryConfig } from "../registryConfig.server";
import { DeploymentService } from "./deployment.server";
import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server";
import {
ImageReferenceMismatchError,
resolveOverrideImageRef,
} from "./initializeDeployment/resolveDeploymentImageRef";
import { errAsync } from "neverthrow";

const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
Expand Down Expand Up @@ -200,11 +204,27 @@ export class InitializeDeploymentService extends BaseService {
const [imageRefError, imageRefResult] = await tryCatch(
(async () => {
if (env.DEPLOY_IMAGE_OVERRIDE) {
return {
imageRef: env.DEPLOY_IMAGE_OVERRIDE,
isEcr: false,
repoCreated: false,
};
// The override is the opt-in switch for the pre-built-image
// flow. When the caller supplies its own canonical
// imageReference (constrained to the override's
// registry/repository), honor it so the stamped image is
// deterministic instead of a function of this pod's boot-time
// override snapshot.
try {
return {
imageRef: resolveOverrideImageRef({
override: env.DEPLOY_IMAGE_OVERRIDE,
clientImageReference: payload.imageReference,
}),
isEcr: false,
repoCreated: false,
};
} catch (error) {
if (error instanceof ImageReferenceMismatchError) {
throw new ServiceValidationError(error.message);
}
throw error;
}
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Pure resolution of the deployment image reference for single-tenant installs
* that set `DEPLOY_IMAGE_OVERRIDE`. Kept free of env/DB imports so it can be
* unit-tested directly (mirrors the `createDeploymentWithNextVersion` helper).
*
* Background: the override is the opt-in switch for the "pre-built canonical
* image" flow. When a caller (e.g. a self-hosted deploy hook that has already
* built and pushed the image) supplies its own `imageReference`, we honor it
* instead of the webapp pod's boot-time override snapshot so the stamped image
* is deterministic and not a function of pod rollout timing. To bound the
* supply-chain surface, the caller-supplied reference must share the override's
* registry + repository; the tag (version) and/or digest may differ.
*/

export class ImageReferenceMismatchError extends Error {
readonly name = "ImageReferenceMismatchError";
readonly clientRepository: string;
readonly overrideRepository: string;

constructor(args: { clientRepository: string; overrideRepository: string }) {
super(
`Client imageReference repository "${args.clientRepository}" does not match the configured DEPLOY_IMAGE_OVERRIDE repository "${args.overrideRepository}"`
);
this.clientRepository = args.clientRepository;
this.overrideRepository = args.overrideRepository;
}
}

/**
* Split an image reference into its repository (registry host + path) and its
* tag/digest. Tolerates a `registry:port` host (the tag is only the segment
* after the last `:` that follows the last `/`) and a trailing `@sha256:...`
* digest.
*/
export function parseImageRef(imageRef: string): {
repository: string;
tag?: string;
digest?: string;
} {
let rest = imageRef;
let digest: string | undefined;

const atIndex = rest.indexOf("@");
if (atIndex !== -1) {
digest = rest.slice(atIndex + 1);
rest = rest.slice(0, atIndex);
}

const lastSlash = rest.lastIndexOf("/");
const lastColon = rest.lastIndexOf(":");

// A colon denotes a tag only when it comes after the last path separator;
// otherwise it is the `registry:port` host separator and there is no tag.
if (lastColon > lastSlash) {
return {
repository: rest.slice(0, lastColon),
tag: rest.slice(lastColon + 1),
digest,
};
}

return { repository: rest, digest };
}

/**
* Resolve the image reference to stamp on a deployment when
* `DEPLOY_IMAGE_OVERRIDE` is set.
*
* - No caller-supplied reference -> use the override verbatim (prior behavior).
* - Caller-supplied reference -> require the same registry/repository as the
* override and use it (so the tag/digest can move ahead deterministically).
*
* Throws {@link ImageReferenceMismatchError} when the repositories differ.
*/
export function resolveOverrideImageRef(args: {
override: string;
clientImageReference?: string;
}): string {
const { override, clientImageReference } = args;

if (!clientImageReference) {
return override;
}

const overrideRepository = parseImageRef(override).repository;
const clientRepository = parseImageRef(clientImageReference).repository;

if (overrideRepository !== clientRepository) {
throw new ImageReferenceMismatchError({ clientRepository, overrideRepository });
}

return clientImageReference;
}
81 changes: 81 additions & 0 deletions apps/webapp/test/resolveDeploymentImageRef.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
ImageReferenceMismatchError,
parseImageRef,
resolveOverrideImageRef,
} from "../app/v3/services/initializeDeployment/resolveDeploymentImageRef";

describe("parseImageRef", () => {
it("splits registry/repository and tag", () => {
expect(parseImageRef("registry.example.com/acme/tasks:0.0.106")).toEqual({
repository: "registry.example.com/acme/tasks",
tag: "0.0.106",
digest: undefined,
});
});

it("treats a registry:port host as part of the repository, not a tag", () => {
expect(parseImageRef("localhost:5001/tasks")).toEqual({
repository: "localhost:5001/tasks",
tag: undefined,
digest: undefined,
});
});

it("handles registry:port host with a tag", () => {
expect(parseImageRef("localhost:5001/tasks:0.0.106")).toEqual({
repository: "localhost:5001/tasks",
tag: "0.0.106",
digest: undefined,
});
});

it("strips a trailing @sha256 digest", () => {
expect(
parseImageRef(
"123456789012.dkr.ecr.us-east-1.amazonaws.com/acme/tasks:0.0.107@sha256:" + "a".repeat(64)
)
).toEqual({
repository: "123456789012.dkr.ecr.us-east-1.amazonaws.com/acme/tasks",
tag: "0.0.107",
digest: "sha256:" + "a".repeat(64),
});
});
});

describe("resolveOverrideImageRef", () => {
const override = "registry.example.com/acme/tasks:0.0.104";

it("returns the override verbatim when no client imageReference is supplied", () => {
expect(resolveOverrideImageRef({ override })).toBe(override);
});

it("returns the client imageReference when it shares the override registry/repository", () => {
const clientImageReference = "registry.example.com/acme/tasks:0.0.106";
expect(resolveOverrideImageRef({ override, clientImageReference })).toBe(clientImageReference);
});

it("allows the client imageReference to add a digest to the same tag", () => {
const clientImageReference = "registry.example.com/acme/tasks:0.0.104@sha256:" + "b".repeat(64);
expect(resolveOverrideImageRef({ override, clientImageReference })).toBe(clientImageReference);
});

it("allows the client imageReference to differ by both tag and digest", () => {
const clientImageReference = "registry.example.com/acme/tasks:0.0.106@sha256:" + "c".repeat(64);
expect(resolveOverrideImageRef({ override, clientImageReference })).toBe(clientImageReference);
});

it("throws when the client imageReference repository differs from the override", () => {
const clientImageReference = "evil.example.com/attacker/image:latest";
expect(() => resolveOverrideImageRef({ override, clientImageReference })).toThrow(
ImageReferenceMismatchError
);
});

it("throws when only the registry host differs (same path)", () => {
const clientImageReference = "evil.example.com/acme/tasks:0.0.106";
expect(() => resolveOverrideImageRef({ override, clientImageReference })).toThrow(
ImageReferenceMismatchError
);
});
});
8 changes: 8 additions & 0 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,14 @@ const InitializeDeploymentRequestBodyBase = z.object({
isLocalBuild: z.boolean().optional(),
triggeredVia: DeploymentTriggeredVia.optional(),
buildId: z.string().optional(),
/**
* Caller-supplied canonical image reference. Only honored by 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. Ignored by stock multi-tenant builds.
*/
imageReference: z.string().optional(),
});
type BaseOutput = z.output<typeof InitializeDeploymentRequestBodyBase>;

Expand Down
Loading