Skip to content

Commit c5bdd04

Browse files
committed
feat(cli): add TRIGGER_REGISTRY_AUTH=file registry auth mode
When TRIGGER_REGISTRY_AUTH=file is set, deploys pass registry credentials to the image build through an ephemeral docker config dir (DOCKER_CONFIG, with BUILDX_CONFIG kept on the real builder state and the contexts/plugin dirs shared read-only) instead of running docker login. Nothing shared is read or mutated for auth, so credential helpers, other registry logins, and concurrent deploys are unaffected. Intended for the managed build server; the default login path is unchanged.
1 parent 83af2d2 commit c5bdd04

2 files changed

Lines changed: 114 additions & 27 deletions

File tree

‎.changeset/thick-otters-swim.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Adds TRIGGER_REGISTRY_AUTH=file: deploys pass registry credentials to the image build through an ephemeral docker config instead of running docker login. Intended for managed build servers.

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

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { logger } from "../utilities/logger.js";
22
import { depot } from "@depot/cli";
33
import { x } from "tinyexec";
44
import type { BuildManifest, BuildRuntime } from "@trigger.dev/core/v3/schemas";
5-
import { networkInterfaces } from "os";
6-
import { join } from "path";
5+
import { homedir, networkInterfaces, tmpdir } from "os";
6+
import { join, resolve } from "path";
77
import { safeReadJSONFile } from "../utilities/fileSystem.js";
8+
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
89
import { readFileSync } from "fs";
910

1011
import { isLinux } from "std-env";
@@ -474,6 +475,10 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
474475

475476
const errors: string[] = [];
476477

478+
const writeRegistryAuthFile = process.env.TRIGGER_REGISTRY_AUTH === "file";
479+
let authConfigDir: string | undefined;
480+
let buildEnv: NodeJS.ProcessEnv | undefined;
481+
477482
let cloudRegistryHost: string | undefined;
478483
if (push && options.authenticateToRegistry) {
479484
cloudRegistryHost =
@@ -499,32 +504,56 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
499504
};
500505
}
501506

502-
logger.debug(`Logging in to docker registry: ${cloudRegistryHost}`);
507+
if (writeRegistryAuthFile) {
508+
const [authDirError, result] = await tryCatch(
509+
createRegistryAuthConfigDir(cloudRegistryHost, credentials)
510+
);
503511

504-
const loginProcess = x(
505-
"docker",
506-
["login", "--username", credentials.username, "--password-stdin", cloudRegistryHost],
507-
{
508-
nodeOptions: {
509-
cwd: options.cwd,
510-
},
512+
if (authDirError) {
513+
return {
514+
ok: false as const,
515+
error: `Failed to write registry auth for ${cloudRegistryHost}: ${authDirError.message}`,
516+
logs: "",
517+
};
511518
}
512-
);
513519

514-
loginProcess.process?.stdin?.write(credentials.password);
515-
loginProcess.process?.stdin?.end();
520+
authConfigDir = result;
521+
// buildx keeps its builder state under $DOCKER_CONFIG/buildx, so point it back at
522+
// the real location while the ephemeral config dir supplies only the registry auth.
523+
const realConfigDir = resolve(process.env.DOCKER_CONFIG ?? join(homedir(), ".docker"));
524+
buildEnv = {
525+
...process.env,
526+
DOCKER_CONFIG: authConfigDir,
527+
BUILDX_CONFIG: process.env.BUILDX_CONFIG ?? join(realConfigDir, "buildx"),
528+
};
529+
} else {
530+
logger.debug(`Logging in to docker registry: ${cloudRegistryHost}`);
531+
532+
const loginProcess = x(
533+
"docker",
534+
["login", "--username", credentials.username, "--password-stdin", cloudRegistryHost],
535+
{
536+
nodeOptions: {
537+
cwd: options.cwd,
538+
},
539+
}
540+
);
516541

517-
for await (const line of loginProcess) {
518-
errors.push(line);
519-
logger.debug(line);
520-
}
542+
loginProcess.process?.stdin?.write(credentials.password);
543+
loginProcess.process?.stdin?.end();
521544

522-
if (loginProcess.exitCode !== 0) {
523-
return {
524-
ok: false as const,
525-
error: `Failed to login to registry: ${cloudRegistryHost}`,
526-
logs: extractLogs(errors),
527-
};
545+
for await (const line of loginProcess) {
546+
errors.push(line);
547+
logger.debug(line);
548+
}
549+
550+
if (loginProcess.exitCode !== 0) {
551+
return {
552+
ok: false as const,
553+
error: `Failed to login to registry: ${cloudRegistryHost}`,
554+
logs: extractLogs(errors),
555+
};
556+
}
528557
}
529558

530559
options.onLog?.(`Successfully logged in to the remote registry`);
@@ -598,6 +627,7 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
598627
const buildProcess = x("docker", args, {
599628
nodeOptions: {
600629
cwd: options.cwd,
630+
env: buildEnv,
601631
},
602632
});
603633

@@ -610,8 +640,7 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
610640

611641
if (buildProcess.exitCode !== 0) {
612642
if (cloudRegistryHost) {
613-
logger.debug(`Logging out from docker registry: ${cloudRegistryHost}`);
614-
await x("docker", ["logout", cloudRegistryHost]);
643+
await clearRegistryAuth(cloudRegistryHost, authConfigDir);
615644
}
616645

617646
return {
@@ -660,8 +689,7 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
660689
}
661690

662691
if (cloudRegistryHost) {
663-
logger.debug(`Logging out from docker registry: ${cloudRegistryHost}`);
664-
await x("docker", ["logout", cloudRegistryHost]);
692+
await clearRegistryAuth(cloudRegistryHost, authConfigDir);
665693
}
666694

667695
return {
@@ -672,6 +700,60 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
672700
};
673701
}
674702

703+
// An ephemeral docker config dir holding only this deploy's registry auth entry, passed
704+
// to the build via DOCKER_CONFIG. Nothing shared is read or mutated, and a fresh config
705+
// has no credsStore, so docker resolves the entry from the file.
706+
async function createRegistryAuthConfigDir(
707+
host: string,
708+
credentials: { username: string; password: string }
709+
) {
710+
const dir = await mkdtemp(join(tmpdir(), "trigger-docker-"));
711+
const realConfigDir = resolve(process.env.DOCKER_CONFIG ?? join(homedir(), ".docker"));
712+
713+
// docker login stores Docker Hub credentials under the legacy index key
714+
const bareHost = host.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
715+
const authKey =
716+
bareHost === "docker.io" || bareHost === "registry-1.docker.io"
717+
? "https://index.docker.io/v1/"
718+
: bareHost;
719+
720+
// Carry over the context selection (e.g. OrbStack/colima) so endpoint resolution
721+
// still works; the contexts store and CLI plugins are shared read-only via symlink.
722+
const realConfig = (await safeReadJSONFile(join(realConfigDir, "config.json"))) as
723+
| { currentContext?: string }
724+
| undefined;
725+
726+
await writeFile(
727+
join(dir, "config.json"),
728+
JSON.stringify({
729+
...(realConfig?.currentContext ? { currentContext: realConfig.currentContext } : {}),
730+
auths: {
731+
[authKey]: {
732+
auth: Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64"),
733+
},
734+
},
735+
}),
736+
{ mode: 0o600 }
737+
);
738+
739+
await tryCatch(symlink(join(realConfigDir, "contexts"), join(dir, "contexts")));
740+
await tryCatch(symlink(join(realConfigDir, "cli-plugins"), join(dir, "cli-plugins")));
741+
742+
logger.debug(`Wrote registry auth for ${host} to ${dir}`);
743+
744+
return dir;
745+
}
746+
747+
async function clearRegistryAuth(host: string, authConfigDir: string | undefined) {
748+
if (!authConfigDir) {
749+
logger.debug(`Logging out from docker registry: ${host}`);
750+
await x("docker", ["logout", host]);
751+
return;
752+
}
753+
754+
await tryCatch(rm(authConfigDir, { recursive: true, force: true }));
755+
}
756+
675757
function extractLogs(outputs: string[]) {
676758
// Remove empty lines
677759
const cleanedOutputs = outputs.map((line) => line.trim()).filter((line) => line !== "");

0 commit comments

Comments
 (0)