import { ECRClient, CreateRepositoryCommand, DescribeRepositoriesCommand, type Repository, type Tag, RepositoryNotFoundException, GetAuthorizationTokenCommand, PutLifecyclePolicyCommand, PutImageTagMutabilityCommand, SetRepositoryPolicyCommand, } from "@aws-sdk/client-ecr"; import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts"; import { tryCatch } from "@trigger.dev/core"; import { logger } from "~/services/logger.server"; import { type RegistryConfig } from "./registryConfig.server"; import type { EnvironmentType } from "@trigger.dev/core/v3"; // Optional configuration for cross-account access export type AssumeRoleConfig = { roleArn?: string; externalId?: string; }; async function getAssumedRoleCredentials({ region, assumeRole, }: { region: string; assumeRole?: AssumeRoleConfig; }): Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken: string; }> { const sts = new STSClient({ region }); // Generate a unique session name using timestamp and random string // This helps with debugging but doesn't affect concurrent sessions const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(2, 8); const sessionName = `TriggerWebappECRAccess_${timestamp}_${randomSuffix}`; const [error, response] = await tryCatch( sts.send( new AssumeRoleCommand({ RoleArn: assumeRole?.roleArn, RoleSessionName: sessionName, // Sessions automatically expire after 1 hour // AWS allows 5000 concurrent sessions by default DurationSeconds: 3600, ExternalId: assumeRole?.externalId, }) ) ); if (error) { logger.error("Failed to assume role", { assumeRole, sessionName, error: error.message, }); throw error; } if (!response.Credentials) { throw new Error("STS: No credentials returned from assumed role"); } if ( !response.Credentials.AccessKeyId || !response.Credentials.SecretAccessKey || !response.Credentials.SessionToken ) { throw new Error("STS: Invalid credentials returned from assumed role"); } return { accessKeyId: response.Credentials.AccessKeyId, secretAccessKey: response.Credentials.SecretAccessKey, sessionToken: response.Credentials.SessionToken, }; } export async function createEcrClient({ region, assumeRole, }: { region: string; assumeRole?: AssumeRoleConfig; }) { if (!assumeRole) { return new ECRClient({ region }); } // Get credentials for cross-account access const credentials = await getAssumedRoleCredentials({ region, assumeRole }); return new ECRClient({ region, credentials, }); } export async function getDeploymentImageRef({ registry, projectRef, nextVersion, environmentType, deploymentShortCode, }: { registry: RegistryConfig; projectRef: string; nextVersion: string; environmentType: EnvironmentType; deploymentShortCode: string; }): Promise<{ imageRef: string; isEcr: boolean; repoCreated: boolean; }> { const repositoryName = `${registry.namespace}/${projectRef}`; const envType = environmentType.toLowerCase(); const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${envType}.${deploymentShortCode}`; if (!isEcrRegistry(registry.host)) { return { imageRef, isEcr: false, repoCreated: false, }; } const [ecrRepoError, ecrData] = await tryCatch( ensureEcrRepositoryExists({ repositoryName, registryHost: registry.host, registryTags: registry.ecrTags, assumeRole: { roleArn: registry.ecrAssumeRoleArn, externalId: registry.ecrAssumeRoleExternalId, }, defaultRepositoryPolicy: registry.ecrDefaultRepositoryPolicy, }) ); if (ecrRepoError) { logger.error("Failed to ensure ECR repository exists", { repositoryName, host: registry.host, ecrRepoError: ecrRepoError.message, }); throw ecrRepoError; } return { imageRef, isEcr: true, repoCreated: ecrData.repoCreated, }; } export function isEcrRegistry(registryHost: string) { try { parseEcrRegistryDomain(registryHost); return true; } catch { return false; } } export function parseRegistryTags(tags: string): Tag[] { if (!tags) { return []; } return tags .split(",") .map((t) => { const tag = t.trim(); if (tag.length === 0) { return null; } // If there's no '=' in the tag, treat the whole tag as the key with an empty value const equalIndex = tag.indexOf("="); const key = equalIndex === -1 ? tag : tag.slice(0, equalIndex); const value = equalIndex === -1 ? "" : tag.slice(equalIndex + 1); if (key.trim().length === 0) { logger.warn("Invalid ECR tag format (empty key), skipping tag", { tag: t }); return null; } return { Key: key.trim(), Value: value.trim(), } as Tag; }) .filter((tag): tag is Tag => tag !== null); } const untaggedImageExpirationPolicy = JSON.stringify({ rules: [ { rulePriority: 1, description: "Expire untagged images older than 3 days", selection: { tagStatus: "untagged", countType: "sinceImagePushed", countUnit: "days", countNumber: 3, }, action: { type: "expire" }, }, ], }); async function createEcrRepository({ repositoryName, region, accountId, registryTags, assumeRole, defaultRepositoryPolicy, }: { repositoryName: string; region: string; accountId?: string; registryTags?: string; assumeRole?: AssumeRoleConfig; defaultRepositoryPolicy?: string; }): Promise { const ecr = await createEcrClient({ region, assumeRole }); const result = await ecr.send( new CreateRepositoryCommand({ repositoryName, imageTagMutability: "IMMUTABLE_WITH_EXCLUSION", imageTagMutabilityExclusionFilters: [ { // only the `cache` tag will be mutable, all other tags will be immutable filter: "cache", filterType: "WILDCARD", }, ], encryptionConfiguration: { encryptionType: "AES256", }, registryId: accountId, tags: registryTags ? parseRegistryTags(registryTags) : undefined, }) ); if (!result.repository) { logger.error("Failed to create ECR repository", { repositoryName, result }); throw new Error(`Failed to create ECR repository: ${repositoryName}`); } // When the `cache` tag is mutated, the old cache images are untagged. // This policy matches those images and expires them to avoid bloating the repository. await ecr.send( new PutLifecyclePolicyCommand({ repositoryName: result.repository.repositoryName, registryId: result.repository.registryId, lifecyclePolicyText: untaggedImageExpirationPolicy, }) ); // Apply an operator-provided IAM policy to the new repository. Useful for // self-hosters whose ECR account is separate from the account running the // EKS workers — without this the workers get 403 Forbidden when pulling the // task image (default ECR policy only grants access to the registry owner). // The existing-repo branch of `ensureEcrRepositoryExists` reconciles this // same policy on every call, so a partial-create that fails here is // self-healing on the next deploy. if (defaultRepositoryPolicy) { await applyEcrRepositoryPolicy({ repositoryName: result.repository.repositoryName!, region, accountId: result.repository.registryId ?? accountId, assumeRole, defaultRepositoryPolicy, }); } return result.repository; } async function applyEcrRepositoryPolicy({ repositoryName, region, accountId, assumeRole, defaultRepositoryPolicy, }: { repositoryName: string; region: string; accountId?: string; assumeRole?: AssumeRoleConfig; defaultRepositoryPolicy: string; }): Promise { const ecr = await createEcrClient({ region, assumeRole }); await ecr.send( new SetRepositoryPolicyCommand({ repositoryName, registryId: accountId, policyText: defaultRepositoryPolicy, }) ); } async function updateEcrRepositoryCacheSettings({ repositoryName, region, accountId, assumeRole, }: { repositoryName: string; region: string; accountId?: string; assumeRole?: AssumeRoleConfig; }): Promise { logger.debug("Updating ECR repository tag mutability to IMMUTABLE_WITH_EXCLUSION", { repositoryName, region, }); const ecr = await createEcrClient({ region, assumeRole }); await ecr.send( new PutImageTagMutabilityCommand({ repositoryName, registryId: accountId, imageTagMutability: "IMMUTABLE_WITH_EXCLUSION", imageTagMutabilityExclusionFilters: [ { // only the `cache` tag will be mutable, all other tags will be immutable filter: "cache", filterType: "WILDCARD", }, ], }) ); // When the `cache` tag is mutated, the old cache images are untagged. // This policy matches those images and expires them to avoid bloating the repository. await ecr.send( new PutLifecyclePolicyCommand({ repositoryName, registryId: accountId, lifecyclePolicyText: untaggedImageExpirationPolicy, }) ); logger.debug("Successfully updated ECR repository to IMMUTABLE_WITH_EXCLUSION", { repositoryName, region, }); } async function getEcrRepository({ repositoryName, region, accountId, assumeRole, }: { repositoryName: string; region: string; accountId?: string; assumeRole?: AssumeRoleConfig; }): Promise { const ecr = await createEcrClient({ region, assumeRole }); try { const result = await ecr.send( new DescribeRepositoriesCommand({ repositoryNames: [repositoryName], registryId: accountId, }) ); if (!result.repositories || result.repositories.length === 0) { logger.debug("ECR repository not found", { repositoryName, region, result }); return undefined; } return result.repositories[0]; } catch (error) { if ( error instanceof RepositoryNotFoundException || (error instanceof Error && error.message?.includes("does not exist")) ) { logger.debug("ECR repository not found: RepositoryNotFoundException", { repositoryName, region, }); return undefined; } throw error; } } export type EcrRegistryComponents = { accountId: string; region: string; }; export function parseEcrRegistryDomain(registryHost: string): EcrRegistryComponents { const parts = registryHost.split("."); const isValid = parts.length === 6 && parts[1] === "dkr" && parts[2] === "ecr" && parts[4] === "amazonaws" && parts[5] === "com"; if (!isValid) { throw new Error(`Invalid ECR registry host: ${registryHost}`); } return { accountId: parts[0], region: parts[3], }; } async function ensureEcrRepositoryExists({ repositoryName, registryHost, registryTags, assumeRole, defaultRepositoryPolicy, }: { repositoryName: string; registryHost: string; registryTags?: string; assumeRole?: AssumeRoleConfig; defaultRepositoryPolicy?: string; }): Promise<{ repo: Repository; repoCreated: boolean }> { const { region, accountId } = parseEcrRegistryDomain(registryHost); const [getRepoError, existingRepo] = await tryCatch( getEcrRepository({ repositoryName, region, accountId, assumeRole }) ); if (getRepoError) { logger.error("Failed to get ECR repository", { repositoryName, region, getRepoError }); throw getRepoError; } if (existingRepo) { logger.debug("ECR repository already exists", { repositoryName, region, existingRepo }); // check if the repository is missing the cache settings if (existingRepo.imageTagMutability === "IMMUTABLE") { const [updateError] = await tryCatch( updateEcrRepositoryCacheSettings({ repositoryName, region, accountId, assumeRole }) ); if (updateError) { logger.error("Failed to update ECR repository cache settings", { repositoryName, region, updateError, }); } } // Reconcile the default repository policy on every call. Idempotent, and // covers two recovery cases: (1) a previous create succeeded but the // SetRepositoryPolicy call failed mid-flight, leaving the repo without a // policy; (2) the operator updated DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY // and existing repos need to pick up the new value. if (defaultRepositoryPolicy) { const [policyError] = await tryCatch( applyEcrRepositoryPolicy({ repositoryName, region, accountId, assumeRole, defaultRepositoryPolicy, }) ); if (policyError) { logger.error("Failed to reconcile ECR repository policy on existing repo", { repositoryName, region, policyError, }); } } return { repo: existingRepo, repoCreated: false, }; } const [createRepoError, newRepo] = await tryCatch( createEcrRepository({ repositoryName, region, accountId, registryTags, assumeRole, defaultRepositoryPolicy, }) ); if (createRepoError) { logger.error("Failed to create ECR repository", { repositoryName, region, createRepoError }); throw createRepoError; } if (newRepo.repositoryName !== repositoryName) { logger.error("ECR repository name mismatch", { repositoryName, region, newRepo }); throw new Error( `ECR repository name mismatch: ${repositoryName} !== ${newRepo.repositoryName}` ); } return { repo: newRepo, repoCreated: true, }; } export async function getEcrAuthToken({ registryHost, assumeRole, }: { registryHost: string; assumeRole?: AssumeRoleConfig; }): Promise<{ username: string; password: string }> { const { region, accountId } = parseEcrRegistryDomain(registryHost); if (!region) { logger.error("Invalid ECR registry host", { registryHost }); throw new Error("Invalid ECR registry host"); } const ecr = await createEcrClient({ region, assumeRole }); const response = await ecr.send( new GetAuthorizationTokenCommand({ registryIds: accountId ? [accountId] : undefined, }) ); if (!response.authorizationData) { throw new Error("Failed to get ECR authorization token"); } const authData = response.authorizationData[0]; if (!authData.authorizationToken) { throw new Error("No authorization token returned from ECR"); } const authToken = Buffer.from(authData.authorizationToken, "base64").toString(); const [username, password] = authToken.split(":"); return { username, password }; }