diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json index 9cce9d5feb..e9609bf154 100644 --- a/apps/supervisor/package.json +++ b/apps/supervisor/package.json @@ -13,6 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@aws-sdk/client-ecr": "^3.839.0", "@kubernetes/client-node": "^1.0.0", "@trigger.dev/core": "workspace:*", "dockerode": "^4.0.6", diff --git a/apps/supervisor/src/workloadManager/docker.ts b/apps/supervisor/src/workloadManager/docker.ts index 6aa74a7ecc..4ebbe11ca7 100644 --- a/apps/supervisor/src/workloadManager/docker.ts +++ b/apps/supervisor/src/workloadManager/docker.ts @@ -8,14 +8,16 @@ import { env } from "../env.js"; import { getDockerHostDomain, getRunnerId, normalizeDockerHostUrl } from "../util.js"; import Docker from "dockerode"; import { tryCatch } from "@trigger.dev/core"; +import { ECRAuthService } from "./ecrAuth.js"; export class DockerWorkloadManager implements WorkloadManager { private readonly logger = new SimpleStructuredLogger("docker-workload-manager"); private readonly docker: Docker; private readonly runnerNetworks: string[]; - private readonly auth?: Docker.AuthConfig; + private readonly staticAuth?: Docker.AuthConfig; private readonly platformOverride?: string; + private readonly ecrAuthService?: ECRAuthService; constructor(private opts: WorkloadManagerOptions) { this.docker = new Docker({ @@ -44,13 +46,18 @@ export class DockerWorkloadManager implements WorkloadManager { url: env.DOCKER_REGISTRY_URL, }); - this.auth = { + this.staticAuth = { username: env.DOCKER_REGISTRY_USERNAME, password: env.DOCKER_REGISTRY_PASSWORD, serveraddress: env.DOCKER_REGISTRY_URL, }; + } else if (ECRAuthService.hasAWSCredentials()) { + this.logger.info("🐋 AWS credentials found, initializing ECR auth service"); + this.ecrAuthService = new ECRAuthService(); } else { - this.logger.warn("🐋 No Docker registry credentials provided, skipping auth"); + this.logger.warn( + "🐋 No Docker registry credentials or AWS credentials provided, skipping auth" + ); } } @@ -160,9 +167,12 @@ export class DockerWorkloadManager implements WorkloadManager { imageArchitecture: inspectResult?.Architecture, }); + // Get auth config (static or ECR) + const authConfig = await this.getAuthConfig(); + // Ensure the image is present const [createImageError, imageResponseReader] = await tryCatch( - this.docker.createImage(this.auth, { + this.docker.createImage(authConfig, { fromImage: imageRef, ...(this.platformOverride ? { platform: this.platformOverride } : {}), }) @@ -216,6 +226,26 @@ export class DockerWorkloadManager implements WorkloadManager { logger.debug("create succeeded", { startResult, containerId: container.id }); } + /** + * Get authentication config for Docker operations + * Uses static credentials if available, otherwise attempts ECR auth + */ + private async getAuthConfig(): Promise { + // Use static credentials if available + if (this.staticAuth) { + return this.staticAuth; + } + + // Use ECR auth if service is available + if (this.ecrAuthService) { + const ecrAuth = await this.ecrAuthService.getAuthConfig(); + return ecrAuth || undefined; + } + + // No auth available + return undefined; + } + private async attachContainerToNetworks({ containerId, networkNames, diff --git a/apps/supervisor/src/workloadManager/ecrAuth.ts b/apps/supervisor/src/workloadManager/ecrAuth.ts new file mode 100644 index 0000000000..33e98f6319 --- /dev/null +++ b/apps/supervisor/src/workloadManager/ecrAuth.ts @@ -0,0 +1,144 @@ +import { ECRClient, GetAuthorizationTokenCommand } from "@aws-sdk/client-ecr"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { tryCatch } from "@trigger.dev/core"; +import Docker from "dockerode"; + +interface ECRTokenCache { + token: string; + username: string; + serverAddress: string; + expiresAt: Date; +} + +export class ECRAuthService { + private readonly logger = new SimpleStructuredLogger("ecr-auth-service"); + private readonly ecrClient: ECRClient; + private tokenCache: ECRTokenCache | null = null; + + constructor() { + this.ecrClient = new ECRClient(); + + this.logger.info("🔐 ECR Auth Service initialized", { + region: this.ecrClient.config.region, + }); + } + + /** + * Check if we have AWS credentials configured + */ + static hasAWSCredentials(): boolean { + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + return true; + } + + if ( + process.env.AWS_PROFILE || + process.env.AWS_ROLE_ARN || + process.env.AWS_WEB_IDENTITY_TOKEN_FILE + ) { + return true; + } + + return false; + } + + /** + * Check if the current token is still valid with a 10-minute buffer + */ + private isTokenValid(): boolean { + if (!this.tokenCache) { + return false; + } + + const now = new Date(); + const bufferMs = 10 * 60 * 1000; // 10 minute buffer before expiration + return now < new Date(this.tokenCache.expiresAt.getTime() - bufferMs); + } + + /** + * Get a fresh ECR authorization token from AWS + */ + private async fetchNewToken(): Promise { + const [error, response] = await tryCatch( + this.ecrClient.send(new GetAuthorizationTokenCommand({})) + ); + + if (error) { + this.logger.error("Failed to get ECR authorization token", { error }); + return null; + } + + const authData = response.authorizationData?.[0]; + if (!authData?.authorizationToken || !authData.proxyEndpoint) { + this.logger.error("Invalid ECR authorization response", { authData }); + return null; + } + + // Decode the base64 token to get username:password + const decoded = Buffer.from(authData.authorizationToken, "base64").toString("utf-8"); + const [username, password] = decoded.split(":", 2); + + if (!username || !password) { + this.logger.error("Failed to parse ECR authorization token"); + return null; + } + + const expiresAt = authData.expiresAt || new Date(Date.now() + 12 * 60 * 60 * 1000); // Default 12 hours + + const tokenCache: ECRTokenCache = { + token: password, + username, + serverAddress: authData.proxyEndpoint, + expiresAt, + }; + + this.logger.info("🔐 Successfully fetched ECR token", { + username, + serverAddress: authData.proxyEndpoint, + expiresAt: expiresAt.toISOString(), + }); + + return tokenCache; + } + + /** + * Get ECR auth config for Docker operations + * Returns cached token if valid, otherwise fetches a new one + */ + async getAuthConfig(): Promise { + // Check if cached token is still valid + if (this.isTokenValid()) { + this.logger.debug("Using cached ECR token"); + return { + username: this.tokenCache!.username, + password: this.tokenCache!.token, + serveraddress: this.tokenCache!.serverAddress, + }; + } + + // Fetch new token + this.logger.info("Fetching new ECR authorization token"); + const newToken = await this.fetchNewToken(); + + if (!newToken) { + return null; + } + + // Cache the new token + this.tokenCache = newToken; + + return { + username: newToken.username, + password: newToken.token, + serveraddress: newToken.serverAddress, + }; + } + + /** + * Clear the cached token (useful for testing or forcing refresh) + */ + clearCache(): void { + this.tokenCache = null; + this.logger.debug("ECR token cache cleared"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6decddb309..427f22076b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: apps/supervisor: dependencies: + '@aws-sdk/client-ecr': + specifier: ^3.839.0 + version: 3.839.0 '@kubernetes/client-node': specifier: ^1.0.0 version: 1.0.0(patch_hash=s75bgwaoixupmywtvgoy5ruszq) @@ -3156,7 +3159,7 @@ packages: resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} dependencies: '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.451.0 + '@aws-sdk/types': 3.840.0 tslib: 1.14.1 dev: false @@ -3223,7 +3226,7 @@ packages: /@aws-crypto/util@3.0.0: resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==} dependencies: - '@aws-sdk/types': 3.451.0 + '@aws-sdk/types': 3.840.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 dev: false @@ -3231,7 +3234,7 @@ packages: /@aws-crypto/util@5.2.0: resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} dependencies: - '@aws-sdk/types': 3.714.0 + '@aws-sdk/types': 3.840.0 '@smithy/util-utf8': 2.0.2 tslib: 2.8.1 dev: false