Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/deploy-build-path-settings.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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 });
}
}
1 change: 1 addition & 0 deletions apps/webapp/app/services/deploymentApiPaths.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down
10 changes: 10 additions & 0 deletions apps/webapp/app/v3/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { DeployBuildPath } from "@trigger.dev/core/v3";

export const FEATURE_FLAG = {
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down
97 changes: 96 additions & 1 deletion apps/webapp/app/v3/services/deployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<Record<RuntimeEnvironmentType, FeatureFlagKey>> = {
PREVIEW: FEATURE_FLAG.deployBuildPathPreview,
STAGING: FEATURE_FLAG.deployBuildPathStaging,
PRODUCTION: FEATURE_FLAG.deployBuildPathProduction,
};

const DEPLOY_ENV_SLUG_FOR_TYPE: Record<RuntimeEnvironmentType, DeployEnvSlug> = {
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.
Expand Down Expand Up @@ -282,6 +316,67 @@ export class DeploymentService extends BaseService {
.map(() => undefined);
}

public getDeploySettings(
authenticatedEnv: Pick<AuthenticatedEnvironment, "type" | "organization" | "project">,
target: { projectRef: string; envSlug: DeployEnvSlug }
) {
const validateTarget = (): ResultAsync<undefined, { type: "environment_mismatch" }> => {
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<string, unknown>): DeploySettings => {
const envKey = DEPLOY_BUILD_PATH_ENV_FLAG[authenticatedEnv.type];
const orgFlags = authenticatedEnv.organization.featureFlags;
const orgFlagSet: Record<string, unknown> =
orgFlags && typeof orgFlags === "object" && !Array.isArray(orgFlags)
? (orgFlags as Record<string, unknown>)
: {};

const candidates: Array<
[Record<string, unknown>, 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.
*
Expand Down
121 changes: 121 additions & 0 deletions apps/webapp/test/deploySettingsRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>>(),
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() },
}));
Comment thread
myftija marked this conversation as resolved.

import { loader } from "~/routes/api.v1.projects.$projectRef.$env.deploy-settings";

function environment(overrides: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading