diff --git a/.changeset/instance-deploy-base-images.md b/.changeset/instance-deploy-base-images.md new file mode 100644 index 00000000000..4baf5db50d5 --- /dev/null +++ b/.changeset/instance-deploy-base-images.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Self-hosted instances can require custom deploy base images per runtime via the new `DEPLOY_BASE_IMAGES` and `DEPLOY_BUILD_BASE_IMAGES` webapp settings. The CLI builds on the images the instance specifies, and older CLIs are rejected with an upgrade message. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index b43ebf63470..d7e0da8dfae 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -5,6 +5,7 @@ import { } from "@trigger.dev/core/v3/isomorphic"; import { BoolEnv } from "./utils/boolEnv"; import { isValidDatabaseUrl } from "./utils/db"; +import { parseDeployBaseImages } from "~/v3/deployBaseImages.server"; import { parseRunOpsShards, validateShardListAgainstNewUrl } from "~/v3/runOpsShards.server"; import { isValidRegex } from "./utils/regex"; import { isValidDuration } from "./services/realtime/duration.server"; @@ -16,6 +17,18 @@ function durationString() { return z.string().refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y"); } +const parseDeployBaseImagesEnv = ( + value: string | undefined, + envVarName: string, + ctx: z.RefinementCtx +) => { + const { images, errors } = parseDeployBaseImages(value, envVarName); + for (const message of errors) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message }); + } + return errors.length > 0 ? z.NEVER : images; +}; + const GithubAppEnvSchema = z.preprocess( (val) => { const obj = val as any; @@ -856,6 +869,14 @@ const EnvironmentSchema = z .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY), DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), + DEPLOY_BASE_IMAGES: z + .string() + .optional() + .transform((v, ctx) => parseDeployBaseImagesEnv(v, "DEPLOY_BASE_IMAGES", ctx)), + DEPLOY_BUILD_BASE_IMAGES: z + .string() + .optional() + .transform((v, ctx) => parseDeployBaseImagesEnv(v, "DEPLOY_BUILD_BASE_IMAGES", ctx)), DEPLOY_TIMEOUT_MS: z.coerce .number() .int() diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index e63bb9542d3..c5fa3d7224e 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -2,6 +2,8 @@ import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server"; import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; @@ -65,6 +67,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { externalId: deployment.externalId ?? undefined, externalBuildData: deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"], + baseImages: resolveDeployBaseImages(deployment.runtime, { + base: env.DEPLOY_BASE_IMAGES, + buildBase: env.DEPLOY_BUILD_BASE_IMAGES, + }), errorData: deployment.errorData as GetDeploymentResponseBody["errorData"], canceledReason: deployment.canceledReason, worker: deployment.worker diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 9b008d5ee75..232b0f63b72 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -9,6 +9,8 @@ import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { env } from "~/env.server"; +import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server"; import { InitializeDeploymentService } from "~/v3/services/initializeDeployment.server"; export async function action({ request, params }: ActionFunctionArgs) { @@ -60,6 +62,10 @@ export async function action({ request, params }: ActionFunctionArgs) { ? { externalBuildData: result.deployment .externalBuildData as InitializeDeploymentResponseBody["externalBuildData"], + baseImages: resolveDeployBaseImages(result.deployment.runtime, { + base: env.DEPLOY_BASE_IMAGES, + buildBase: env.DEPLOY_BUILD_BASE_IMAGES, + }), eventStream: result.eventStream, canceledDeployments: result.canceledDeployments, } diff --git a/apps/webapp/app/v3/deployBaseImages.server.ts b/apps/webapp/app/v3/deployBaseImages.server.ts new file mode 100644 index 00000000000..6f98784de25 --- /dev/null +++ b/apps/webapp/app/v3/deployBaseImages.server.ts @@ -0,0 +1,77 @@ +import { BuildRuntime, DeployBaseImageRef } from "@trigger.dev/core/v3"; + +type BaseImageMap = Partial>; + +export function parseDeployBaseImages( + value: string | undefined, + envVarName: string +): { images: BaseImageMap; errors: string[] } { + const images: BaseImageMap = {}; + const errors: string[] = []; + + if (!value) { + return { images, errors }; + } + + for (const segment of value.split(",").map((s) => s.trim())) { + if (!segment) { + continue; + } + + const fail = (reason: string) => errors.push(`${envVarName}: ${reason} in "${segment}"`); + + const separator = segment.indexOf("="); + if (separator === -1) { + fail("expected runtime=image"); + continue; + } + + const runtimeName = segment.slice(0, separator).trim(); + const image = segment.slice(separator + 1).trim(); + + if (runtimeName === "node") { + fail('runtime "node" is an alias; use the concrete runtime (node-22, node-24, node-26)'); + continue; + } + + const runtime = BuildRuntime.safeParse(runtimeName); + if (!runtime.success) { + fail(`unknown runtime "${runtimeName}" (expected one of ${BuildRuntime.options.join(", ")})`); + continue; + } + + if (!image) { + fail("missing image"); + continue; + } + + if (!DeployBaseImageRef.safeParse(image).success) { + fail("image must be image@sha256:<64 hex chars> with no whitespace before the digest"); + continue; + } + + if (runtime.data in images) { + fail(`duplicate runtime "${runtimeName}"`); + continue; + } + + images[runtime.data] = image; + } + + return { images, errors }; +} + +export function resolveDeployBaseImages( + runtime: string | null | undefined, + config: { base: BaseImageMap; buildBase: BaseImageMap } +): { base?: string; buildBase?: string } | undefined { + const parsedRuntime = BuildRuntime.safeParse(runtime); + if (!parsedRuntime.success) { + return undefined; + } + + const base = config.base[parsedRuntime.data]; + const buildBase = config.buildBase[parsedRuntime.data]; + + return base || buildBase ? { base, buildBase } : undefined; +} diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index ecc2d848fd3..25d2b06d07b 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -14,6 +14,7 @@ import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; +import { resolveDeployBaseImages } from "../deployBaseImages.server"; import { getDeploymentImageRef } from "../getDeploymentImageRef.server"; import { tryCatch } from "@trigger.dev/core"; import { getRegistryConfig } from "../registryConfig.server"; @@ -147,6 +148,25 @@ export class InitializeDeploymentService extends BaseService { throw new ServiceValidationError("UNMANAGED deployments are not supported"); } + const requiredBaseImages = resolveDeployBaseImages(runtime, { + base: env.DEPLOY_BASE_IMAGES, + buildBase: env.DEPLOY_BUILD_BASE_IMAGES, + }); + + if (requiredBaseImages && payload.isNativeBuild) { + throw new ServiceValidationError( + "This instance requires custom deploy base images, which native builds cannot apply. Deploy without --native-build or --local-bundle.", + 400 + ); + } + + if (requiredBaseImages && payload.supportsInstanceBaseImages !== true) { + throw new ServiceValidationError( + "This instance requires custom deploy base images, which this version of the CLI cannot apply. Upgrade the trigger.dev CLI and deploy again.", + 400 + ); + } + // Upgrade the project to engine "V2" if it's not already. This should cover cases where people deploy to V2 without running dev first. if (payload.type === "MANAGED" && environment.project.engine === "V1") { await this._prisma.project.update({ diff --git a/apps/webapp/test/deployBaseImages.test.ts b/apps/webapp/test/deployBaseImages.test.ts new file mode 100644 index 00000000000..e60b53d4b5a --- /dev/null +++ b/apps/webapp/test/deployBaseImages.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { parseDeployBaseImages, resolveDeployBaseImages } from "~/v3/deployBaseImages.server"; + +const digestA = `sha256:${"a".repeat(64)}`; +const digestB = `sha256:${"b".repeat(64)}`; +const digestC = `sha256:${"c".repeat(64)}`; + +describe("parseDeployBaseImages", () => { + it("returns an empty map for undefined and empty values", () => { + const empty = { images: {}, errors: [] }; + expect(parseDeployBaseImages(undefined, "DEPLOY_BASE_IMAGES")).toEqual(empty); + expect(parseDeployBaseImages("", "DEPLOY_BASE_IMAGES")).toEqual(empty); + expect(parseDeployBaseImages(" , ,", "DEPLOY_BASE_IMAGES")).toEqual(empty); + }); + + it("parses multiple entries and trims whitespace", () => { + expect( + parseDeployBaseImages( + ` node-24 = acme/node-fips:24@${digestA} , bun=acme/bun:1@${digestB},`, + "DEPLOY_BASE_IMAGES" + ) + ).toEqual({ + images: { + "node-24": `acme/node-fips:24@${digestA}`, + bun: `acme/bun:1@${digestB}`, + }, + errors: [], + }); + }); + + it("accepts a registry with a port and a tag before the digest", () => { + const image = `registry.example.com:5000/ns/img:tag@${digestA}`; + expect(parseDeployBaseImages(`node-24=${image}`, "DEPLOY_BASE_IMAGES")).toEqual({ + images: { "node-24": image }, + errors: [], + }); + }); + + it.each([ + ["missing =", "garbage"], + ["unknown runtime", `node-23=acme/node:23@${digestA}`], + ["node alias", `node=acme/node:24@${digestA}`], + ["empty image", "node-24="], + ["missing digest", "node-24=acme/node:24"], + ["flag before image", `node-24=--platform=linux/arm64 acme/node@${digestA}`], + ["bare digest", `node-24=@${digestA}`], + ["newline in image", `node-24=acme/node\nx@${digestA}`], + ["duplicate runtime", `node-24=acme/a@${digestA},node-24=acme/b@${digestB}`], + ])("reports an error naming the env var and segment: %s", (_name, value) => { + const segments = value.split(","); + const offending = segments[segments.length - 1]!.trim(); + + const { images, errors } = parseDeployBaseImages( + `node-22=acme/ok@${digestC},${value}`, + "DEPLOY_BUILD_BASE_IMAGES" + ); + + expect(images["node-22"]).toBe(`acme/ok@${digestC}`); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("DEPLOY_BUILD_BASE_IMAGES"); + expect(errors[0]).toContain(offending); + }); + + it("explains that node is an alias", () => { + const { errors } = parseDeployBaseImages(`node=acme/node@${digestA}`, "DEPLOY_BASE_IMAGES"); + expect(errors[0]).toContain('runtime "node" is an alias; use the concrete runtime'); + }); + + it("reports every bad segment", () => { + const { errors } = parseDeployBaseImages( + `garbage,node-23=acme/node@${digestA},bun=acme/bun`, + "DEPLOY_BASE_IMAGES" + ); + expect(errors).toHaveLength(3); + expect(errors[0]).toContain("garbage"); + expect(errors[1]).toContain("node-23"); + expect(errors[2]).toContain("bun=acme/bun"); + }); +}); + +describe("resolveDeployBaseImages", () => { + const base = { "node-26": `acme/node-fips:26@${digestA}` } as const; + const buildBase = { "node-26": `acme/node:26-dev@${digestB}` } as const; + + it("returns undefined for a missing or unknown runtime", () => { + expect(resolveDeployBaseImages("node-23", { base, buildBase })).toBeUndefined(); + expect(resolveDeployBaseImages(null, { base, buildBase })).toBeUndefined(); + expect(resolveDeployBaseImages(undefined, { base, buildBase })).toBeUndefined(); + }); + + it("returns undefined when the runtime has no entries", () => { + expect(resolveDeployBaseImages("bun", { base, buildBase })).toBeUndefined(); + expect(resolveDeployBaseImages("node-26", { base: {}, buildBase: {} })).toBeUndefined(); + }); + + it("returns both images", () => { + expect(resolveDeployBaseImages("node-26", { base, buildBase })).toEqual({ + base: base["node-26"], + buildBase: buildBase["node-26"], + }); + }); + + it("returns only the base image", () => { + expect(resolveDeployBaseImages("node-26", { base, buildBase: {} })).toEqual({ + base: base["node-26"], + }); + }); + + it("returns only the build base image", () => { + expect(resolveDeployBaseImages("node-26", { base: {}, buildBase })).toEqual({ + buildBase: buildBase["node-26"], + }); + }); +}); diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index 643a8b15405..dc7d6a65167 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -101,6 +101,8 @@ mode: "wide" | `DEPLOY_REGISTRY_NAMESPACE` | No | trigger | Deploy registry namespace. | | `DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY` | No | — | Raw IAM policy JSON applied via SetRepositoryPolicy to every ECR repo created by the webapp. Use to grant cross-account pull access to EKS workers when the ECR account is separate from the cluster account. | | `DEPLOY_IMAGE_PLATFORM` | No | linux/amd64 | Deploy image platform, same values as docker `--platform` flag. | +| `DEPLOY_BASE_IMAGES` | No | — | Base images every deploy must build on, as comma-separated `runtime=image@sha256:`. See [custom base images](/self-hosting/overview#custom-base-images). | +| `DEPLOY_BUILD_BASE_IMAGES` | No | — | Build-stage images, in the same format as `DEPLOY_BASE_IMAGES`. See [custom base images](/self-hosting/overview#custom-base-images). | | `DEPLOY_TIMEOUT_MS` | No | 480000 (8m) | Deploy timeout (ms). | | `DEPLOY_QUEUE_TIMEOUT_MS` | No | 900000 (15m) | Deploy queue timeout (ms). | | **Object store (S3)** | | | | diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index 0b2192a166b..2f7479f2309 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -100,6 +100,32 @@ All fields are optional. Partial overrides are supported: } ``` +## Custom base images + +Deploys build on the published `triggerdotdev/node` and `triggerdotdev/bun` Debian images. To require a different base for every deploy to your instance, such as a FIPS-validated or hardened Node image, set `DEPLOY_BASE_IMAGES` on the webapp (and optionally `DEPLOY_BUILD_BASE_IMAGES` for the build stage): + +```bash +DEPLOY_BASE_IMAGES="node-26=registry.example.com/node-fips:26@sha256:<64-character-digest>" +``` + +Entries are comma-separated `runtime=image@sha256:`. The runtimes are `node-22`, `node-24`, `node-26` and `bun`, and every image must be pinned by digest. An invalid value prevents the webapp from starting. Projects with `runtime: "node"` in their config resolve to the current default Node runtime (`node-24` today), so set that key for them. With the Helm chart, set the variables through `webapp.extraEnvVars`. + +Runtimes with an entry build on that image; the others keep the published images. Deploys from CLI versions that cannot apply the images are rejected with an error asking to upgrade, and so are `--native-build`, `--local-bundle` and `--from-bundle` deploys. This is a self-hosting setting and does not apply to Trigger.dev Cloud. + +You own a custom base image. It must provide: + +- `node` on `PATH` at the runtime's major version (for `bun`, both `bun` and `node`, because the final stage starts the app with `dumb-init node`) +- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl` +- a `node` user (a `bun` user for the Bun runtime) +- glibc, so native modules built in the build stage load at runtime + +A `DEPLOY_BUILD_BASE_IMAGES` image needs everything the base image provides, plus `python3`, `make` and `g++`. Build extensions that add image instructions are replayed on it. Without an entry, the build stage uses the published build image, except for projects whose build extensions add image instructions: those build from your base image and install the toolchain with `apt-get`, so that base must be Debian-based. To avoid the published images entirely, set both variables. + + + `image.pkgs` and build extensions that run `apt-get` (such as `aptGet` and `playwright`) assume a + Debian base. On other distributions, install those packages in your base image instead. + + ## Community support It's dangerous to go alone! Join the self-hosting channel on our [Discord server](https://discord.gg/NQTxt5NA7s). diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index 6ad0da8ba07..299b4fc5d76 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -276,7 +276,7 @@ async function readProjectPackageJson(packageJsonPath: string) { return packageJson; } -async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) { +export async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) { if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) { throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]"); } diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 9dd74ca7eaf..ce70f4408df 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -22,7 +22,7 @@ import { x } from "tinyexec"; import { z } from "zod"; import chalk from "chalk"; import type { CliApiClient } from "../apiClient.js"; -import { buildWorker } from "../build/buildWorker.js"; +import { buildWorker, writeContainerfile } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; import { createBundleArchive } from "../deploy/bundleArchive.js"; @@ -591,6 +591,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { triggeredVia: getTriggeredVia(), externalId: options.externalId, force: options.force, + supportsInstanceBaseImages: true, }, envVars.TRIGGER_EXISTING_DEPLOYMENT_ID ); @@ -638,6 +639,13 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId); + await applyServerBaseImages({ + baseImages: deployment.baseImages, + outputPath: destination.path, + buildManifest, + options, + }); + // When `externalBuildData` is not present the deployment implicitly goes into the local build path // which is used in self-hosted setups. There are a few subtle differences between local builds for the cloud // and local builds for self-hosted setups. We need to make the separation of the two paths clearer to avoid confusion. @@ -1202,6 +1210,40 @@ function buildDeploymentLinks({ }; } +async function applyServerBaseImages({ + baseImages, + outputPath, + buildManifest, + options, +}: { + baseImages: InitializeDeploymentResponseBody["baseImages"]; + outputPath: string; + buildManifest: BuildManifest; + options: DeployCommandOptions; +}) { + if (!baseImages) { + return; + } + + const required = [ + baseImages.base ? `base ${baseImages.base}` : undefined, + baseImages.buildBase ? `build ${baseImages.buildBase}` : undefined, + ].filter(Boolean); + + const message = `Building on base images required by this instance: ${required.join(", ")}`; + + if (options.plain) { + console.log(message); + } else { + log.info(message); + } + + await writeContainerfile(outputPath, { + ...buildManifest, + image: { ...buildManifest.image, ...baseImages }, + }); +} + function warnAboutSkippedBuild(externalId: string | undefined, isPromoted: boolean | undefined) { prettyWarning( "Environment variables were not synced because nothing was built.", @@ -2261,10 +2303,22 @@ async function handleFromBundleDeploy({ isLocalBuild: true, isNativeBuild: false, triggeredVia: getTriggeredVia(), + supportsInstanceBaseImages: true, }, existingDeploymentId ); + if (deployment.baseImages) { + const message = + "This instance requires custom deploy base images, which --from-bundle deploys cannot apply. Deploy without --from-bundle."; + + await projectClient.client.failDeployment(deployment.id, { + error: { name: "BuildError", message }, + }); + + throw new Error(message); + } + // Fail fast if we know local builds will fail const buildxResult = await x("docker", ["buildx", "version"]); diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 128b2261733..061c6013d6c 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -233,4 +233,94 @@ describe("generateContainerfile", () => { expect(excludeCopy).toBeGreaterThan(codeStage); } ); + + it.each(["node", "bun"] as BuildRuntime[])( + "uses the configured base and build images on %s", + async (runtime) => { + const containerfile = await generateContainerfile({ + runtime, + build: {}, + image: { + base: "acme/node-fips:26@sha256:abc", + buildBase: "acme/node:26-dev@sha256:def", + }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base"); + expect(containerfile).toContain("FROM acme/node:26-dev@sha256:def AS build"); + expect(containerfile).toContain("FROM base AS final"); + expect(containerfile).not.toContain(BASE_IMAGE[runtime]); + expect(containerfile).not.toContain(BUILD_IMAGE[runtime]); + } + ); + + it("keeps the published build image when only the base is overridden", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-26", + build: {}, + image: { base: "acme/node-fips:26@sha256:abc" }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base"); + expect(containerfile).toContain(`FROM ${BUILD_IMAGE["node-26"]} AS build`); + }); + + it("builds on the configured build image and replays instructions there", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-26", + build: {}, + image: { + base: "acme/node-fips:26@sha256:abc", + buildBase: "acme/node:26-dev@sha256:def", + pkgs: ["jq"], + instructions: ["RUN echo first > /etc/first", "RUN echo second > /etc/second"], + }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + const buildStage = containerfile.slice(containerfile.indexOf("AS build")); + + expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base"); + expect(containerfile).toContain("FROM acme/node:26-dev@sha256:def AS build"); + expect(containerfile).not.toContain("FROM base AS build"); + expect(containerfile).not.toContain(TOOLCHAIN_PACKAGES); + expect(buildStage).toContain( + "apt-get install -y --no-install-recommends --allow-downgrades jq" + ); + expect(containerfile.indexOf("RUN echo first > /etc/first")).toBeLessThan( + containerfile.indexOf("AS build") + ); + + const buildFrom = "FROM acme/node:26-dev@sha256:def AS build"; + const baseEnv = "ENV DEBIAN_FRONTEND=noninteractive\n\n"; + const baseStart = containerfile.indexOf(baseEnv) + baseEnv.length; + const baseCustomization = containerfile.slice( + baseStart, + containerfile.indexOf(buildFrom) - "\n\n".length + ); + + expect(containerfile).toContain(`${buildFrom}\n\n${baseEnv}${baseCustomization}\n\n`); + }); + + it("builds from the base stage when instructions have no configured build image", async () => { + const containerfile = await generateContainerfile({ + runtime: "node-26", + build: {}, + image: { + base: "acme/node-fips:26@sha256:abc", + instructions: ["RUN echo custom > /etc/marker"], + }, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).toContain("FROM acme/node-fips:26@sha256:abc AS base"); + expect(containerfile).toContain("FROM base AS build"); + expect(containerfile).toContain(TOOLCHAIN_PACKAGES); + }); }); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 47d0c84d63e..ac41a18d1d7 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -801,21 +801,20 @@ const parseGenerateOptions = (options: GenerateContainerfileOptions) => { // Instructions run once (FROM base) since their downloads are unbounded; // package-only projects keep the prebuilt toolchain and repeat the small install - const buildStage = baseInstructions - ? `FROM base AS build + const buildStage = + baseInstructions && !options.image?.buildBase + ? `FROM base AS build RUN apt-get update && \\ apt-get install -y --no-install-recommends ${TOOLCHAIN_PACKAGES} && \\ apt-get clean && \\ rm -rf /var/lib/apt/lists/*` - : `FROM ${BUILD_IMAGE[options.runtime]} AS build + : `FROM ${options.image?.buildBase ?? BUILD_IMAGE[options.runtime]} AS build -ENV DEBIAN_FRONTEND=noninteractive${ - userPackages.length > 0 ? `\n\n${aptInstall(userPackages, { repair: false })}` : "" - }`; +ENV DEBIAN_FRONTEND=noninteractive${customization ? `\n\n${customization}` : ""}`; return { - baseImage: BASE_IMAGE[options.runtime], + baseImage: options.image?.base ?? BASE_IMAGE[options.runtime], buildStage, customization, buildArgs, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ec673c045d5..08967c1a78d 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -840,6 +840,15 @@ export const CreateArtifactResponseBody = z.object({ export type CreateArtifactResponseBody = z.infer; +export const DeployBaseImageRef = z.string().regex(/^[^\s@]+@sha256:[a-f0-9]{64}$/); + +export const DeployBaseImages = z.object({ + base: DeployBaseImageRef.optional(), + buildBase: DeployBaseImageRef.optional(), +}); + +export type DeployBaseImages = z.infer; + export const InitializeDeploymentResponseBody = z.object({ id: z.string(), contentHash: z.string(), @@ -851,6 +860,7 @@ export const InitializeDeploymentResponseBody = z.object({ outcome: z.enum(["created", "existing"]).optional(), isPromoted: z.boolean().optional(), externalBuildData: ExternalBuildData.optional().nullable(), + baseImages: DeployBaseImages.optional(), canceledDeployments: z.array(z.object({ version: z.string(), shortCode: z.string() })).optional(), eventStream: z .object({ @@ -879,6 +889,7 @@ const InitializeDeploymentRequestBodyBase = z.object({ buildId: z.string().optional(), externalId: ExternalDeploymentId, force: z.boolean().optional(), + supportsInstanceBaseImages: z.boolean().optional(), }); type BaseOutput = z.output; @@ -1029,6 +1040,7 @@ export const GetDeploymentResponseBody = z.object({ */ externalId: z.string().optional(), externalBuildData: ExternalBuildData.optional().nullable(), + baseImages: DeployBaseImages.optional(), errorData: DeploymentErrorData.nullish(), canceledReason: z.string().nullish(), worker: z diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index f31f0882b8f..b966313ae7e 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -85,6 +85,8 @@ export const BuildManifest = z.object({ .object({ pkgs: z.array(z.string()).optional(), instructions: z.array(z.string()).optional(), + base: z.string().optional(), + buildBase: z.string().optional(), }) .optional(), otelImportHook: z