Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/cli/aws/__tests__/account-extended.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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'));

Expand Down
12 changes: 10 additions & 2 deletions src/cli/aws/account.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -61,9 +62,10 @@ export async function detectAccount(): Promise<string | null> {

/**
* 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<void> {
export async function validateAwsCredentials(target?: Pick<AwsDeploymentTarget, 'name' | 'account'>): Promise<void> {
const account = await detectAccount();
if (!account) {
const guidance = await getAwsLoginGuidance();
Expand All @@ -75,4 +77,10 @@ export async function validateAwsCredentials(): Promise<void> {
' 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.`
);
}
}
4 changes: 2 additions & 2 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep

// Preflight: validate project
startStep('Validate project');
const context = await validateProject();
const context = await validateProject(target);
endStep('success');

// Warn about imperative-build orphan harnesses (preview→GA transition). These aren't
Expand Down Expand Up @@ -257,7 +257,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
// Validate AWS credentials (deferred for teardown deploys until after confirmation)
if (context.isTeardownDeploy) {
startStep('Validate AWS credentials');
await validateAwsCredentials();
await validateAwsCredentials(target);
endStep('success');
}

Expand Down
37 changes: 37 additions & 0 deletions src/cli/operations/deploy/__tests__/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,43 @@ describe('validateProject', () => {
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);
Expand Down
4 changes: 2 additions & 2 deletions src/cli/operations/deploy/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreflightContext> {
export async function validateProject(selectedTarget?: AwsDeploymentTarget): Promise<PreflightContext> {
// Find the agentcore config directory, walking up from cwd if needed
const configRoot = requireConfigRoot();
// Project root is the parent of the agentcore directory
Expand Down Expand Up @@ -152,7 +152,7 @@ export async function validateProject(): Promise<PreflightContext> {
// 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 };
Expand Down
2 changes: 1 addition & 1 deletion src/cli/tui/hooks/useCdkPreflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this validation be an issue if the selected target is different than the first target? I see above we're doing selectedTarget ?? awsTargets[0] but not here.

} catch (err) {
const errorMsg = formatError(err);
logger.endStep('error', errorMsg);
Expand Down
Loading