diff --git a/.changeset/deploy-build-path-settings.md b/.changeset/deploy-build-path-settings.md new file mode 100644 index 00000000000..e42f9b2e7c5 --- /dev/null +++ b/.changeset/deploy-build-path-settings.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +`trigger.dev deploy` now asks the server whether to build with Depot or the native build server unless `--native-build`, `--depot-build`, or `--local-build` is passed, so the native build server can be rolled out per organization without a CLI change. `--local-bundle` and `--detach` now require `--native-build`. diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts new file mode 100644 index 00000000000..54a805932bf --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.deploy-settings.ts @@ -0,0 +1,70 @@ +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type GetDeploySettingsResponseBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { authenticateApiKeyWithScope } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), + env: z.enum(["dev", "staging", "prod", "preview"]), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + try { + const authResult = await authenticateApiKeyWithScope(request, { + action: "read", + resource: { type: "deployments" }, + }); + + if (!authResult.ok) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: authResult.error }, { status: authResult.status }); + } + + const { environment: authenticatedEnv } = authResult.authentication; + const { projectRef, env } = parsedParams.data; + + const deploymentService = new DeploymentService(); + + return await deploymentService + .getDeploySettings(authenticatedEnv, { projectRef, envSlug: env }) + .match( + ({ buildPath, buildPathSource }) => { + logger.info("Resolved deploy build path", { + environmentId: authenticatedEnv.id, + projectRef, + env, + buildPath, + buildPathSource, + }); + + return json({ build_path: buildPath } satisfies GetDeploySettingsResponseBody); + }, + (error) => { + switch (error.type) { + case "environment_mismatch": + return json( + { error: "API key does not belong to this project environment" }, + { status: 403 } + ); + case "failed_to_load_global_flags": + default: + error.type satisfies "failed_to_load_global_flags"; + logger.error("Failed to load the global feature flags", { error: error.cause }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + ); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to resolve deploy settings", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/services/deploymentApiPaths.server.ts b/apps/webapp/app/services/deploymentApiPaths.server.ts index c044394b244..fcd69a2c265 100644 --- a/apps/webapp/app/services/deploymentApiPaths.server.ts +++ b/apps/webapp/app/services/deploymentApiPaths.server.ts @@ -4,6 +4,7 @@ export const deploymentApiPaths: (RegExp | string)[] = [ // /current is runtime SDK surface, kept out of the deploy budget /^\/api\/v\d+\/deployments(?!\/current$)(\/|$)/, /^\/api\/v1\/projects\/[^/]+\/(dev|staging|prod|preview)$/, + /^\/api\/v1\/projects\/[^/]+\/(dev|staging|prod|preview)\/deploy-settings$/, /^\/api\/v1\/projects\/[^/]+\/envvars$/, /^\/api\/v1\/projects\/[^/]+\/envvars\/[^/]+\/import$/, /^\/api\/v1\/projects\/[^/]+\/branches$/, diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 3a88beb54bc..3dccd5c4cc7 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { DeployBuildPath } from "@trigger.dev/core/v3"; export const FEATURE_FLAG = { defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId", @@ -37,6 +38,11 @@ export const FEATURE_FLAG = { // Fleet-wide pin for the complete cutover. Beats every per-org and per-env pin. runOpsMintShardOverride: "runOpsMintShardOverride", queueMetricsUiEnabled: "queueMetricsUiEnabled", + // Build path for CLI deploys, resolved by DeploymentService.getDeploySettings. + deployBuildPath: "deployBuildPath", + deployBuildPathPreview: "deployBuildPathPreview", + deployBuildPathStaging: "deployBuildPathStaging", + deployBuildPathProduction: "deployBuildPathProduction", // Per-organization rollout for creating additional environment API keys. additionalApiKeysEnabled: "additionalApiKeysEnabled", // System-wide kill switch for issuing additional environment API keys. @@ -148,6 +154,10 @@ export const FeatureFlagCatalog = { // Per-org access to the Queue Metrics dashboard UI (view only; emission is global and // separate). Off unless enabled for the org. [FEATURE_FLAG.queueMetricsUiEnabled]: z.coerce.boolean(), + [FEATURE_FLAG.deployBuildPath]: DeployBuildPath, + [FEATURE_FLAG.deployBuildPathPreview]: DeployBuildPath, + [FEATURE_FLAG.deployBuildPathStaging]: DeployBuildPath, + [FEATURE_FLAG.deployBuildPathProduction]: DeployBuildPath, // Strict booleans prevent a stringified "false" from silently enabling API-key // creation or lookup. Cold/absent values resolve to the safe `false`. [FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(), diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index 0eeaa82b3d6..5729053db79 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -4,16 +4,25 @@ import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; import { Prisma, type WorkerDeployment, type Project, boundedIn } from "@trigger.dev/database"; import { BuildServerMetadata, + DeployBuildPath, logger, type GitMeta, type DeploymentEvent, + type RuntimeEnvironmentType, } from "@trigger.dev/core/v3"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; import { recordDeploymentFinished } from "./recordDeploymentFinished.server"; import { env } from "~/env.server"; import { createRemoteImageBuild } from "../remoteImageBuilder.server"; import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server"; -import { enqueueBuild, generateRegistryCredentials } from "~/services/platform.v3.server"; +import { + enqueueBuild, + generateRegistryCredentials, + isBillingConfigured, +} from "~/services/platform.v3.server"; +import { FEATURE_FLAG, type FeatureFlagKey } from "../featureFlags"; +import { flags } from "../featureFlags.server"; +import { globalFlagsRegistry } from "../globalFlagsRegistry.server"; import { AppendInput, AppendRecord, S2 } from "@s2-dev/streamstore"; import { createRedisClient } from "~/redis.server"; @@ -28,6 +37,31 @@ const s2TokenRedis = createRedisClient("s2-token-cache", { }); const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined; +const DEPLOY_BUILD_PATH_ENV_FLAG: Partial> = { + PREVIEW: FEATURE_FLAG.deployBuildPathPreview, + STAGING: FEATURE_FLAG.deployBuildPathStaging, + PRODUCTION: FEATURE_FLAG.deployBuildPathProduction, +}; + +const DEPLOY_ENV_SLUG_FOR_TYPE: Record = { + DEVELOPMENT: "dev", + STAGING: "staging", + PRODUCTION: "prod", + PREVIEW: "preview", +}; + +type DeployEnvSlug = "dev" | "staging" | "prod" | "preview"; + +type DeployBuildPathSource = + | "unavailable" + | "organization_environment" + | "organization" + | "global_environment" + | "global" + | "default"; + +type DeploySettings = { buildPath: DeployBuildPath; buildPathSource: DeployBuildPathSource }; + export class DeploymentService extends BaseService { /** * Progresses a deployment from PENDING to INSTALLING and then to BUILDING. @@ -282,6 +316,67 @@ export class DeploymentService extends BaseService { .map(() => undefined); } + public getDeploySettings( + authenticatedEnv: Pick, + target: { projectRef: string; envSlug: DeployEnvSlug } + ) { + const validateTarget = (): ResultAsync => { + if ( + authenticatedEnv.project.externalRef !== target.projectRef || + DEPLOY_ENV_SLUG_FOR_TYPE[authenticatedEnv.type] !== target.envSlug + ) { + return errAsync({ type: "environment_mismatch" as const }); + } + return okAsync(undefined); + }; + + const loadGlobalFlags = () => + fromPromise(Promise.resolve(globalFlagsRegistry.current() ?? flags()), (error) => ({ + type: "failed_to_load_global_flags" as const, + cause: error, + })); + + const pickBuildPath = (globalFlagSet: Record): DeploySettings => { + const envKey = DEPLOY_BUILD_PATH_ENV_FLAG[authenticatedEnv.type]; + const orgFlags = authenticatedEnv.organization.featureFlags; + const orgFlagSet: Record = + orgFlags && typeof orgFlags === "object" && !Array.isArray(orgFlags) + ? (orgFlags as Record) + : {}; + + const candidates: Array< + [Record, FeatureFlagKey | undefined, DeployBuildPathSource] + > = [ + [orgFlagSet, envKey, "organization_environment"], + [orgFlagSet, FEATURE_FLAG.deployBuildPath, "organization"], + [globalFlagSet, envKey, "global_environment"], + [globalFlagSet, FEATURE_FLAG.deployBuildPath, "global"], + ]; + + for (const [flagSet, key, buildPathSource] of candidates) { + if (!key) continue; + const parsed = DeployBuildPath.safeParse(flagSet[key]); + if (parsed.success) { + return { buildPath: parsed.data, buildPathSource }; + } + } + + return { buildPath: "depot", buildPathSource: "default" }; + }; + + const resolveBuildPath = (): ResultAsync< + DeploySettings, + { type: "failed_to_load_global_flags"; cause: unknown } + > => { + if (!isBillingConfigured()) { + return okAsync({ buildPath: "depot" as const, buildPathSource: "unavailable" as const }); + } + return loadGlobalFlags().map(pickBuildPath); + }; + + return validateTarget().andThen(resolveBuildPath); + } + /** * Generates registry credentials for a deployment. Returns an error if the deployment is in a final state. * diff --git a/apps/webapp/test/deploySettingsRoute.test.ts b/apps/webapp/test/deploySettingsRoute.test.ts new file mode 100644 index 00000000000..18c46a0cdb5 --- /dev/null +++ b/apps/webapp/test/deploySettingsRoute.test.ts @@ -0,0 +1,121 @@ +import { errAsync, okAsync } from "neverthrow"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticateApiKeyWithScope: vi.fn<(...args: any[]) => Promise>(), + getDeploySettings: vi.fn<(...args: any[]) => any>(), +})); + +vi.mock("~/services/apiAuth.server", () => ({ + authenticateApiKeyWithScope: mocks.authenticateApiKeyWithScope, +})); +vi.mock("~/v3/services/deployment.server", () => ({ + DeploymentService: class { + getDeploySettings = mocks.getDeploySettings; + }, +})); +vi.mock("~/services/logger.server", () => ({ + logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +import { loader } from "~/routes/api.v1.projects.$projectRef.$env.deploy-settings"; + +function environment(overrides: Record = {}) { + return { + id: "env_1", + type: "PRODUCTION", + project: { id: "proj_1", externalRef: "proj_ref" }, + organization: { featureFlags: {} }, + ...overrides, + }; +} + +function load(env = "prod", projectRef = "proj_ref") { + return loader({ + request: new Request( + `https://app.example.com/api/v1/projects/${projectRef}/${env}/deploy-settings` + ), + params: { projectRef, env }, + context: {}, + }); +} + +describe("deploy settings route", () => { + beforeEach(() => { + mocks.authenticateApiKeyWithScope.mockReset(); + mocks.getDeploySettings.mockReset(); + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: true, + authentication: { environment: environment() }, + }); + mocks.getDeploySettings.mockReturnValue( + okAsync({ buildPath: "depot", buildPathSource: "default" }) + ); + }); + + it("rejects an unknown env slug before authenticating", async () => { + const response = await load("nope"); + expect(response.status).toBe(400); + expect(mocks.authenticateApiKeyWithScope).not.toHaveBeenCalled(); + }); + + it("passes the auth failure through", async () => { + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: false, + status: 401, + error: "Invalid API key", + }); + const response = await load(); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Invalid API key" }); + expect(mocks.getDeploySettings).not.toHaveBeenCalled(); + }); + + it("maps an environment mismatch to 403", async () => { + mocks.getDeploySettings.mockReturnValue(errAsync({ type: "environment_mismatch" })); + const response = await load("prod", "proj_other"); + expect(response.status).toBe(403); + expect(mocks.getDeploySettings).toHaveBeenCalledWith(environment(), { + projectRef: "proj_other", + envSlug: "prod", + }); + }); + + it("returns only the build path, resolved for the authenticated environment", async () => { + const env = environment({ type: "PREVIEW" }); + mocks.authenticateApiKeyWithScope.mockResolvedValue({ + ok: true, + authentication: { environment: env }, + }); + mocks.getDeploySettings.mockReturnValue( + okAsync({ buildPath: "native", buildPathSource: "organization_environment" }) + ); + + const response = await load("preview"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ build_path: "native" }); + expect(mocks.getDeploySettings).toHaveBeenCalledWith(env, { + projectRef: "proj_ref", + envSlug: "preview", + }); + expect(mocks.authenticateApiKeyWithScope).toHaveBeenCalledWith(expect.any(Request), { + action: "read", + resource: { type: "deployments" }, + }); + }); + + it("returns 500 when the global flags cannot be loaded", async () => { + mocks.getDeploySettings.mockReturnValue( + errAsync({ type: "failed_to_load_global_flags", cause: new Error("db down") }) + ); + const response = await load(); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "Internal Server Error" }); + }); + + it("rethrows a Response thrown by authentication", async () => { + const thrown = new Response(null, { status: 429 }); + mocks.authenticateApiKeyWithScope.mockRejectedValue(thrown); + await expect(load()).rejects.toBe(thrown); + }); +}); diff --git a/apps/webapp/test/deploymentServiceDeploySettings.test.ts b/apps/webapp/test/deploymentServiceDeploySettings.test.ts new file mode 100644 index 00000000000..3109b8fe0e3 --- /dev/null +++ b/apps/webapp/test/deploymentServiceDeploySettings.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isBillingConfigured: vi.fn<() => boolean>(), + current: vi.fn<() => Record | undefined>(), + flags: vi.fn<() => Promise>>(), +})); + +vi.mock("~/services/platform.v3.server", async (importOriginal) => ({ + ...(await importOriginal()), + isBillingConfigured: mocks.isBillingConfigured, +})); +vi.mock("~/v3/globalFlagsRegistry.server", () => ({ + globalFlagsRegistry: { current: mocks.current }, +})); +vi.mock("~/v3/featureFlags.server", async (importOriginal) => ({ + ...(await importOriginal()), + flags: mocks.flags, +})); + +import { DeploymentService } from "~/v3/services/deployment.server"; + +type EnvType = "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION"; +type EnvSlug = "dev" | "staging" | "prod" | "preview"; + +const SLUG: Record = { + DEVELOPMENT: "dev", + STAGING: "staging", + PRODUCTION: "prod", + PREVIEW: "preview", +}; + +function resolve( + type: EnvType, + orgFeatureFlags: unknown = {}, + target = { projectRef: "proj_ref", envSlug: SLUG[type] } +) { + return new DeploymentService().getDeploySettings( + { + type, + project: { externalRef: "proj_ref" }, + organization: { featureFlags: orgFeatureFlags }, + } as any, + target + ); +} + +async function path(type: EnvType, orgFeatureFlags: unknown = {}) { + const result = await resolve(type, orgFeatureFlags); + if (result.isErr()) throw result.error.cause; + return [result.value.buildPath, result.value.buildPathSource]; +} + +describe("DeploymentService.getDeploySettings", () => { + beforeEach(() => { + mocks.isBillingConfigured.mockReset().mockReturnValue(true); + mocks.current.mockReset().mockReturnValue({}); + mocks.flags.mockReset().mockResolvedValue({}); + }); + + it("rejects a target that is not the key's project or environment type", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + for (const target of [ + { projectRef: "proj_other", envSlug: "prod" as const }, + { projectRef: "proj_ref", envSlug: "staging" as const }, + { projectRef: "proj_ref", envSlug: "preview" as const }, + ]) { + const result = await resolve("PRODUCTION", {}, target); + expect(result.isErr() && result.error).toEqual({ type: "environment_mismatch" }); + } + expect(mocks.flags).not.toHaveBeenCalled(); + }); + + it("accepts every environment type on its own slug", async () => { + for (const type of ["DEVELOPMENT", "PREVIEW", "STAGING", "PRODUCTION"] as const) { + expect(await path(type)).toEqual(["depot", "default"]); + } + }); + + it("is depot when the native build server is unavailable, whatever the flags say", async () => { + mocks.isBillingConfigured.mockReturnValue(false); + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + expect(await path("PRODUCTION", { deployBuildPath: "native" })).toEqual([ + "depot", + "unavailable", + ]); + }); + + it("defaults to depot when nothing is set", async () => { + expect(await path("PRODUCTION")).toEqual(["depot", "default"]); + }); + + it("applies the plain global flag to every environment type", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + for (const type of ["DEVELOPMENT", "PREVIEW", "STAGING", "PRODUCTION"] as const) { + expect(await path(type)).toEqual(["native", "global"]); + } + }); + + it("prefers the global env-type flag over the plain global flag", async () => { + mocks.current.mockReturnValue({ + deployBuildPath: "native", + deployBuildPathProduction: "depot", + }); + expect(await path("PRODUCTION")).toEqual(["depot", "global_environment"]); + expect(await path("STAGING")).toEqual(["native", "global"]); + }); + + it("lets the org plain flag beat every global flag", async () => { + mocks.current.mockReturnValue({ + deployBuildPath: "native", + deployBuildPathProduction: "native", + }); + expect(await path("PRODUCTION", { deployBuildPath: "depot" })).toEqual([ + "depot", + "organization", + ]); + + mocks.current.mockReturnValue({ deployBuildPath: "depot" }); + expect(await path("PRODUCTION", { deployBuildPath: "native" })).toEqual([ + "native", + "organization", + ]); + }); + + it("prefers the org env-type flag over the org plain flag", async () => { + const org = { deployBuildPath: "native", deployBuildPathPreview: "native_local_bundle" }; + expect(await path("PREVIEW", org)).toEqual(["native_local_bundle", "organization_environment"]); + expect(await path("PRODUCTION", org)).toEqual(["native", "organization"]); + }); + + it("never lets another environment type's key leak", async () => { + mocks.current.mockReturnValue({ deployBuildPathStaging: "native" }); + expect(await path("PRODUCTION", { deployBuildPathPreview: "native" })).toEqual([ + "depot", + "default", + ]); + expect(await path("DEVELOPMENT", { deployBuildPathProduction: "native" })).toEqual([ + "depot", + "default", + ]); + }); + + it("skips values the schema rejects instead of treating them as depot", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + expect(await path("PRODUCTION", { deployBuildPathProduction: "bogus" })).toEqual([ + "native", + "global", + ]); + expect( + await path("PRODUCTION", { deployBuildPathProduction: null, deployBuildPath: 1 }) + ).toEqual(["native", "global"]); + }); + + it("tolerates a malformed org flag blob", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + for (const blob of [null, undefined, "native", 42, ["native"]]) { + expect(await path("PRODUCTION", blob)).toEqual(["native", "global"]); + } + }); + + it("reads the registry snapshot without calling flags()", async () => { + mocks.current.mockReturnValue({ deployBuildPath: "native" }); + await path("PRODUCTION"); + expect(mocks.flags).not.toHaveBeenCalled(); + }); + + it("falls back to flags() when the registry is cold", async () => { + mocks.current.mockReturnValue(undefined); + mocks.flags.mockResolvedValue({ deployBuildPath: "native" }); + expect(await path("PRODUCTION")).toEqual(["native", "global"]); + expect(mocks.flags).toHaveBeenCalledTimes(1); + }); + + it("returns an error when the global flags cannot be loaded", async () => { + mocks.current.mockReturnValue(undefined); + mocks.flags.mockRejectedValue(new Error("db down")); + const result = await resolve("PRODUCTION"); + expect(result.isErr()).toBe(true); + expect(result.isErr() && result.error).toMatchObject({ type: "failed_to_load_global_flags" }); + }); +}); diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index a7a07cf40eb..f888d4680e3 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -29,6 +29,7 @@ import { GetLatestDeploymentResponseBody, GetPersonalAccessTokenResponseSchema, GetProjectEnvResponse, + GetDeploySettingsResponseBody, GetProjectResponseBody, GetProjectRuntimesResponseBody, GetProjectsResponseBody, @@ -486,6 +487,19 @@ export class CliApiClient { ); } + async getDeploySettings(projectRef: string, env: string, signal?: AbortSignal) { + return wrapZodFetch( + GetDeploySettingsResponseBody, + `${this.apiURL}/api/v1/projects/${projectRef}/${env}/deploy-settings`, + { + method: "GET", + headers: this.getHeaders(), + signal, + }, + { retry: { maxAttempts: 1 } } + ); + } + async getRemoteBuildProviderStatus() { return wrapZodFetch( RemoteBuildProviderStatusResponseBody, diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 74052486c13..f2661f412d4 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -6,6 +6,7 @@ import { tryCatch, } from "@trigger.dev/core/v3"; import type { + DeployBuildPath, InitializeDeploymentRequestBody, InitializeDeploymentResponseBody, GitMeta, @@ -25,6 +26,11 @@ import { buildWorker } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; import { createContextArchive, getArchiveSize } from "../deploy/archiveContext.js"; import { createBundleArchive } from "../deploy/bundleArchive.js"; +import { + applyBuildPathOptions, + nativeOnlyFlagError, + resolveBuildPath, +} from "../deploy/buildPath.js"; import { S2 } from "@s2-dev/streamstore"; import { mkdir, readFile, unlink } from "node:fs/promises"; import { @@ -91,6 +97,8 @@ const DeployCommandOptions = CommonCommandOptions.extend({ push: z.boolean().optional(), builder: z.string().default("trigger"), nativeBuildServer: z.boolean().default(false), + nativeBuild: z.boolean().default(false), + depotBuild: z.boolean().default(false), localBundle: z.boolean().default(false), fromBundle: z.string().optional(), detach: z.boolean().default(false), @@ -221,13 +229,16 @@ export function configureDeployCommand(program: Command) { .implies({ localBuild: true, }) - .conflicts("nativeBuildServer") + .conflicts(["nativeBuild", "nativeBuildServer", "localBundle", "detach"]) .hideHelp() ) .addOption( - new CommandOption("--local-build", "Build the deployment image locally").conflicts( - "nativeBuildServer" - ) + new CommandOption("--local-build", "Build the deployment image locally").conflicts([ + "nativeBuild", + "nativeBuildServer", + "localBundle", + "detach", + ]) ) .addOption(new CommandOption("--push", "Push the image after local builds").hideHelp()) .addOption( @@ -247,17 +258,32 @@ export function configureDeployCommand(program: Command) { ) .addOption( new CommandOption( - "--native-build-server", - "Use the native build server for building the image" + "--native-build", + "Build the image on the native build server, ignoring the build path configured for this project on the server" ) + .implies({ nativeBuildServer: true }) + .conflicts(["localBuild", "forceLocalBuild", "depotBuild", "fromBundle"]) + ) + .addOption(new CommandOption("--native-build-server", "Alias for --native-build").hideHelp()) + .addOption( + new CommandOption( + "--depot-build", + "Build the image with Depot, ignoring the build path configured for this project on the server" + ).conflicts([ + "nativeBuild", + "nativeBuildServer", + "localBundle", + "localBuild", + "forceLocalBuild", + "fromBundle", + "detach", + ]) ) .addOption( new CommandOption( "--local-bundle", - "Experimental: install and bundle locally, upload only the build output, and build the image remotely. Implies using the native build server." - ) - .implies({ nativeBuildServer: true }) - .conflicts(["localBuild", "forceLocalBuild"]) + "Experimental: install and bundle locally and upload only the build output. Requires --native-build." + ).conflicts(["localBuild", "forceLocalBuild", "depotBuild"]) ) .addOption( new CommandOption( @@ -265,14 +291,14 @@ export function configureDeployCommand(program: Command) { "Internal: build the image from a pre-built bundle directory. Implies a local build." ) .implies({ localBuild: true }) - .conflicts(["nativeBuildServer", "localBundle"]) + .conflicts(["nativeBuild", "nativeBuildServer", "depotBuild", "localBundle", "detach"]) .hideHelp() ) .addOption( new CommandOption( "--detach", - "Return immediately after the deployment is queued, do not wait for the build to complete. Implies using the native build server." - ).implies({ nativeBuildServer: true }) + "Return immediately after the deployment is queued, do not wait for the build to complete. Requires --native-build." + ) ) .addOption(new CommandOption("--plain", "Plain output").hideHelp()) .action(async (path, options) => { @@ -291,6 +317,11 @@ async function deployCommand(dir: string, options: unknown) { } async function _deployCommand(dir: string, options: DeployCommandOptions) { + const nativeOnlyError = nativeOnlyFlagError(options); + if (nativeOnlyError) { + throw new Error(nativeOnlyError); + } + if (options.externalId !== undefined) { options.externalId = options.externalId.trim(); @@ -445,7 +476,18 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { resolvedConfig.runtime = projectClient.defaultRuntime; } - if (options.localBundle) { + const resolvedBuildPath = await resolveServerBuildPath( + projectClient.client, + resolvedConfig.project, + options + ); + const buildPath = applyBuildPathOptions(resolvedBuildPath, options); + + if (options.dryRun && resolvedBuildPath === "native" && buildPath === "depot") { + log.info("Dry run is not supported on the native build server path, bundling locally instead"); + } + + if (buildPath === "native_local_bundle") { await handleLocalBundleDeploy({ apiClient: projectClient.client, config: resolvedConfig, @@ -458,7 +500,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { return; } - if (options.nativeBuildServer) { + if (buildPath === "native") { await handleNativeBuildServerDeploy({ apiClient: projectClient.client, config: resolvedConfig, @@ -1237,6 +1279,39 @@ function getTriggeredVia(): DeploymentTriggeredVia { return "cli:manual"; } +const DEPLOY_SETTINGS_TIMEOUT_MS = 5_000; + +async function resolveServerBuildPath( + apiClient: CliApiClient, + projectRef: string, + options: DeployCommandOptions +): Promise { + const resolved = await resolveBuildPath(options, () => + apiClient.getDeploySettings( + projectRef, + options.env, + AbortSignal.timeout(DEPLOY_SETTINGS_TIMEOUT_MS) + ) + ); + + switch (resolved.from) { + case "flag": + logger.debug(`Build path ${resolved.buildPath} from ${resolved.flag}`); + break; + case "fallback": + logger.debug("Failed to fetch deploy settings", { failure: resolved.failure }); + if (!resolved.silent) { + log.warn("Could not read the deploy settings from the server, using the Depot build path"); + } + break; + case "server": + logger.debug(`Build path ${resolved.buildPath} (server)`); + break; + } + + return resolved.buildPath; +} + async function handleNativeBuildServerDeploy({ apiClient, options, diff --git a/packages/cli-v3/src/deploy/buildPath.test.ts b/packages/cli-v3/src/deploy/buildPath.test.ts new file mode 100644 index 00000000000..9ae613089cb --- /dev/null +++ b/packages/cli-v3/src/deploy/buildPath.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { applyBuildPathOptions, nativeOnlyFlagError, resolveBuildPath } from "./buildPath.js"; + +const none = { nativeBuildServer: false, localBundle: false, detach: false, dryRun: false }; +const native = { ...none, nativeBuildServer: true }; + +describe("nativeOnlyFlagError", () => { + it("requires --native-build for --local-bundle and --detach", () => { + expect(nativeOnlyFlagError({ ...none, localBundle: true })).toBe( + "--local-bundle requires --native-build." + ); + expect(nativeOnlyFlagError({ ...none, detach: true })).toBe( + "--detach requires --native-build." + ); + }); + + it("is satisfied by --native-build and by having neither flag", () => { + expect(nativeOnlyFlagError({ ...native, localBundle: true, detach: true })).toBeUndefined(); + expect(nativeOnlyFlagError(none)).toBeUndefined(); + }); +}); + +describe("applyBuildPathOptions", () => { + it("passes the resolved path through without modifiers", () => { + expect(applyBuildPathOptions("depot", none)).toBe("depot"); + expect(applyBuildPathOptions("native", none)).toBe("native"); + expect(applyBuildPathOptions("native_local_bundle", none)).toBe("native_local_bundle"); + }); + + it("upgrades a native path to the local bundle variant with --local-bundle", () => { + expect(applyBuildPathOptions("native", { ...native, localBundle: true })).toBe( + "native_local_bundle" + ); + }); + + it("keeps --detach on the native paths", () => { + expect(applyBuildPathOptions("native", { ...native, detach: true })).toBe("native"); + expect(applyBuildPathOptions("native_local_bundle", { ...native, detach: true })).toBe( + "native_local_bundle" + ); + }); + + it("moves a native dry run onto the Depot path, however native was chosen", () => { + expect(applyBuildPathOptions("native", { ...none, dryRun: true })).toBe("depot"); + expect(applyBuildPathOptions("native", { ...native, dryRun: true })).toBe("depot"); + }); + + it("lets a local-bundle dry run stay on the local bundle path", () => { + expect(applyBuildPathOptions("native", { ...native, localBundle: true, dryRun: true })).toBe( + "native_local_bundle" + ); + expect(applyBuildPathOptions("native_local_bundle", { ...none, dryRun: true })).toBe( + "native_local_bundle" + ); + }); +}); + +describe("resolveBuildPath", () => { + const noFlags = { nativeBuildServer: false, depotBuild: false, localBuild: false }; + const neverFetch = () => { + throw new Error("fetchSettings must not be called"); + }; + + it("lets explicit flags decide without asking the server", async () => { + expect(await resolveBuildPath({ ...noFlags, nativeBuildServer: true }, neverFetch)).toEqual({ + buildPath: "native", + from: "flag", + flag: "--native-build", + }); + expect(await resolveBuildPath({ ...noFlags, depotBuild: true }, neverFetch)).toEqual({ + buildPath: "depot", + from: "flag", + flag: "--depot-build", + }); + expect(await resolveBuildPath({ ...noFlags, localBuild: true }, neverFetch)).toEqual({ + buildPath: "depot", + from: "flag", + flag: "--local-build", + }); + }); + + it("uses the server's build path", async () => { + for (const build_path of ["depot", "native", "native_local_bundle"] as const) { + expect( + await resolveBuildPath(noFlags, async () => ({ success: true, data: { build_path } })) + ).toEqual({ buildPath: build_path, from: "server" }); + } + }); + + it("falls back to depot silently on a 404", async () => { + const resolved = await resolveBuildPath(noFlags, async () => ({ + success: false, + statusCode: 404, + })); + expect(resolved).toMatchObject({ buildPath: "depot", from: "fallback", silent: true }); + }); + + it("falls back to depot loudly on any other failure", async () => { + const failures: Array<() => Promise> = [ + async () => ({ success: false, statusCode: 500 }), + async () => ({ success: false }), + async () => { + throw new Error("timeout"); + }, + ]; + for (const fetchSettings of failures) { + expect(await resolveBuildPath(noFlags, fetchSettings)).toMatchObject({ + buildPath: "depot", + from: "fallback", + silent: false, + }); + } + }); +}); diff --git a/packages/cli-v3/src/deploy/buildPath.ts b/packages/cli-v3/src/deploy/buildPath.ts new file mode 100644 index 00000000000..3dc7b8fe542 --- /dev/null +++ b/packages/cli-v3/src/deploy/buildPath.ts @@ -0,0 +1,73 @@ +import type { DeployBuildPath, GetDeploySettingsResponseBody } from "@trigger.dev/core/v3/schemas"; +import { tryCatch } from "@trigger.dev/core/v3"; + +export type BuildPathOptions = { + nativeBuildServer: boolean; + localBundle: boolean; + detach: boolean; + dryRun: boolean; +}; + +export function nativeOnlyFlagError(options: BuildPathOptions): string | undefined { + if (options.nativeBuildServer) return undefined; + const flag = options.localBundle ? "--local-bundle" : options.detach ? "--detach" : undefined; + return flag ? `${flag} requires --native-build.` : undefined; +} + +export function applyBuildPathOptions( + resolved: DeployBuildPath, + options: BuildPathOptions +): DeployBuildPath { + if (options.localBundle) { + return "native_local_bundle"; + } + + // A plain native dry run would deploy for real, so bundle locally on the Depot path instead. + if (options.dryRun && resolved === "native") { + return "depot"; + } + + return resolved; +} + +export type BuildPathFlags = { + nativeBuildServer?: boolean; + depotBuild?: boolean; + localBuild?: boolean; +}; + +export type DeploySettingsResult = + | { success: true; data: GetDeploySettingsResponseBody } + | { success: false; statusCode?: number }; + +export type ResolvedBuildPath = + | { buildPath: DeployBuildPath; from: "flag"; flag: string } + | { buildPath: DeployBuildPath; from: "server" } + | { buildPath: "depot"; from: "fallback"; silent: boolean; failure: unknown }; + +export async function resolveBuildPath( + options: BuildPathFlags, + fetchSettings: () => Promise +): Promise { + if (options.nativeBuildServer) { + return { buildPath: "native", from: "flag", flag: "--native-build" }; + } + + if (options.localBuild) { + return { buildPath: "depot", from: "flag", flag: "--local-build" }; + } + + if (options.depotBuild) { + return { buildPath: "depot", from: "flag", flag: "--depot-build" }; + } + + const [error, result] = await tryCatch(fetchSettings()); + + if (error || !result.success) { + // A 404 is an older server without the endpoint; Depot is exactly what it expects. + const silent = !error && !result.success && result.statusCode === 404; + return { buildPath: "depot", from: "fallback", silent, failure: error ?? result }; + } + + return { buildPath: result.data.build_path, from: "server" }; +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index a90430953d4..5175f94cb19 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -839,6 +839,16 @@ export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFu export type InitializeDeploymentRequestBody = z.infer; +export const DeployBuildPath = z.enum(["depot", "native", "native_local_bundle"]); + +export type DeployBuildPath = z.infer; + +export const GetDeploySettingsResponseBody = z.object({ + build_path: DeployBuildPath, +}); + +export type GetDeploySettingsResponseBody = z.infer; + export const RemoteBuildProviderStatusResponseBody = z.object({ status: z.enum(["operational", "degraded", "unknown"]), message: z.string(),