Skip to content

Commit c6407a2

Browse files
committed
Add a --force-local-build flag to the deployment command to skip remote build
1 parent e5c4ebd commit c6407a2

File tree

4 files changed

+151
-9
lines changed

4 files changed

+151
-9
lines changed

packages/cli-v3/src/apiClient.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
GetJWTRequestBody,
3838
GetJWTResponse,
3939
ApiBranchListResponseBody,
40+
GenerateRegistryCredentialsResponseBody,
4041
} from "@trigger.dev/core/v3";
4142
import {
4243
WorkloadDebugLogRequestBody,
@@ -327,6 +328,22 @@ export class CliApiClient {
327328
);
328329
}
329330

331+
async generateRegistryCredentials(deploymentId: string) {
332+
if (!this.accessToken) {
333+
throw new Error("generateRegistryCredentials: No access token");
334+
}
335+
336+
return wrapZodFetch(
337+
GenerateRegistryCredentialsResponseBody,
338+
`${this.apiURL}/api/v1/deployments/${deploymentId}/generate-registry-credentials`,
339+
{
340+
method: "POST",
341+
headers: this.getHeaders(),
342+
body: "{}",
343+
}
344+
);
345+
}
346+
330347
async initializeDeployment(body: InitializeDeploymentRequestBody) {
331348
if (!this.accessToken) {
332349
throw new Error("initializeDeployment: No access token");

packages/cli-v3/src/commands/deploy.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({
5757
noCache: z.boolean().default(false),
5858
envFile: z.string().optional(),
5959
// Local build options
60+
forceLocalBuild: z.boolean().optional(),
6061
network: z.enum(["default", "none", "host"]).optional(),
6162
push: z.boolean().optional(),
6263
builder: z.string().default("trigger"),
@@ -127,6 +128,7 @@ export function configureDeployCommand(program: Command) {
127128
).hideHelp()
128129
)
129130
// Local build options
131+
.option("--force-local-build", "Force a local build of the image")
130132
.addOption(new CommandOption("--push", "Push the image after local builds").hideHelp())
131133
.addOption(
132134
new CommandOption("--no-push", "Do not push the image after local builds").hideHelp()
@@ -320,7 +322,9 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
320322
},
321323
envVars.TRIGGER_EXISTING_DEPLOYMENT_ID
322324
);
323-
const isLocalBuild = !deployment.externalBuildData;
325+
const isLocalBuild = options.forceLocalBuild || !deployment.externalBuildData;
326+
// Would be best to actually store this separately in the deployment object. This is an okay proxy for now.
327+
const remoteBuildExplicitlySkipped = options.forceLocalBuild && !!deployment.externalBuildData;
324328

325329
// Fail fast if we know local builds will fail
326330
if (isLocalBuild) {
@@ -391,8 +395,10 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
391395

392396
const $spinner = spinner();
393397

394-
const buildSuffix = isLocalBuild ? " (local)" : "";
395-
const deploySuffix = isLocalBuild ? " (local build)" : "";
398+
const buildSuffix =
399+
isLocalBuild && !process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED ? " (local)" : "";
400+
const deploySuffix =
401+
isLocalBuild && !process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED ? " (local build)" : "";
396402

397403
if (isCI) {
398404
log.step(`Building version ${version}\n`);
@@ -420,6 +426,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
420426
projectRef: resolvedConfig.project,
421427
apiUrl: projectClient.client.apiURL,
422428
apiKey: projectClient.client.accessToken!,
429+
apiClient: projectClient.client,
423430
branchName: branch,
424431
authAccessToken: authorization.auth.accessToken,
425432
compilationPath: destination.path,
@@ -442,6 +449,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
442449
network: options.network,
443450
builder: options.builder,
444451
push: options.push,
452+
authenticateToRegistry: remoteBuildExplicitlySkipped,
445453
});
446454

447455
logger.debug("Build result", buildResult);
@@ -525,6 +533,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
525533
{
526534
imageDigest: buildResult.digest,
527535
skipPromotion: options.skipPromotion,
536+
skipPushToRegistry: remoteBuildExplicitlySkipped,
528537
},
529538
(logMessage) => {
530539
if (isCI) {

packages/cli-v3/src/commands/workers/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti
336336
projectRef: resolvedConfig.project,
337337
apiUrl: projectClient.client.apiURL,
338338
apiKey: projectClient.client.accessToken!,
339+
apiClient: projectClient.client,
339340
branchName: branch,
340341
authAccessToken: authorization.auth.accessToken,
341342
compilationPath: destination.path,

packages/cli-v3/src/deploy/buildImage.ts

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import { networkInterfaces } from "os";
66
import { join } from "path";
77
import { safeReadJSONFile } from "../utilities/fileSystem.js";
88
import { readFileSync } from "fs";
9+
910
import { isLinux } from "std-env";
1011
import { z } from "zod";
1112
import { assertExhaustive } from "../utilities/assertExhaustive.js";
13+
import { tryCatch } from "@trigger.dev/core";
14+
import { CliApiClient } from "../apiClient.js";
1215

1316
export interface BuildImageOptions {
1417
// Common options
@@ -19,6 +22,7 @@ export interface BuildImageOptions {
1922

2023
// Local build options
2124
push?: boolean;
25+
authenticateToRegistry?: boolean;
2226
network?: string;
2327
builder: string;
2428

@@ -37,6 +41,7 @@ export interface BuildImageOptions {
3741
extraCACerts?: string;
3842
apiUrl: string;
3943
apiKey: string;
44+
apiClient: CliApiClient;
4045
branchName?: string;
4146
buildEnvVars?: Record<string, string | undefined>;
4247
onLog?: (log: string) => void;
@@ -51,6 +56,7 @@ export async function buildImage(options: BuildImageOptions): Promise<BuildImage
5156
imagePlatform,
5257
noCache,
5358
push,
59+
authenticateToRegistry,
5460
load,
5561
authAccessToken,
5662
imageTag,
@@ -66,6 +72,7 @@ export async function buildImage(options: BuildImageOptions): Promise<BuildImage
6672
extraCACerts,
6773
apiUrl,
6874
apiKey,
75+
apiClient,
6976
branchName,
7077
buildEnvVars,
7178
network,
@@ -84,11 +91,13 @@ export async function buildImage(options: BuildImageOptions): Promise<BuildImage
8491
contentHash,
8592
projectRef,
8693
push,
94+
authenticateToRegistry,
8795
load,
8896
noCache,
8997
extraCACerts,
9098
apiUrl,
9199
apiKey,
100+
apiClient,
92101
branchName,
93102
buildEnvVars,
94103
network,
@@ -292,8 +301,10 @@ interface SelfHostedBuildImageOptions {
292301
projectRef: string;
293302
imagePlatform: string;
294303
push?: boolean;
304+
authenticateToRegistry?: boolean;
295305
apiUrl: string;
296306
apiKey: string;
307+
apiClient: CliApiClient;
297308
branchName?: string;
298309
noCache?: boolean;
299310
extraCACerts?: string;
@@ -305,7 +316,7 @@ interface SelfHostedBuildImageOptions {
305316
}
306317

307318
async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<BuildImageResults> {
308-
const { builder, imageTag } = options;
319+
const { builder, imageTag, deploymentId, apiClient } = options;
309320

310321
// Ensure multi-platform build is supported on the local machine
311322
let builderExists = false;
@@ -414,6 +425,64 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
414425

415426
await ensureQemuRegistered(options.imagePlatform);
416427

428+
const errors: string[] = [];
429+
430+
let registryHost: string | undefined;
431+
if (push && options.authenticateToRegistry) {
432+
registryHost = process.env.TRIGGER_DOCKER_REGISTRY ?? extractRegistryHostFromImageTag(imageTag);
433+
434+
if (!registryHost) {
435+
return {
436+
ok: false as const,
437+
error: "Failed to extract registry host from image tag",
438+
logs: "",
439+
};
440+
}
441+
442+
const [credentialsError, credentials] = await tryCatch(
443+
getDockerUsernameAndPassword(apiClient, deploymentId)
444+
);
445+
446+
if (credentialsError) {
447+
return {
448+
ok: false as const,
449+
error: `Failed to get docker credentials: ${credentialsError.message}`,
450+
logs: "",
451+
};
452+
}
453+
454+
logger.debug(`Logging in to docker registry: ${registryHost}`);
455+
456+
const loginProcess = x(
457+
"docker",
458+
["login", "--username", credentials.username, "--password-stdin", registryHost],
459+
{
460+
nodeOptions: {
461+
cwd: options.cwd,
462+
},
463+
}
464+
);
465+
466+
loginProcess.process?.stdin?.write(credentials.password);
467+
loginProcess.process?.stdin?.end();
468+
469+
for await (const line of loginProcess) {
470+
errors.push(line);
471+
logger.debug(line);
472+
options.onLog?.(line);
473+
}
474+
475+
if (loginProcess.exitCode !== 0) {
476+
return {
477+
ok: false as const,
478+
error: `Failed to login to registry: ${registryHost}`,
479+
logs: extractLogs(errors),
480+
};
481+
}
482+
483+
options.onLog?.(`Successfully logged in to ${registryHost}`);
484+
}
485+
417486
const args = [
418487
"buildx",
419488
"build",
@@ -453,17 +522,16 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
453522
"--progress",
454523
"plain",
455524
"-t",
456-
options.imageTag,
525+
imageTag,
457526
".", // The build context
458527
].filter(Boolean) as string[];
459528

460529
logger.debug(`docker ${args.join(" ")}`, { cwd: options.cwd });
461530

462-
const errors: string[] = [];
463-
464-
// Build the image
465531
const buildProcess = x("docker", args, {
466-
nodeOptions: { cwd: options.cwd },
532+
nodeOptions: {
533+
cwd: options.cwd,
534+
},
467535
});
468536

469537
for await (const line of buildProcess) {
@@ -474,6 +542,11 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
474542
}
475543

476544
if (buildProcess.exitCode !== 0) {
545+
if (registryHost) {
546+
logger.debug(`Logging out from docker registry: ${registryHost}`);
547+
await x("docker", ["logout", registryHost]);
548+
}
549+
477550
return {
478551
ok: false as const,
479552
error: "Error building image",
@@ -519,6 +592,11 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
519592
options.onLog?.(`Image size: ${(imageSizeBytes / (1024 * 1024)).toFixed(2)} MB`);
520593
}
521594

595+
if (registryHost) {
596+
logger.debug(`Logging out from docker registry: ${registryHost}`);
597+
await x("docker", ["logout", registryHost]);
598+
}
599+
522600
return {
523601
ok: true as const,
524602
imageSizeBytes,
@@ -840,6 +918,43 @@ function getAddHost(apiUrl: string) {
840918
return;
841919
}
842920

921+
function extractRegistryHostFromImageTag(imageTag: string): string | undefined {
922+
const host = imageTag.split("/")[0];
923+
924+
if (!host || !host.includes(".")) {
925+
return undefined;
926+
}
927+
928+
return host;
929+
}
930+
931+
async function getDockerUsernameAndPassword(
932+
apiClient: CliApiClient,
933+
deploymentId: string
934+
): Promise<{ username: string; password: string }> {
935+
if (process.env.TRIGGER_DOCKER_USERNAME && process.env.TRIGGER_DOCKER_PASSWORD) {
936+
return {
937+
username: process.env.TRIGGER_DOCKER_USERNAME,
938+
password: process.env.TRIGGER_DOCKER_PASSWORD,
939+
};
940+
}
941+
942+
const result = await apiClient.generateRegistryCredentials(deploymentId);
943+
944+
if (!result.success) {
945+
logger.debug("Failed to generate registry credentials", {
946+
error: result.error,
947+
deploymentId,
948+
});
949+
throw new Error("Failed to generate registry credentials");
950+
}
951+
952+
return {
953+
username: result.data.username,
954+
password: result.data.password,
955+
};
956+
}
957+
843958
function isQemuRegistered() {
844959
try {
845960
// Check a single QEMU handler

0 commit comments

Comments
 (0)