Repository navigation
feat(trigger): reach AWS from trigger.dev runs through workload identity #9578
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 all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0cfaa41
feat(trigger): reach AWS from trigger.dev runs through workload identity
waleedlatif1 d999ce2
chore(deps): allow trigger.dev 4.8.3 ahead of the release-age window
waleedlatif1 e93a105
refactor(trigger): run the per-attempt setup through one module
waleedlatif1 719cb74
fix(aws): keep the default chain outside workload identity and cover ses
waleedlatif1 e47cd97
test(uploads): give the presigner credentials where the client now re…
waleedlatif1 1e97ce1
fix(aws): serve unexpired workload credentials when an exchange fails
waleedlatif1 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
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 |
|---|---|---|
| @@ -1,24 +1,126 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { type AWSCredentials, awsCredentialProvider } from '@trigger.dev/sdk/oidc/aws' | ||
| import { env } from '@/lib/core/config/env' | ||
|
|
||
| interface AwsCredentials { | ||
| accessKeyId: string | ||
| secretAccessKey: string | ||
| const logger = createLogger('AwsCredentials') | ||
|
|
||
| type AwsCredentialsProvider = () => Promise<AWSCredentials> | ||
|
|
||
| /** | ||
| * Refresh workload credentials this long before they expire. It must exceed the AWS SDK's own | ||
| * five-minute refresh window, or a client keeps asking for credentials this module still | ||
| * considers fresh. | ||
| */ | ||
| const REFRESH_BEFORE_EXPIRY_MS = 10 * 60_000 | ||
|
|
||
| /** | ||
| * At the start of a run, refresh workload credentials that expire within this window. A token can | ||
| * only be minted while an attempt is executing, and work a run leaves behind (the hosted-key | ||
| * metrics flush) still calls AWS after the attempt ends. | ||
| */ | ||
| const RUN_START_MIN_REMAINING_MS = 30 * 60_000 | ||
|
|
||
| /** | ||
| * One AWS session for the whole worker process, not one per run. Every run in a deployment | ||
| * assumes the same role, and Sim's AWS clients are process-wide, so a run-named session would | ||
| * attribute later runs' calls to the run that minted it. | ||
| */ | ||
| const WORKLOAD_ROLE_SESSION_NAME = 'sim-trigger-worker' | ||
|
|
||
| let workloadProvider: AwsCredentialsProvider | null = null | ||
| let currentWorkloadCredentials: AWSCredentials | null = null | ||
| let pendingWorkloadCredentials: Promise<AWSCredentials> | null = null | ||
|
|
||
| function getWorkloadProvider(roleArn: string): AwsCredentialsProvider { | ||
| if (!workloadProvider) { | ||
| if (!env.AWS_REGION) { | ||
| throw new Error('TRIGGER_AWS_ROLE_ARN is set but AWS_REGION is not; set both.') | ||
| } | ||
| workloadProvider = awsCredentialProvider({ | ||
| roleArn, | ||
| region: env.AWS_REGION, | ||
| roleSessionName: WORKLOAD_ROLE_SESSION_NAME, | ||
| }) | ||
| } | ||
| return workloadProvider | ||
| } | ||
|
|
||
| /** Credentials this close to expiry are treated as expired, so a signed request never outlives them. */ | ||
| const EXPIRY_SKEW_MS = 60_000 | ||
|
|
||
| function remainingMs(credentials: AWSCredentials | null): number { | ||
| return credentials ? credentials.expiration.getTime() - Date.now() : 0 | ||
| } | ||
|
|
||
| /** | ||
| * The cached workload credentials while they have more than `minRemainingMs` left, otherwise a | ||
| * fresh exchange. Concurrent callers share one exchange. When the exchange fails, still-valid | ||
| * cached credentials are served instead: outside an executing attempt no token can be minted, and | ||
| * work a run leaves behind (the metrics flush) must not lose credentials that have not expired. | ||
| */ | ||
| function resolveWorkloadCredentials( | ||
| roleArn: string, | ||
| minRemainingMs: number | ||
| ): Promise<AWSCredentials> { | ||
| const current = currentWorkloadCredentials | ||
| if (current && remainingMs(current) > minRemainingMs) { | ||
| return Promise.resolve(current) | ||
| } | ||
| if (!pendingWorkloadCredentials) { | ||
| pendingWorkloadCredentials = getWorkloadProvider(roleArn)() | ||
| .then((credentials) => { | ||
| currentWorkloadCredentials = credentials | ||
| return credentials | ||
| }) | ||
| .catch((error: unknown) => { | ||
| const cached = currentWorkloadCredentials | ||
| if (!cached || remainingMs(cached) <= EXPIRY_SKEW_MS) throw error | ||
| logger.warn('Workload-identity exchange failed; serving cached credentials until expiry', { | ||
| error, | ||
| remainingMs: remainingMs(cached), | ||
| }) | ||
| return cached | ||
| }) | ||
| .finally(() => { | ||
| pendingWorkloadCredentials = null | ||
| }) | ||
| } | ||
| return pendingWorkloadCredentials | ||
| } | ||
|
|
||
| /** | ||
| * Explicit AWS credentials from the environment, or `undefined` to defer to the | ||
| * default AWS provider chain (the ECS task role in our deployments). | ||
| * Credentials for Sim's own AWS clients: file storage (S3), AppConfig, email (SES), hosted-key | ||
| * metrics and hosted Bedrock. Never for a user's integration, which brings its own keys. | ||
| * | ||
| * Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is | ||
| * identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and | ||
| * `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise | ||
| * the instance/task role. | ||
| * With `TRIGGER_AWS_ROLE_ARN` set (deployed Trigger.dev runs), temporary credentials for that | ||
| * role, exchanged for the run's Trigger.dev workload-identity token. It takes precedence over any | ||
| * static keys in the environment, so a failed exchange surfaces instead of silently falling back | ||
| * to a stored key. Otherwise `undefined`: the AWS SDK's default chain, which reads | ||
| * `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`, then shared profiles, then | ||
| * the ECS task role. | ||
| */ | ||
| export function getAwsCredentials(): AwsCredentialsProvider | undefined { | ||
| const roleArn = env.TRIGGER_AWS_ROLE_ARN | ||
| if (!roleArn) return undefined | ||
| return () => resolveWorkloadCredentials(roleArn, REFRESH_BEFORE_EXPIRY_MS) | ||
| } | ||
|
|
||
| /** Whether Sim's own AWS clients have credentials of their own rather than the default chain. */ | ||
| export function hasExplicitAwsCredentials(): boolean { | ||
| return Boolean(env.TRIGGER_AWS_ROLE_ARN || (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)) | ||
| } | ||
|
|
||
| /** | ||
| * Refreshes workload credentials at the start of a Trigger.dev run, while a token can be minted, | ||
| * so they outlive the attempt. A no-op outside workload identity. Never throws: a failure is | ||
| * logged here, and the run's first AWS call retries the exchange and fails with the cause. | ||
| */ | ||
| export function getAwsCredentialsFromEnv(): AwsCredentials | undefined { | ||
| return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY | ||
| ? { | ||
| accessKeyId: env.AWS_ACCESS_KEY_ID, | ||
| secretAccessKey: env.AWS_SECRET_ACCESS_KEY, | ||
| } | ||
| : undefined | ||
| export async function refreshAwsCredentialsForRun(): Promise<void> { | ||
| const roleArn = env.TRIGGER_AWS_ROLE_ARN | ||
| if (!roleArn) return | ||
| try { | ||
| await resolveWorkloadCredentials(roleArn, RUN_START_MIN_REMAINING_MS) | ||
| } catch (error) { | ||
| logger.error('Could not exchange the workload-identity token for AWS credentials', { error }) | ||
| } | ||
| } | ||
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,13 @@ | ||
| import { refreshAwsCredentialsForRun } from '@/lib/core/config/aws' | ||
| import { warmRedisConnection } from '@/lib/core/config/redis' | ||
|
|
||
| /** | ||
| * The per-attempt setup the Trigger.dev `init` hook awaits before `run()`. Warms the shared Redis | ||
| * connection, and refreshes workload-identity AWS credentials while the attempt is executing and a | ||
| * token can still be minted, so work a run leaves behind (the hosted-key metrics flush) has | ||
| * credentials after the attempt ends. Never throws, like each step: a throw in `init` fails the | ||
| * attempt. | ||
| */ | ||
| export async function prepareTriggerAttempt(): Promise<void> { | ||
| await Promise.all([warmRedisConnection(), refreshAwsCredentialsForRun()]) | ||
| } |
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
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
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
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.