feat(deploy): store local-bundle build env vars encrypted on the deployment
Replaces the trigger-build-args.json file and generated .dockerignore: the bundle artifact is now secret-free. Build-arg values are sent with the init request, stored aes-256-gcm encrypted in a new WorkerDeployment.buildEnvVars column, and cleared on every terminal status transition. - new dedicated GET /api/v1/deployments/:id/build-env-vars endpoint, used by the from-bundle build in attach mode; returns an empty record for terminal deployments and never 500s on a bad envelope - size limits enforced server-side and pre-checked client-side (128 KiB serialized, 200 keys) - version-skew guard: the CLI hard-errors when it sent vars and the server did not ack storing them
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { type GetDeploymentBuildEnvVarsResponseBody } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { decryptSecret, EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
|
||||
import { FINAL_DEPLOYMENT_STATUSES } from "~/v3/services/failDeployment.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
// Returns the decrypted build-time env vars stored on a fromBundle deployment.
|
||||
// Deliberately separate from the main GET deployment endpoint: this is secret
|
||||
// material, and a dedicated route keeps access explicit and auditable. The vars
|
||||
// are cleared when the deployment reaches a terminal status, so this only ever
|
||||
// serves the active build window.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const { deploymentId } = parsedParams.data;
|
||||
|
||||
const deployment = await prisma.workerDeployment.findFirst({
|
||||
where: {
|
||||
friendlyId: deploymentId,
|
||||
environmentId: authenticatedEnv.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
buildEnvVars: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
return json({ error: "Deployment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
logger.info("Build env vars read", {
|
||||
deploymentId,
|
||||
environmentId: authenticatedEnv.id,
|
||||
projectId: authenticatedEnv.projectId,
|
||||
status: deployment.status,
|
||||
hasVars: deployment.buildEnvVars !== null,
|
||||
});
|
||||
|
||||
// Terminal deployments have their vars cleared; even if a clear is still in
|
||||
// flight, never serve secrets for a build that is no longer active.
|
||||
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
|
||||
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
if (!deployment.buildEnvVars) {
|
||||
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = EncryptedSecretValueSchema.safeParse(deployment.buildEnvVars);
|
||||
|
||||
if (!envelope.success) {
|
||||
logger.error("Stored build env vars are not a valid encrypted envelope", {
|
||||
deploymentId,
|
||||
environmentId: authenticatedEnv.id,
|
||||
});
|
||||
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
let variables: Record<string, string>;
|
||||
|
||||
try {
|
||||
const decrypted = await decryptSecret(env.ENCRYPTION_KEY, envelope.data);
|
||||
variables = z.record(z.string()).parse(JSON.parse(decrypted));
|
||||
} catch (error) {
|
||||
logger.error("Failed to decrypt stored build env vars", {
|
||||
deploymentId,
|
||||
environmentId: authenticatedEnv.id,
|
||||
error,
|
||||
});
|
||||
return json({ variables: {} } satisfies GetDeploymentBuildEnvVarsResponseBody, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
return json({ variables } satisfies GetDeploymentBuildEnvVarsResponseBody, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof Response) throw error;
|
||||
logger.error("Failed to load deployment build env vars", { error });
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
|
||||
eventStream: result.eventStream,
|
||||
canceledDeployments: result.canceledDeployments,
|
||||
// Only ack when we actually stored vars; older CLIs ignore this field.
|
||||
...(result.buildEnvVarsStored ? { buildEnvVarsStored: true } : {}),
|
||||
}
|
||||
: { isPromoted: result.isPromoted }),
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
|
||||
import { logger, tryCatch } from "@trigger.dev/core/v3";
|
||||
import type {
|
||||
BackgroundWorker,
|
||||
PrismaClientOrTransaction,
|
||||
WorkerDeployment,
|
||||
import {
|
||||
Prisma,
|
||||
type BackgroundWorker,
|
||||
type PrismaClientOrTransaction,
|
||||
type WorkerDeployment,
|
||||
} from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { type TaskMetadataCache } from "~/services/taskMetadataCache.server";
|
||||
@@ -313,6 +314,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
},
|
||||
// Build env vars only live for the active build window
|
||||
buildEnvVars: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
|
||||
import { type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database";
|
||||
import { Prisma, type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database";
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
logger,
|
||||
@@ -227,6 +227,8 @@ export class DeploymentService extends BaseService {
|
||||
status: "CANCELED",
|
||||
canceledAt: new Date(),
|
||||
canceledReason: data?.canceledReason,
|
||||
// Build env vars only live for the active build window
|
||||
buildEnvVars: Prisma.DbNull,
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { type WorkerDeploymentStatus } from "@trigger.dev/database";
|
||||
import { Prisma, type WorkerDeploymentStatus } from "@trigger.dev/database";
|
||||
import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { DeploymentService } from "./deployment.server";
|
||||
@@ -49,6 +49,8 @@ export class FailDeploymentService extends BaseService {
|
||||
status: "FAILED",
|
||||
failedAt: new Date(),
|
||||
errorData: params.error,
|
||||
// Build env vars only live for the active build window
|
||||
buildEnvVars: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { updateEnvConcurrencyLimits } from "../runQueue.server";
|
||||
@@ -76,6 +77,8 @@ export class FinalizeDeploymentService extends BaseService {
|
||||
deployedAt: new Date(),
|
||||
// Only add the digest, if any
|
||||
imageReference: imageDigest ? `${deployment.imageReference}@${imageDigest}` : undefined,
|
||||
// Build env vars only live for the active build window
|
||||
buildEnvVars: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { env } from "~/env.server";
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { encryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server";
|
||||
@@ -29,6 +30,11 @@ import { errAsync } from "neverthrow";
|
||||
|
||||
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
|
||||
|
||||
// Limits for fromBundle build env vars — they expand into --build-arg values, so
|
||||
// keep them well under exec argv limits while staying generous for env vars.
|
||||
const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024;
|
||||
const BUILD_ENV_VARS_MAX_KEYS = 200;
|
||||
|
||||
type DeploymentEventStream = {
|
||||
s2: {
|
||||
basin: string;
|
||||
@@ -44,6 +50,7 @@ export type InitializeDeploymentResult =
|
||||
imageRef: string;
|
||||
eventStream?: DeploymentEventStream;
|
||||
canceledDeployments?: SupersededDeployment[];
|
||||
buildEnvVarsStored?: boolean;
|
||||
}
|
||||
| {
|
||||
outcome: "existing";
|
||||
@@ -103,6 +110,7 @@ export class InitializeDeploymentService extends BaseService {
|
||||
outcome: "created",
|
||||
deployment: existingDeployment,
|
||||
imageRef: existingDeployment.imageReference ?? "",
|
||||
buildEnvVarsStored: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,6 +276,36 @@ export class InitializeDeploymentService extends BaseService {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Encrypt fromBundle build env vars for storage on the deployment row. Only
|
||||
// meaningful for pre-bundled deploys; cleared on every terminal transition.
|
||||
let encryptedBuildEnvVars: Awaited<ReturnType<typeof encryptSecret>> | undefined;
|
||||
|
||||
if (
|
||||
payload.isNativeBuild &&
|
||||
payload.fromBundle &&
|
||||
payload.buildEnvVars &&
|
||||
Object.keys(payload.buildEnvVars).length > 0
|
||||
) {
|
||||
const buildEnvVars = payload.buildEnvVars;
|
||||
|
||||
const keyCount = Object.keys(buildEnvVars).length;
|
||||
if (keyCount > BUILD_ENV_VARS_MAX_KEYS) {
|
||||
throw new ServiceValidationError(
|
||||
`Too many build environment variables: ${keyCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).`
|
||||
);
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(buildEnvVars);
|
||||
const serializedBytes = Buffer.byteLength(serialized, "utf8");
|
||||
if (serializedBytes > BUILD_ENV_VARS_MAX_BYTES) {
|
||||
throw new ServiceValidationError(
|
||||
`Build environment variables are too large: ${serializedBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.`
|
||||
);
|
||||
}
|
||||
|
||||
encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized);
|
||||
}
|
||||
|
||||
const buildServerMetadata: BuildServerMetadata | undefined =
|
||||
payload.isNativeBuild || payload.buildId
|
||||
? {
|
||||
@@ -344,6 +382,7 @@ export class InitializeDeploymentService extends BaseService {
|
||||
projectId: environment.projectId,
|
||||
externalBuildData,
|
||||
buildServerMetadata,
|
||||
buildEnvVars: encryptedBuildEnvVars,
|
||||
triggeredById: triggeredBy?.id,
|
||||
type: payload.type,
|
||||
imageReference: imageRef,
|
||||
@@ -411,6 +450,7 @@ export class InitializeDeploymentService extends BaseService {
|
||||
imageRef: deployment.imageReference ?? "",
|
||||
eventStream,
|
||||
canceledDeployments,
|
||||
buildEnvVarsStored: encryptedBuildEnvVars !== undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { commonWorker } from "../commonWorker.server";
|
||||
@@ -45,6 +46,8 @@ export class TimeoutDeploymentService extends BaseService {
|
||||
status: "TIMED_OUT",
|
||||
failedAt: new Date(),
|
||||
errorData: { message: errorMessage, name: "TimeoutError" },
|
||||
// Build env vars only live for the active build window
|
||||
buildEnvVars: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN IF NOT EXISTS "buildEnvVars" JSONB;
|
||||
@@ -2271,6 +2271,10 @@ model WorkerDeployment {
|
||||
|
||||
externalBuildData Json?
|
||||
buildServerMetadata Json?
|
||||
/// Encrypted build-time env vars for pre-bundled (fromBundle) deploys — an
|
||||
/// EncryptedSecretValue envelope of a JSON record. Cleared when the deployment
|
||||
/// reaches a terminal status; only ever exists for the active build window.
|
||||
buildEnvVars Json?
|
||||
|
||||
status WorkerDeploymentStatus @default(PENDING)
|
||||
type WorkerDeploymentType @default(V1)
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DevDisconnectResponseBody,
|
||||
EnvironmentVariableResponseBody,
|
||||
FailDeploymentResponseBody,
|
||||
GetDeploymentBuildEnvVarsResponseBody,
|
||||
GetDeploymentResponseBody,
|
||||
GetEnvironmentVariablesResponseBody,
|
||||
GetLatestDeploymentResponseBody,
|
||||
@@ -689,6 +690,20 @@ export class CliApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getDeploymentBuildEnvVars(deploymentId: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("getDeploymentBuildEnvVars: No access token");
|
||||
}
|
||||
|
||||
return wrapZodFetch(
|
||||
GetDeploymentBuildEnvVarsResponseBody,
|
||||
`${this.apiURL}/api/v1/deployments/${deploymentId}/build-env-vars`,
|
||||
{
|
||||
headers: this.getHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) {
|
||||
if (!this.accessToken) {
|
||||
return { success: true as const, data: { notification: null } };
|
||||
|
||||
@@ -26,7 +26,7 @@ import { resolveAlwaysExternal } from "../build/externals.js";
|
||||
import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js";
|
||||
import { createBundleArchive } from "../deploy/bundleArchive.js";
|
||||
import { S2 } from "@s2-dev/streamstore";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, unlink } from "node:fs/promises";
|
||||
import {
|
||||
CommonCommandOptions,
|
||||
commonOptions,
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
prettyWarning,
|
||||
} from "../utilities/cliOutput.js";
|
||||
import { loadDotEnvVars } from "../utilities/dotEnv.js";
|
||||
import { isDirectory, writeJSONFile } from "../utilities/fileSystem.js";
|
||||
import { isDirectory } from "../utilities/fileSystem.js";
|
||||
import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js";
|
||||
import { createGitMeta, isGitHubActions } from "../utilities/gitMeta.js";
|
||||
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
|
||||
@@ -105,12 +105,12 @@ type DeployCommandOptions = z.infer<typeof DeployCommandOptions>;
|
||||
|
||||
type Deployment = InitializeDeploymentResponseBody;
|
||||
|
||||
// Carries the build-arg VALUES for the `ARG` lines in the generated Containerfile.
|
||||
// They only exist in the in-memory build manifest (build.json is deliberately scrubbed
|
||||
// because it gets COPY'd into the image), so --local-bundle writes them to this file
|
||||
// and --from-bundle reads them back. A .dockerignore entry keeps the file out of the
|
||||
// image COPY context so the values never land in image layers.
|
||||
const BUNDLE_BUILD_ARGS_FILE = "trigger-build-args.json";
|
||||
// Limits for the build-arg VALUES sent with --local-bundle deploys (they only exist in
|
||||
// the in-memory build manifest — build.json is deliberately scrubbed because it gets
|
||||
// COPY'd into the image). The server enforces the same limits authoritatively; this
|
||||
// pre-check just fails fast with a friendly error before uploading anything.
|
||||
const BUILD_ENV_VARS_MAX_BYTES = 128 * 1024;
|
||||
const BUILD_ENV_VARS_MAX_KEYS = 200;
|
||||
|
||||
export function configureDeployCommand(program: Command) {
|
||||
return (
|
||||
@@ -1305,6 +1305,9 @@ async function handleNativeBuildServerDeploy({
|
||||
// server then runs just the container build from it.
|
||||
let bundleManifest: BuildManifest | undefined;
|
||||
let bundleOutputPath: string | undefined;
|
||||
// Build-arg values for --local-bundle, sent with the init request and stored
|
||||
// encrypted on the deployment (they're scrubbed from build.json).
|
||||
let bundleBuildEnvVars: Record<string, string> | undefined;
|
||||
|
||||
if (options.localBundle) {
|
||||
// The container build runs on the build server with its own fixed settings —
|
||||
@@ -1367,23 +1370,25 @@ async function handleNativeBuildServerDeploy({
|
||||
bundleManifest = buildManifest;
|
||||
bundleOutputPath = destination.path;
|
||||
|
||||
// Persist the build-arg values (scrubbed from build.json) for the build server's
|
||||
// --from-bundle step, and keep them out of the image via .dockerignore.
|
||||
await writeJSONFile(join(destination.path, BUNDLE_BUILD_ARGS_FILE), {
|
||||
env: buildManifest.build.env ?? {},
|
||||
});
|
||||
// The build-arg values (scrubbed from build.json) travel via the deployment
|
||||
// record (sent with the init request, stored encrypted server-side) — never
|
||||
// as a file in the bundle. Pre-check the limits the server enforces.
|
||||
bundleBuildEnvVars = buildManifest.build.env ?? {};
|
||||
|
||||
// Append to a .dockerignore a build extension may have produced, never clobber it.
|
||||
// Our exclusions always go LAST so a pre-existing negation (!file) can't re-include
|
||||
// the build-args file into the image context.
|
||||
const dockerignorePath = join(destination.path, ".dockerignore");
|
||||
const [, existingDockerignore] = await tryCatch(readFile(dockerignorePath, "utf-8"));
|
||||
await writeFile(
|
||||
dockerignorePath,
|
||||
`${
|
||||
existingDockerignore ? existingDockerignore.trimEnd() + "\n" : ""
|
||||
}${BUNDLE_BUILD_ARGS_FILE}\n.dockerignore\n`
|
||||
);
|
||||
const buildEnvVarCount = Object.keys(bundleBuildEnvVars).length;
|
||||
const buildEnvVarBytes = Buffer.byteLength(JSON.stringify(bundleBuildEnvVars), "utf8");
|
||||
|
||||
if (buildEnvVarCount > BUILD_ENV_VARS_MAX_KEYS) {
|
||||
throw new Error(
|
||||
`Your build uses too many build environment variables: ${buildEnvVarCount} (max ${BUILD_ENV_VARS_MAX_KEYS}).`
|
||||
);
|
||||
}
|
||||
|
||||
if (buildEnvVarBytes > BUILD_ENV_VARS_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Your build environment variables are too large: ${buildEnvVarBytes} bytes (max ${BUILD_ENV_VARS_MAX_BYTES}). Reduce the size of the env var values used by your build.`
|
||||
);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
logger.info(`Dry run complete. View the built bundle at ${destination.path}`);
|
||||
@@ -1522,6 +1527,10 @@ async function handleNativeBuildServerDeploy({
|
||||
externalId: options.externalId,
|
||||
force: options.force,
|
||||
fromBundle: options.localBundle ? true : undefined,
|
||||
buildEnvVars:
|
||||
options.localBundle && bundleBuildEnvVars && Object.keys(bundleBuildEnvVars).length > 0
|
||||
? bundleBuildEnvVars
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!initializeDeploymentResult.success) {
|
||||
@@ -1532,6 +1541,26 @@ async function handleNativeBuildServerDeploy({
|
||||
|
||||
const deployment = initializeDeploymentResult.data;
|
||||
|
||||
// Version-skew guard: an older server silently strips unknown fields, so if we sent
|
||||
// build env vars and the server didn't ack storing them, the remote build would run
|
||||
// without them and fail in a confusing way. Fail fast instead.
|
||||
if (
|
||||
options.localBundle &&
|
||||
bundleBuildEnvVars &&
|
||||
Object.keys(bundleBuildEnvVars).length > 0 &&
|
||||
!deployment.buildEnvVarsStored
|
||||
) {
|
||||
$deploymentSpinner.stop("Failed to initialize deployment");
|
||||
log.error(
|
||||
chalk.bold(
|
||||
chalkError(
|
||||
"This server does not support --local-bundle deploys with build environment variables yet. Deploy without --local-bundle instead."
|
||||
)
|
||||
)
|
||||
);
|
||||
throw new OutroCommandError(`Deployment failed`);
|
||||
}
|
||||
|
||||
const rawDeploymentLink = `${dashboardUrl}/projects/v3/${config.project}/deployments/${deployment.shortCode}`;
|
||||
const rawTestLink = `${dashboardUrl}/projects/v3/${config.project}/test?environment=${
|
||||
options.env === "prod" ? "prod" : "stg"
|
||||
@@ -1862,8 +1891,8 @@ export function verifyDirectory(dir: string, projectPath: string) {
|
||||
// the bundling step, as produced by --local-bundle / a dry-run build). Used primarily
|
||||
// by the build server to run ONLY the container build for pre-bundled artifacts, but
|
||||
// also works standalone for local testing. Skips config loading entirely — the bundle
|
||||
// has no trigger.config.ts source; everything needed comes from the bundle's build.json,
|
||||
// the build-args file, and the deployment record.
|
||||
// has no trigger.config.ts source; everything needed comes from the bundle's build.json
|
||||
// and the deployment record (including the build-arg values, stored encrypted there).
|
||||
async function handleFromBundleDeploy({
|
||||
bundleDir,
|
||||
options,
|
||||
@@ -1910,26 +1939,6 @@ async function handleFromBundleDeploy({
|
||||
|
||||
const bundleManifest = manifestResult.data;
|
||||
|
||||
// Recover the build-arg values scrubbed from build.json (written by --local-bundle).
|
||||
// Optional: bundles without build-time env vars may not carry the file.
|
||||
let buildEnvVars: Record<string, string> | undefined;
|
||||
const [buildArgsError, buildArgsRaw] = await tryCatch(
|
||||
readFile(join(bundlePath, BUNDLE_BUILD_ARGS_FILE), "utf-8")
|
||||
);
|
||||
|
||||
if (!buildArgsError) {
|
||||
let parsed: { env?: Record<string, string> };
|
||||
try {
|
||||
parsed = JSON.parse(buildArgsRaw);
|
||||
} catch {
|
||||
throw new Error(`Invalid ${BUNDLE_BUILD_ARGS_FILE} in the bundle directory`);
|
||||
}
|
||||
buildEnvVars = (typeof parsed === "object" && parsed !== null ? parsed.env : undefined) ?? {};
|
||||
} else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) {
|
||||
// The scrubbed manifest can't carry values, but if a manifest somehow has them, use them.
|
||||
buildEnvVars = bundleManifest.build.env;
|
||||
}
|
||||
|
||||
const projectRef = projectRefOverride ?? bundleManifest.config.project;
|
||||
|
||||
const branch = options.env === "preview" ? getBranch({ specified: options.branch }) : undefined;
|
||||
@@ -1965,11 +1974,34 @@ async function handleFromBundleDeploy({
|
||||
throw new Error("Failed to get project client");
|
||||
}
|
||||
|
||||
// Recover the build-arg values scrubbed from build.json. In attach mode they were
|
||||
// stored encrypted on the deployment by --local-bundle's init request; fetch them
|
||||
// through the dedicated endpoint. An empty record is normal for builds that use no
|
||||
// build-time env vars.
|
||||
let buildEnvVars: Record<string, string> | undefined;
|
||||
|
||||
if (existingDeploymentId) {
|
||||
const buildEnvVarsResult =
|
||||
await projectClient.client.getDeploymentBuildEnvVars(existingDeploymentId);
|
||||
|
||||
if (!buildEnvVarsResult.success) {
|
||||
throw new Error(
|
||||
`Failed to fetch the build environment variables for deployment ${existingDeploymentId}: ${buildEnvVarsResult.error}`
|
||||
);
|
||||
}
|
||||
|
||||
buildEnvVars = buildEnvVarsResult.data.variables;
|
||||
} else if (bundleManifest.build.env && Object.keys(bundleManifest.build.env).length > 0) {
|
||||
// The scrubbed manifest can't carry values, but if a manifest somehow has them, use them.
|
||||
buildEnvVars = bundleManifest.build.env;
|
||||
}
|
||||
|
||||
if (!existingDeploymentId) {
|
||||
// The supported flow is attach mode (the build server sets
|
||||
// TRIGGER_EXISTING_DEPLOYMENT_ID). Fresh-init from a bundle is equivalent to a
|
||||
// plain local build and mainly useful for local testing — warn so nobody relies
|
||||
// on it against cloud by accident.
|
||||
// on it against cloud by accident. There are no stored build env vars on this
|
||||
// path; the build proceeds without them.
|
||||
logger.warn(
|
||||
"No existing deployment to attach to — initializing a fresh local-build deployment from the bundle. This path is intended for testing."
|
||||
);
|
||||
|
||||
@@ -25,8 +25,8 @@ describe("createBundleArchive", () => {
|
||||
await writeFile(join(bundleDir, "Containerfile"), "FROM scratch");
|
||||
await writeFile(join(bundleDir, "package.json"), "{}");
|
||||
await writeFile(join(bundleDir, "index.mjs"), "export {}");
|
||||
await writeFile(join(bundleDir, ".dockerignore"), "trigger-build-args.json\n");
|
||||
await writeFile(join(bundleDir, "trigger-build-args.json"), JSON.stringify({ env: {} }));
|
||||
// A build extension may produce a .dockerignore — it must survive archiving
|
||||
await writeFile(join(bundleDir, ".dockerignore"), "*.log\n");
|
||||
await mkdir(join(bundleDir, ".trigger", "skills", "my-skill"), { recursive: true });
|
||||
await writeFile(join(bundleDir, ".trigger", "skills", "my-skill", "SKILL.md"), "# skill");
|
||||
|
||||
@@ -48,7 +48,6 @@ describe("createBundleArchive", () => {
|
||||
"build.json",
|
||||
"index.mjs",
|
||||
"package.json",
|
||||
"trigger-build-args.json",
|
||||
].sort()
|
||||
);
|
||||
|
||||
|
||||
@@ -758,6 +758,9 @@ export const InitializeDeploymentResponseBody = z.object({
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
// Ack that the server accepted and stored buildEnvVars from the request. The CLI
|
||||
// treats its absence (older server) as a hard error when it sent non-empty vars.
|
||||
buildEnvVarsStored: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type InitializeDeploymentResponseBody = z.infer<typeof InitializeDeploymentResponseBody>;
|
||||
@@ -786,6 +789,7 @@ type NativeBuildOutput = BaseOutput & {
|
||||
configFilePath?: string;
|
||||
skipEnqueue?: boolean;
|
||||
fromBundle?: boolean;
|
||||
buildEnvVars?: Record<string, string>;
|
||||
};
|
||||
|
||||
type NonNativeBuildOutput = BaseOutput & {
|
||||
@@ -795,6 +799,7 @@ type NonNativeBuildOutput = BaseOutput & {
|
||||
configFilePath?: never;
|
||||
skipEnqueue?: never;
|
||||
fromBundle?: never;
|
||||
buildEnvVars?: never;
|
||||
};
|
||||
|
||||
const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({
|
||||
@@ -806,6 +811,9 @@ const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.
|
||||
// The uploaded artifact is a pre-built bundle (local install + bundle already done);
|
||||
// the build server should skip install/bundle and only run the container build.
|
||||
fromBundle: z.boolean().optional(),
|
||||
// Build-time env var values for fromBundle deploys. Stored encrypted on the
|
||||
// deployment and cleared once the deployment reaches a terminal status.
|
||||
buildEnvVars: z.record(z.string()).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.force && !data.externalId) {
|
||||
ctx.addIssue({
|
||||
@@ -821,7 +829,15 @@ export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFu
|
||||
if (data.isNativeBuild) {
|
||||
return { ...data, isNativeBuild: true as const };
|
||||
}
|
||||
const { skipPromotion, artifactKey, configFilePath, skipEnqueue, fromBundle, ...rest } = data;
|
||||
const {
|
||||
skipPromotion,
|
||||
artifactKey,
|
||||
configFilePath,
|
||||
skipEnqueue,
|
||||
fromBundle,
|
||||
buildEnvVars,
|
||||
...rest
|
||||
} = data;
|
||||
return { ...rest, isNativeBuild: false as const };
|
||||
}
|
||||
);
|
||||
@@ -927,6 +943,16 @@ export const GetDeploymentResponseBody = z.object({
|
||||
|
||||
export type GetDeploymentResponseBody = z.infer<typeof GetDeploymentResponseBody>;
|
||||
|
||||
// Response of the dedicated build-env-vars endpoint (secret material — deliberately
|
||||
// kept off GetDeploymentResponseBody). Empty record when none were stored.
|
||||
export const GetDeploymentBuildEnvVarsResponseBody = z.object({
|
||||
variables: z.record(z.string()),
|
||||
});
|
||||
|
||||
export type GetDeploymentBuildEnvVarsResponseBody = z.infer<
|
||||
typeof GetDeploymentBuildEnvVarsResponseBody
|
||||
>;
|
||||
|
||||
export const GetLatestDeploymentResponseBody = GetDeploymentResponseBody.omit({
|
||||
worker: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user