Skip to content

Commit 9092ca8

Browse files
authored
feat(supervisor): add ecr support to docker client (#2424)
1 parent b41129a commit 9092ca8

File tree

4 files changed

+185
-7
lines changed

4 files changed

+185
-7
lines changed

apps/supervisor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"typecheck": "tsc --noEmit"
1414
},
1515
"dependencies": {
16+
"@aws-sdk/client-ecr": "^3.839.0",
1617
"@kubernetes/client-node": "^1.0.0",
1718
"@trigger.dev/core": "workspace:*",
1819
"dockerode": "^4.0.6",

apps/supervisor/src/workloadManager/docker.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import { env } from "../env.js";
88
import { getDockerHostDomain, getRunnerId, normalizeDockerHostUrl } from "../util.js";
99
import Docker from "dockerode";
1010
import { tryCatch } from "@trigger.dev/core";
11+
import { ECRAuthService } from "./ecrAuth.js";
1112

1213
export class DockerWorkloadManager implements WorkloadManager {
1314
private readonly logger = new SimpleStructuredLogger("docker-workload-manager");
1415
private readonly docker: Docker;
1516

1617
private readonly runnerNetworks: string[];
17-
private readonly auth?: Docker.AuthConfig;
18+
private readonly staticAuth?: Docker.AuthConfig;
1819
private readonly platformOverride?: string;
20+
private readonly ecrAuthService?: ECRAuthService;
1921

2022
constructor(private opts: WorkloadManagerOptions) {
2123
this.docker = new Docker({
@@ -44,13 +46,18 @@ export class DockerWorkloadManager implements WorkloadManager {
4446
url: env.DOCKER_REGISTRY_URL,
4547
});
4648

47-
this.auth = {
49+
this.staticAuth = {
4850
username: env.DOCKER_REGISTRY_USERNAME,
4951
password: env.DOCKER_REGISTRY_PASSWORD,
5052
serveraddress: env.DOCKER_REGISTRY_URL,
5153
};
54+
} else if (ECRAuthService.hasAWSCredentials()) {
55+
this.logger.info("🐋 AWS credentials found, initializing ECR auth service");
56+
this.ecrAuthService = new ECRAuthService();
5257
} else {
53-
this.logger.warn("🐋 No Docker registry credentials provided, skipping auth");
58+
this.logger.warn(
59+
"🐋 No Docker registry credentials or AWS credentials provided, skipping auth"
60+
);
5461
}
5562
}
5663

@@ -160,9 +167,12 @@ export class DockerWorkloadManager implements WorkloadManager {
160167
imageArchitecture: inspectResult?.Architecture,
161168
});
162169

170+
// Get auth config (static or ECR)
171+
const authConfig = await this.getAuthConfig();
172+
163173
// Ensure the image is present
164174
const [createImageError, imageResponseReader] = await tryCatch(
165-
this.docker.createImage(this.auth, {
175+
this.docker.createImage(authConfig, {
166176
fromImage: imageRef,
167177
...(this.platformOverride ? { platform: this.platformOverride } : {}),
168178
})
@@ -216,6 +226,26 @@ export class DockerWorkloadManager implements WorkloadManager {
216226
logger.debug("create succeeded", { startResult, containerId: container.id });
217227
}
218228

229+
/**
230+
* Get authentication config for Docker operations
231+
* Uses static credentials if available, otherwise attempts ECR auth
232+
*/
233+
private async getAuthConfig(): Promise<Docker.AuthConfig | undefined> {
234+
// Use static credentials if available
235+
if (this.staticAuth) {
236+
return this.staticAuth;
237+
}
238+
239+
// Use ECR auth if service is available
240+
if (this.ecrAuthService) {
241+
const ecrAuth = await this.ecrAuthService.getAuthConfig();
242+
return ecrAuth || undefined;
243+
}
244+
245+
// No auth available
246+
return undefined;
247+
}
248+
219249
private async attachContainerToNetworks({
220250
containerId,
221251
networkNames,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { ECRClient, GetAuthorizationTokenCommand } from "@aws-sdk/client-ecr";
2+
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
3+
import { tryCatch } from "@trigger.dev/core";
4+
import Docker from "dockerode";
5+
6+
interface ECRTokenCache {
7+
token: string;
8+
username: string;
9+
serverAddress: string;
10+
expiresAt: Date;
11+
}
12+
13+
export class ECRAuthService {
14+
private readonly logger = new SimpleStructuredLogger("ecr-auth-service");
15+
private readonly ecrClient: ECRClient;
16+
private tokenCache: ECRTokenCache | null = null;
17+
18+
constructor() {
19+
this.ecrClient = new ECRClient();
20+
21+
this.logger.info("🔐 ECR Auth Service initialized", {
22+
region: this.ecrClient.config.region,
23+
});
24+
}
25+
26+
/**
27+
* Check if we have AWS credentials configured
28+
*/
29+
static hasAWSCredentials(): boolean {
30+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
31+
return true;
32+
}
33+
34+
if (
35+
process.env.AWS_PROFILE ||
36+
process.env.AWS_ROLE_ARN ||
37+
process.env.AWS_WEB_IDENTITY_TOKEN_FILE
38+
) {
39+
return true;
40+
}
41+
42+
return false;
43+
}
44+
45+
/**
46+
* Check if the current token is still valid with a 10-minute buffer
47+
*/
48+
private isTokenValid(): boolean {
49+
if (!this.tokenCache) {
50+
return false;
51+
}
52+
53+
const now = new Date();
54+
const bufferMs = 10 * 60 * 1000; // 10 minute buffer before expiration
55+
return now < new Date(this.tokenCache.expiresAt.getTime() - bufferMs);
56+
}
57+
58+
/**
59+
* Get a fresh ECR authorization token from AWS
60+
*/
61+
private async fetchNewToken(): Promise<ECRTokenCache | null> {
62+
const [error, response] = await tryCatch(
63+
this.ecrClient.send(new GetAuthorizationTokenCommand({}))
64+
);
65+
66+
if (error) {
67+
this.logger.error("Failed to get ECR authorization token", { error });
68+
return null;
69+
}
70+
71+
const authData = response.authorizationData?.[0];
72+
if (!authData?.authorizationToken || !authData.proxyEndpoint) {
73+
this.logger.error("Invalid ECR authorization response", { authData });
74+
return null;
75+
}
76+
77+
// Decode the base64 token to get username:password
78+
const decoded = Buffer.from(authData.authorizationToken, "base64").toString("utf-8");
79+
const [username, password] = decoded.split(":", 2);
80+
81+
if (!username || !password) {
82+
this.logger.error("Failed to parse ECR authorization token");
83+
return null;
84+
}
85+
86+
const expiresAt = authData.expiresAt || new Date(Date.now() + 12 * 60 * 60 * 1000); // Default 12 hours
87+
88+
const tokenCache: ECRTokenCache = {
89+
token: password,
90+
username,
91+
serverAddress: authData.proxyEndpoint,
92+
expiresAt,
93+
};
94+
95+
this.logger.info("🔐 Successfully fetched ECR token", {
96+
username,
97+
serverAddress: authData.proxyEndpoint,
98+
expiresAt: expiresAt.toISOString(),
99+
});
100+
101+
return tokenCache;
102+
}
103+
104+
/**
105+
* Get ECR auth config for Docker operations
106+
* Returns cached token if valid, otherwise fetches a new one
107+
*/
108+
async getAuthConfig(): Promise<Docker.AuthConfig | null> {
109+
// Check if cached token is still valid
110+
if (this.isTokenValid()) {
111+
this.logger.debug("Using cached ECR token");
112+
return {
113+
username: this.tokenCache!.username,
114+
password: this.tokenCache!.token,
115+
serveraddress: this.tokenCache!.serverAddress,
116+
};
117+
}
118+
119+
// Fetch new token
120+
this.logger.info("Fetching new ECR authorization token");
121+
const newToken = await this.fetchNewToken();
122+
123+
if (!newToken) {
124+
return null;
125+
}
126+
127+
// Cache the new token
128+
this.tokenCache = newToken;
129+
130+
return {
131+
username: newToken.username,
132+
password: newToken.token,
133+
serveraddress: newToken.serverAddress,
134+
};
135+
}
136+
137+
/**
138+
* Clear the cached token (useful for testing or forcing refresh)
139+
*/
140+
clearCache(): void {
141+
this.tokenCache = null;
142+
this.logger.debug("ECR token cache cleared");
143+
}
144+
}

pnpm-lock.yaml

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)