From 1de339997b38faaa9c20ef19f3aa1327653390e6 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 16 Jul 2026 22:19:35 -0400 Subject: [PATCH] fix(deploy): fail fast on AWS account mismatch --- .../aws/__tests__/account-extended.test.ts | 18 ++++++++- src/cli/aws/account.ts | 12 +++++- src/cli/commands/deploy/actions.ts | 4 +- .../deploy/__tests__/preflight.test.ts | 37 +++++++++++++++++++ src/cli/operations/deploy/preflight.ts | 4 +- src/cli/tui/hooks/useCdkPreflight.ts | 2 +- 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/cli/aws/__tests__/account-extended.test.ts b/src/cli/aws/__tests__/account-extended.test.ts index c859634a8..5f9a7376a 100644 --- a/src/cli/aws/__tests__/account-extended.test.ts +++ b/src/cli/aws/__tests__/account-extended.test.ts @@ -1,4 +1,4 @@ -import { AwsCredentialsError } from '../../../lib/errors/types.js'; +import { AwsCredentialsError, ValidationError } from '../../../lib/errors/types.js'; import { detectAccount, getCredentialProvider, validateAwsCredentials } from '../account.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -116,6 +116,22 @@ describe('validateAwsCredentials', () => { await expect(validateAwsCredentials()).resolves.toBeUndefined(); }); + it('does not throw when credentials match the deployment target', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + + await expect(validateAwsCredentials({ name: 'prod', account: '123456789012' })).resolves.toBeUndefined(); + }); + + it('throws a clear error when credentials do not match the deployment target', async () => { + mockSend.mockResolvedValue({ Account: '111111111111' }); + + const validation = validateAwsCredentials({ name: 'prod', account: '222222222222' }); + await expect(validation).rejects.toBeInstanceOf(ValidationError); + await expect(validation).rejects.toThrow( + 'Your AWS credentials are for account 111111111111, but the target "prod" is configured for account 222222222222.' + ); + }); + it('throws AwsCredentialsError when detectAccount returns null', async () => { mockSend.mockRejectedValue(new Error('something')); diff --git a/src/cli/aws/account.ts b/src/cli/aws/account.ts index 8e962cfb3..5641b3c49 100644 --- a/src/cli/aws/account.ts +++ b/src/cli/aws/account.ts @@ -1,4 +1,5 @@ -import { AwsCredentialsError } from '../../lib/errors/types.js'; +import { AwsCredentialsError, ValidationError } from '../../lib/errors/types.js'; +import type { AwsDeploymentTarget } from '../../schema'; import { getAwsLoginGuidance } from '../external-requirements/checks'; import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; import { fromEnv, fromNodeProviderChain } from '@aws-sdk/credential-providers'; @@ -61,9 +62,10 @@ export async function detectAccount(): Promise { /** * Validate that AWS credentials are configured and working. + * When a target is provided, also verify that the credentials belong to its account. * Throws AwsCredentialsError with a helpful message if not. */ -export async function validateAwsCredentials(): Promise { +export async function validateAwsCredentials(target?: Pick): Promise { const account = await detectAccount(); if (!account) { const guidance = await getAwsLoginGuidance(); @@ -75,4 +77,10 @@ export async function validateAwsCredentials(): Promise { ' 2. Or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables' ); } + + if (target?.account && account !== target.account) { + throw new ValidationError( + `Your AWS credentials are for account ${account}, but the target "${target.name}" is configured for account ${target.account}.\nEnsure your credentials match the deployment target.` + ); + } } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 841355934..23467ce98 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -218,7 +218,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { expect(result.isTeardownDeploy).toBe(false); }); + it('validates credentials against the selected deployment target', async () => { + const selectedTarget = { name: 'prod', account: '222222222222', region: 'us-east-1' } as const; + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [{ name: 'test-agent' }], + agentCoreGateways: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([ + { name: 'default', account: '111111111111', region: 'us-west-2' }, + selectedTarget, + ]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + await validateProject(selectedTarget); + + expect(mockValidateAwsCredentials).toHaveBeenCalledWith(selectedTarget); + }); + + it('validates credentials against the first target when none is selected', async () => { + const firstTarget = { name: 'default', account: '111111111111', region: 'us-west-2' } as const; + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [{ name: 'test-agent' }], + agentCoreGateways: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([firstTarget]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + await validateProject(); + + expect(mockValidateAwsCredentials).toHaveBeenCalledWith(firstTarget); + }); + it('accepts gateway target name within 48 chars when prefixed with project name', async () => { mockRequireConfigRoot.mockReturnValue('/project/agentcore'); mockValidate.mockReturnValue(undefined); diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 5700649b7..75c34b41a 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -72,7 +72,7 @@ export function formatError(err: unknown): string { * Also validates AWS credentials are configured before proceeding. * Returns the project context needed for subsequent steps. */ -export async function validateProject(): Promise { +export async function validateProject(selectedTarget?: AwsDeploymentTarget): Promise { // Find the agentcore config directory, walking up from cwd if needed const configRoot = requireConfigRoot(); // Project root is the parent of the agentcore directory @@ -152,7 +152,7 @@ export async function validateProject(): Promise { // Validate AWS credentials before proceeding with build/synth. // Skip for teardown deploys — callers validate after teardown confirmation. if (!isTeardownDeploy) { - await validateAwsCredentials(); + await validateAwsCredentials(selectedTarget ?? awsTargets[0]); } return { projectSpec, awsTargets, cdkProject, isTeardownDeploy, isFirstDeploy: !hasExistingStack }; diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index b2462130e..cecedcc99 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -441,7 +441,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { // Validate AWS credentials (deferred for teardown deploys until after confirmation) if (preflightContext.isTeardownDeploy) { try { - await validateAwsCredentials(); + await validateAwsCredentials(preflightContext.awsTargets[0]); } catch (err) { const errorMsg = formatError(err); logger.endStep('error', errorMsg);