diff --git a/.changeset/instance-deploy-base-images.md b/.changeset/instance-deploy-base-images.md new file mode 100644 index 00000000000..16a9390c955 --- /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 base images for deploys, such as FIPS-validated or hardened Node images, with the new `DEPLOY_BASE_IMAGES` webapp setting. The CLI builds on the base images the instance specifies. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index be0106debbf..2697b880e14 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -901,6 +901,8 @@ const EnvironmentSchema = z ), DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), + DEPLOY_BASE_IMAGES: z.string().optional(), // csv of runtime=image, for example: "node-26=registry.example.com/node-fips:26@sha256:..." + DEPLOY_BUILD_BASE_IMAGES: z.string().optional(), // csv of runtime=image for the build stage DEPLOY_TIMEOUT_MS: z.coerce .number() .int() 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..a5b9a0e66ed --- /dev/null +++ b/apps/webapp/app/v3/deployBaseImages.server.ts @@ -0,0 +1,45 @@ +type BaseImages = { base?: string; buildBase?: string }; + +/** Base images the operator requires for a runtime, from `runtime=image` csv env vars. */ +export function resolveDeployBaseImages( + runtime: string | null | undefined, + config: { base?: string; buildBase?: string } +): BaseImages | undefined { + if (!runtime) { + return undefined; + } + + const base = parseImageMap(config.base)[runtime]; + const buildBase = parseImageMap(config.buildBase)[runtime]; + + if (!base && !buildBase) { + return undefined; + } + + return { + ...(base ? { base } : {}), + ...(buildBase ? { buildBase } : {}), + }; +} + +function parseImageMap(value: string | undefined): Record { + if (!value) { + return {}; + } + + return Object.fromEntries( + value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .flatMap((entry) => { + const separator = entry.indexOf("="); + if (separator <= 0) { + return []; + } + const runtime = entry.slice(0, separator).trim(); + const image = entry.slice(separator + 1).trim(); + return image ? [[runtime, image] as const] : []; + }) + ); +} diff --git a/apps/webapp/test/deployBaseImages.test.ts b/apps/webapp/test/deployBaseImages.test.ts new file mode 100644 index 00000000000..ca92b2345de --- /dev/null +++ b/apps/webapp/test/deployBaseImages.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { resolveDeployBaseImages } from "~/v3/deployBaseImages.server"; + +describe("resolveDeployBaseImages", () => { + it("returns undefined when nothing is configured", () => { + expect(resolveDeployBaseImages("node-26", {})).toBeUndefined(); + }); + + it("returns the images configured for the runtime", () => { + expect( + resolveDeployBaseImages("node-26", { + base: "node-24=acme/node-fips:24@sha256:aaa, node-26=acme/node-fips:26@sha256:bbb", + buildBase: "node-26=acme/node:26-dev@sha256:ccc", + }) + ).toEqual({ base: "acme/node-fips:26@sha256:bbb", buildBase: "acme/node:26-dev@sha256:ccc" }); + }); + + it("returns undefined for runtimes without an entry", () => { + expect(resolveDeployBaseImages("bun", { base: "node-26=acme/node-fips:26" })).toBeUndefined(); + }); + + it("returns undefined when the deployment has no runtime", () => { + expect(resolveDeployBaseImages(null, { base: "node-26=acme/node-fips:26" })).toBeUndefined(); + }); + + it("skips malformed entries", () => { + expect( + resolveDeployBaseImages("node-26", { base: "garbage,=nope,node-26=,node-26=acme/node:26" }) + ).toEqual({ base: "acme/node:26" }); + }); +}); diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index 506e95880f7..2a9564b201f 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -97,6 +97,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, per runtime, as `runtime=image` csv, e.g. `node-26=registry.example.com/node-fips:26@sha256:...`. Use for FIPS-validated or hardened images. See [custom base images](/self-hosting/overview#custom-base-images). | +| `DEPLOY_BUILD_BASE_IMAGES` | No | — | Build-stage toolchain images per runtime, same format as `DEPLOY_BASE_IMAGES`. Defaults to the published `-build` 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..229fe845629 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -100,6 +100,28 @@ 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:..." +``` + +The CLI builds with these images for any runtime that has an entry. Runtimes without one keep the published images. With the Helm chart, set them through `webapp.extraEnvVars`. + +You own a custom base image. It must provide: + +- `node` (or `bun`) on `PATH` at the runtime's major version +- `busybox`, `ca-certificates`, `dumb-init`, `git` and `openssl` +- a `node` user +- glibc, so native modules built in the build stage load at runtime + + + `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..51b7751d04d 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"; @@ -638,6 +638,15 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { warnAboutCanceledDeployments(deployment.canceledDeployments, options.externalId); + if (deployment.baseImages) { + logger.debug("Using base images required by the server", deployment.baseImages); + + await writeContainerfile(destination.path, { + ...buildManifest, + image: { ...buildManifest.image, ...deployment.baseImages }, + }); + } + // 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. diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts index 128b2261733..00409e3f519 100644 --- a/packages/cli-v3/src/deploy/buildImage.test.ts +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -233,4 +233,39 @@ 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`); + }); }); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 47d0c84d63e..3ed093ef610 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -808,14 +808,14 @@ 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 })}` : "" }`; 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 3a6c46621a8..fb1092af254 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -849,6 +849,13 @@ export const InitializeDeploymentResponseBody = z.object({ outcome: z.enum(["created", "existing"]).optional(), isPromoted: z.boolean().optional(), externalBuildData: ExternalBuildData.optional().nullable(), + /** Base images the instance operator requires for this deployment's runtime */ + baseImages: z + .object({ + base: z.string().optional(), + buildBase: z.string().optional(), + }) + .optional(), canceledDeployments: z.array(z.object({ version: z.string(), shortCode: z.string() })).optional(), eventStream: z .object({ 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