From 4000950443e5320b0c74eb8020c8f13f47e5e60a Mon Sep 17 00:00:00 2001 From: Conner <20548516+ConProgramming@users.noreply.github.com> Date: Tue, 26 May 2026 11:34:40 -0500 Subject: [PATCH] feat(cli-v3): support offline build via TRIGGER_INDEX_OFFLINE + containerfile-module Adds a documented workflow for building tasks images without calling out to the trigger.dev API at build time, plus the necessary code paths. Two new build-time options: TRIGGER_INDEX_OFFLINE=1 Bypasses the live CliApiClient during indexing. Index metadata is written to /app/index-metadata.json instead of POSTed to the deploy endpoint. A separate process at deploy time reads the file and submits it to the real API. --containerfile-module= Lets operators provide a custom Containerfile generator instead of the built-in templates. Useful for air-gapped registries, custom base images, FIPS-compliant variants, etc. Together these let CI builders produce a deployable tasks image with no live API dependency at build time. See packages/cli-v3/docs/build-offline.md for the full flow + an example pipeline. The online path is unchanged; both flags are opt-in. Co-authored-by: Cursor --- packages/cli-v3/docs/build-offline.md | 65 ++++++++++++ packages/cli-v3/src/build/buildWorker.ts | 13 ++- packages/cli-v3/src/deploy/buildImage.ts | 68 +++++++++++- .../src/deploy/containerfile-template.ts | 5 + .../entryPoints/managed-index-controller.ts | 100 ++++++++++++++++-- 5 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 packages/cli-v3/docs/build-offline.md create mode 100644 packages/cli-v3/src/deploy/containerfile-template.ts diff --git a/packages/cli-v3/docs/build-offline.md b/packages/cli-v3/docs/build-offline.md new file mode 100644 index 00000000000..ab1e60557ba --- /dev/null +++ b/packages/cli-v3/docs/build-offline.md @@ -0,0 +1,65 @@ +# Offline-build flow (`TRIGGER_INDEX_OFFLINE=1`) + +This document covers the build pipeline for operators who need to build a +tasks image **without calling out to a live trigger.dev API at build time** +— e.g. air-gapped or regulated build environments where the build host +cannot reach the webapp the image will eventually be deployed against. + +## How it works + +Two pieces compose: + +1. `TRIGGER_INDEX_OFFLINE=1` switches the in-image indexer to a stub + `CliApiClient` that writes deployment metadata to disk instead of + POST-ing it to the API. See + [`packages/cli-v3/src/entryPoints/managed-index-controller.ts`](../src/entryPoints/managed-index-controller.ts) + (`createOfflineCliApiClient`). The shim implements only the three + methods that `indexDeployment` actually calls: + - `getEnvironmentVariables()` → returns `{}` (no project env vars + reachable at build time) + - `createDeploymentBackgroundWorker(_id, body)` → writes + `index-metadata.json` to `process.cwd()` + - `failDeployment(_id, body)` → writes `index-error.json` +2. The build script (a small `build.mjs` driven by `@trigger.dev/cli-v3`'s + `internal` subpath — see [`packages/cli-v3/src/internal.ts`](../src/internal.ts)) + passes `offlineIndex: true` to `buildImage` and `--containerfile-module=` + to use a custom Containerfile generator. See + [`packages/cli-v3/src/deploy/buildImage.ts`](../src/deploy/buildImage.ts) — + when `offlineIndex: true`, the generated Containerfile sets + `ARG TRIGGER_INDEX_OFFLINE` and forwards it as a build arg into the + indexer stage; the final stage copies `/app/index-metadata.json` into + the runtime image at `/app/`. + +The metadata gets baked into the runtime image, then re-played against +the real webapp API later by a separate **register** process running +inside the cluster (where it _can_ reach the webapp). The register +process uses the same image, reads `/app/index-metadata.json`, hits the +real webapp API via `getProjectClient`, and finalizes the deployment. + +## Operator responsibilities + +- Provide a `containerfileModule` if the default Containerfile doesn't fit + (e.g. base on UBI / chainguard / a FIPS-validated runtime). The module's + job is to emit the multi-stage Containerfile text that invokes the + indexer stage with `TRIGGER_INDEX_OFFLINE=1` and copies + `/app/index-metadata.json` into the final image. +- Run your `build.mjs` equivalent on the build host (no API access + required). The output is just the tasks image pushed to your registry. +- Run your `register.mjs` equivalent on the deploy host (Kubernetes Job / + systemd unit / etc.) using **the same image**. It reads + `/app/index-metadata.json` and re-issues `createDeploymentBackgroundWorker` + against the live webapp. + +## Caveats + +- Project env vars are **not** available at build time in offline mode. + Any task whose indexing step depends on a project env var will index + with `{}`. If your tasks need env vars at index time, use the online + flow. +- The offline shim returns synthetic `id: "offline"` / `version: "offline"` + from `createDeploymentBackgroundWorker`. Anything in your build pipeline + reading those fields must be aware that the real id/version is assigned + later, by the register step. +- `TRIGGER_INDEX_OFFLINE=1` only affects the indexer stage. The rest of + the runtime (the tasks themselves) still talks to the live API at + runtime as normal. diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index c3e1641ade1..c05414257c1 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -39,6 +39,7 @@ export type BuildWorkerOptions = { rewritePaths?: boolean; forcedExternals?: string[]; plain?: boolean; + containerfileModule?: string; }; export async function buildWorker(options: BuildWorkerOptions) { @@ -137,6 +138,7 @@ export async function buildWorker(options: BuildWorkerOptions) { resolvedConfig, outputPath: options.destination, bundleResult, + containerfileModule: options.containerfileModule, }); } @@ -196,11 +198,13 @@ async function writeDeployFiles({ resolvedConfig, outputPath, bundleResult, + containerfileModule, }: { buildManifest: BuildManifest; resolvedConfig: ResolvedConfig; outputPath: string; bundleResult: BundleResult; + containerfileModule?: string; }) { // Step 1. Read the package.json file const packageJson = await readProjectPackageJson(resolvedConfig.packageJsonPath); @@ -237,7 +241,7 @@ async function writeDeployFiles({ ); await writeJSONFile(join(outputPath, "build.json"), buildManifestToJSON(buildManifest)); - await writeContainerfile(outputPath, buildManifest); + await writeContainerfile(outputPath, buildManifest, containerfileModule); } async function readProjectPackageJson(packageJsonPath: string) { @@ -246,7 +250,11 @@ async function readProjectPackageJson(packageJsonPath: string) { return packageJson; } -async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) { +async function writeContainerfile( + outputPath: string, + buildManifest: BuildManifest, + containerfileModule?: string +) { if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) { throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]"); } @@ -257,6 +265,7 @@ async function writeContainerfile(outputPath: string, buildManifest: BuildManife build: buildManifest.build, image: buildManifest.image, indexScript: buildManifest.indexControllerEntryPoint, + containerfileModule, }); const containerfilePath = join(outputPath, "Containerfile"); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index aa8285a7c3e..e5c8a13e1a9 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -3,7 +3,7 @@ import { depot } from "@depot/cli"; import { x } from "tinyexec"; import { BuildManifest, BuildRuntime } from "@trigger.dev/core/v3/schemas"; import { networkInterfaces } from "os"; -import { join } from "path"; +import { join, resolve } from "path"; import { safeReadJSONFile } from "../utilities/fileSystem.js"; import { readFileSync } from "fs"; @@ -12,6 +12,8 @@ import { z } from "zod"; import { assertExhaustive } from "../utilities/assertExhaustive.js"; import { tryCatch } from "@trigger.dev/core"; import { CliApiClient } from "../apiClient.js"; +import { pathToFileURL } from "url"; +import type { ContainerfileTemplate } from "./containerfile-template.js"; export interface BuildImageOptions { // Common options @@ -46,9 +48,10 @@ export interface BuildImageOptions { extraCACerts?: string; apiUrl: string; apiKey: string; - apiClient: CliApiClient; + apiClient?: CliApiClient; branchName?: string; buildEnvVars?: Record; + offlineIndex?: boolean; // When true, skip API-based indexing in the container onLog?: (log: string) => void; // Optional deployment spinner @@ -81,6 +84,7 @@ export async function buildImage(options: BuildImageOptions): Promise; + offlineIndex?: boolean; network?: string; builder: string; load?: boolean; @@ -487,6 +493,14 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise = { @@ -696,7 +712,44 @@ const BASE_IMAGE: Record = { const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; +async function loadContainerfileModule(modulePath: string): Promise { + const absolutePath = resolve(modulePath); + + try { + // Convert to file URL for proper ESM import + const moduleUrl = pathToFileURL(absolutePath).href; + + // Dynamic import of the module + const module = await import(moduleUrl); + + // Return the default export + if (!module.default) { + throw new Error(`Module ${modulePath} does not have a default export`); + } + + return module.default; + } catch (error) { + logger.error(`Failed to load containerfile module from ${modulePath}`, error); + throw new Error(`Failed to load containerfile module: ${error instanceof Error ? error.message : String(error)}`); + } +} + export async function generateContainerfile(options: GenerateContainerfileOptions) { + // If a custom module is specified, use it + if (options.containerfileModule) { + try { + const template = await loadContainerfileModule(options.containerfileModule); + + // Pass the full options directly to the template for complete control + const containerfile = await template.generate(options); + return containerfile; + } catch (error) { + logger.error("Failed to generate containerfile from module", error); + throw error; + } + } + + // Fall back to built-in templates switch (options.runtime) { case "node": case "node-22": { @@ -788,6 +841,7 @@ ARG NODE_EXTRA_CA_CERTS ARG TRIGGER_SECRET_KEY ARG TRIGGER_API_URL ARG TRIGGER_PREVIEW_BRANCH +ARG TRIGGER_INDEX_OFFLINE ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \ @@ -798,6 +852,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_API_URL=\${TRIGGER_API_URL} \ TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ + TRIGGER_INDEX_OFFLINE=\${TRIGGER_INDEX_OFFLINE} \ NODE_ENV=production ARG TARGETPLATFORM @@ -896,6 +951,7 @@ ARG NODE_EXTRA_CA_CERTS ARG TRIGGER_SECRET_KEY ARG TRIGGER_API_URL ARG TRIGGER_PREVIEW_BRANCH +ARG TRIGGER_INDEX_OFFLINE ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \ @@ -907,6 +963,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \ TRIGGER_LOG_LEVEL=debug \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ + TRIGGER_INDEX_OFFLINE=\${TRIGGER_INDEX_OFFLINE} \ NODE_ENV=production \ NODE_OPTIONS="--max_old_space_size=8192" @@ -938,8 +995,11 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ # Copy the files from the install stage COPY --from=build --chown=node:node /app ./ -# Copy the index.json file from the indexer stage +# Copy the index.json file from the indexer stage if it exists COPY --from=indexer --chown=node:node /app/index.json ./ +COPY --from=indexer --chown=node:node /app/index-metadata.json ./ +# index-error.json is optional - it only exists if indexing failed +COPY --from=indexer --chown=node:node /app/index-error.json* ./ ENTRYPOINT [ "dumb-init", "node", "${options.entrypoint}" ] CMD [] diff --git a/packages/cli-v3/src/deploy/containerfile-template.ts b/packages/cli-v3/src/deploy/containerfile-template.ts new file mode 100644 index 00000000000..3cbe42fe7d1 --- /dev/null +++ b/packages/cli-v3/src/deploy/containerfile-template.ts @@ -0,0 +1,5 @@ +import type { GenerateContainerfileOptions } from "./buildImage.js"; + +export interface ContainerfileTemplate { + generate(options: GenerateContainerfileOptions): Promise | string; +} diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts index 21aa3d829d2..c41b8b5e07b 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts @@ -19,9 +19,40 @@ async function loadBuildManifest() { return BuildManifest.parse(raw); } -async function bootstrap() { +type OnlineBootstrap = { + buildManifest: BuildManifest; + cliApiClient: CliApiClient; + projectRef: string; + deploymentId: string; +}; + +type OfflineBootstrap = { + buildManifest: BuildManifest; + cliApiClient: CliApiClient; + // Fields that don't apply in offline mode but need to exist on the union + // so indexDeployment can keep upstream's destructured signature. + projectRef?: undefined; + deploymentId?: undefined; +}; + +type BootstrapResult = OnlineBootstrap | OfflineBootstrap; + +async function bootstrap(): Promise { const buildManifest = await loadBuildManifest(); + // Offline mode (TRIGGER_INDEX_OFFLINE=1): swap in a CliApiClient shim that + // writes the same payloads to disk that the real client would have sent + // over the wire. The build container has no project ref / deployment id + // in this mode — the shim doesn't read them — so we don't fake them here; + // the call site below provides placeholder values for indexDeployment's + // upstream-shaped signature. + if (env.TRIGGER_INDEX_OFFLINE === "1") { + return { + buildManifest, + cliApiClient: createOfflineCliApiClient(), + }; + } + if (typeof env.TRIGGER_API_URL !== "string") { console.error("TRIGGER_API_URL is not set"); process.exit(1); @@ -51,8 +82,6 @@ async function bootstrap() { }; } -type BootstrapResult = Awaited>; - async function indexDeployment({ cliApiClient, projectRef, @@ -63,7 +92,7 @@ async function indexDeployment({ const stderr: string[] = []; try { - const $env = await cliApiClient.getEnvironmentVariables(projectRef); + const $env = await cliApiClient.getEnvironmentVariables(projectRef!); if (!$env.success) { throw new Error(`Failed to fetch environment variables: ${$env.error}`); @@ -117,7 +146,7 @@ async function indexDeployment({ }; const createResponse = await cliApiClient.createDeploymentBackgroundWorker( - deploymentId, + deploymentId!, backgroundWorkerBody ); @@ -147,12 +176,71 @@ async function indexDeployment({ console.error("Failed to index deployment", serialiedIndexError); - await cliApiClient.failDeployment(deploymentId, { error: serialiedIndexError }); + await cliApiClient.failDeployment(deploymentId!, { error: serialiedIndexError }); process.exit(1); } } +/** + * Stub `CliApiClient` for offline indexing (TRIGGER_INDEX_OFFLINE=1). + * + * indexDeployment makes three calls on the API client: + * + * 1. `getEnvironmentVariables(projectRef)` — returns an empty `variables` + * map. The build container has no API access in offline mode, so we + * can't fetch project env vars; the indexer runs with `{}`. + * 2. `createDeploymentBackgroundWorker(deploymentId, body)` — writes the + * flattened body to `index-metadata.json`. Downstream tooling (e.g. + * a register-only Job in the cluster) re-issues this payload to the + * real API. + * 3. `failDeployment(deploymentId, body)` — writes the error to + * `index-error.json`. + * + * The multi-stage Containerfile copies these files into the final image so + * downstream tooling reads them out of the runtime image. + * + * Cast through `unknown` because `CliApiClient` is a concrete class with + * private fields and methods we don't need to stub. indexDeployment only + * touches the three methods above. + */ +function createOfflineCliApiClient(): CliApiClient { + return { + async getEnvironmentVariables() { + return { success: true as const, data: { variables: {} as Record } }; + }, + async createDeploymentBackgroundWorker( + _deploymentId: string, + body: CreateBackgroundWorkerRequestBody + ) { + const indexMetadata = { + ...body.metadata, + buildPlatform: body.buildPlatform, + targetPlatform: body.targetPlatform, + }; + await writeFile( + join(process.cwd(), "index-metadata.json"), + JSON.stringify(indexMetadata, null, 2) + ); + return { + success: true as const, + data: { + id: "offline", + version: "offline", + contentHash: body.metadata.contentHash, + }, + }; + }, + async failDeployment(_deploymentId: string, body: { error: unknown }) { + await writeFile( + join(process.cwd(), "index-error.json"), + JSON.stringify(body, null, 2) + ); + return { success: true as const, data: { id: "offline" } }; + }, + } as unknown as CliApiClient; +} + const results = await bootstrap(); await indexDeployment(results);