-
-
Notifications
You must be signed in to change notification settings - Fork 853
Add ECR support for remote builds #2224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
e16c43c
create repo if doesn't exist
nicktrn eb99545
fresh auth token for each deploy
nicktrn e9fc7af
Merge remote-tracking branch 'origin/main' into feat/arm64-registry
nicktrn 14cbc22
optional assume role
nicktrn a93b54c
Merge remote-tracking branch 'origin/main' into feat/ecr-support
nicktrn ef87110
log when machine overrides enabled
nicktrn 372c7ce
make test repo namespace configurable
nicktrn 512d682
assume role fix and env var changes
nicktrn 6202130
Merge remote-tracking branch 'origin/main' into feat/ecr-support
nicktrn 2eae336
improve ecr check
nicktrn 1c82f72
improve tag parsing
nicktrn e65875d
tag parsing tests
nicktrn ba68d19
track if repo created and fix test
nicktrn 210984a
missing tryCatch for sts call
nicktrn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,341 @@ | ||
import { | ||
ECRClient, | ||
CreateRepositoryCommand, | ||
DescribeRepositoriesCommand, | ||
type Repository, | ||
type Tag, | ||
RepositoryNotFoundException, | ||
GetAuthorizationTokenCommand, | ||
} 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"; | ||
|
||
// 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}`; | ||
|
||
try { | ||
const response = await sts.send( | ||
nicktrn marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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 (!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, | ||
}; | ||
} catch (error) { | ||
logger.error("Failed to assume role", { | ||
assumeRole, | ||
sessionName, | ||
error, | ||
}); | ||
throw error; | ||
} | ||
} | ||
|
||
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({ | ||
host, | ||
namespace, | ||
projectRef, | ||
nextVersion, | ||
environmentSlug, | ||
registryTags, | ||
assumeRole, | ||
}: { | ||
host: string; | ||
namespace: string; | ||
projectRef: string; | ||
nextVersion: string; | ||
environmentSlug: string; | ||
registryTags?: string; | ||
assumeRole?: AssumeRoleConfig; | ||
}): Promise<{ | ||
imageRef: string; | ||
isEcr: boolean; | ||
}> { | ||
const repositoryName = `${namespace}/${projectRef}`; | ||
const imageRef = `${host}/${repositoryName}:${nextVersion}.${environmentSlug}`; | ||
|
||
if (!isEcrRegistry(host)) { | ||
return { | ||
imageRef, | ||
isEcr: false, | ||
}; | ||
} | ||
|
||
const [ecrRepoError] = await tryCatch( | ||
ensureEcrRepositoryExists({ | ||
repositoryName, | ||
registryHost: host, | ||
registryTags, | ||
assumeRole, | ||
}) | ||
); | ||
|
||
if (ecrRepoError) { | ||
logger.error("Failed to ensure ECR repository exists", { | ||
repositoryName, | ||
host, | ||
ecrRepoError: ecrRepoError.message, | ||
}); | ||
throw ecrRepoError; | ||
} | ||
|
||
return { | ||
imageRef, | ||
isEcr: true, | ||
}; | ||
} | ||
|
||
export function isEcrRegistry(registryHost: string) { | ||
return registryHost.includes("amazonaws.com"); | ||
|
||
} | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
function parseRegistryTags(tags: string): Tag[] { | ||
return tags.split(",").map((tag) => { | ||
const [key, value] = tag.split("="); | ||
return { Key: key, Value: value }; | ||
}); | ||
} | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
async function createEcrRepository({ | ||
repositoryName, | ||
region, | ||
accountId, | ||
registryTags, | ||
assumeRole, | ||
}: { | ||
repositoryName: string; | ||
region: string; | ||
accountId?: string; | ||
registryTags?: string; | ||
assumeRole?: AssumeRoleConfig; | ||
}): Promise<Repository> { | ||
const ecr = await createEcrClient({ region, assumeRole }); | ||
|
||
const result = await ecr.send( | ||
new CreateRepositoryCommand({ | ||
repositoryName, | ||
imageTagMutability: "IMMUTABLE", | ||
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}`); | ||
} | ||
|
||
return result.repository; | ||
} | ||
|
||
async function getEcrRepository({ | ||
repositoryName, | ||
region, | ||
accountId, | ||
assumeRole, | ||
}: { | ||
repositoryName: string; | ||
region: string; | ||
accountId?: string; | ||
assumeRole?: AssumeRoleConfig; | ||
}): Promise<Repository | undefined> { | ||
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) { | ||
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, | ||
}: { | ||
repositoryName: string; | ||
registryHost: string; | ||
registryTags?: string; | ||
assumeRole?: AssumeRoleConfig; | ||
}): Promise<Repository> { | ||
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 }); | ||
return existingRepo; | ||
} | ||
|
||
const [createRepoError, newRepo] = await tryCatch( | ||
createEcrRepository({ repositoryName, region, accountId, registryTags, assumeRole }) | ||
); | ||
|
||
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 newRepo; | ||
} | ||
|
||
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 }; | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.