Skip to content

Commit ffccd6c

Browse files
committed
fix(deploy): honor aws-targets.json region for all SDK and CDK calls (#924)
AWS SDK clients constructed by @aws-cdk/toolkit-lib internally (for CloudFormation, S3 asset upload, etc.) do not receive an explicit region option and fall back to the SDK's default region resolution chain (AWS_REGION -> AWS_DEFAULT_REGION -> shared config). When a user's aws-targets.json specified a non-default region but those env vars were unset, resources were created in the SDK default region instead of the configured target. Promote target.region to AWS_REGION and AWS_DEFAULT_REGION for the lifetime of deploy and teardown operations, restoring prior values in a finally block. This ensures downstream SDK clients (explicit and toolkit-lib internal) agree on the target region. Covers CLI non-interactive deploy (handleDeploy) and the interactive TUI deploy/teardown (useCdkPreflight, destroyTarget). Invoke/status/eval already pass target.region explicitly.
1 parent ce683c0 commit ffccd6c

6 files changed

Lines changed: 205 additions & 10 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { applyTargetRegionToEnv, withTargetRegion } from '../target-region.js';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
4+
describe('target-region', () => {
5+
let savedRegion: string | undefined;
6+
let savedDefaultRegion: string | undefined;
7+
8+
beforeEach(() => {
9+
savedRegion = process.env.AWS_REGION;
10+
savedDefaultRegion = process.env.AWS_DEFAULT_REGION;
11+
delete process.env.AWS_REGION;
12+
delete process.env.AWS_DEFAULT_REGION;
13+
});
14+
15+
afterEach(() => {
16+
if (savedRegion !== undefined) process.env.AWS_REGION = savedRegion;
17+
else delete process.env.AWS_REGION;
18+
if (savedDefaultRegion !== undefined) process.env.AWS_DEFAULT_REGION = savedDefaultRegion;
19+
else delete process.env.AWS_DEFAULT_REGION;
20+
});
21+
22+
describe('applyTargetRegionToEnv', () => {
23+
it('sets AWS_REGION and AWS_DEFAULT_REGION to the provided region', () => {
24+
applyTargetRegionToEnv('ap-southeast-2');
25+
expect(process.env.AWS_REGION).toBe('ap-southeast-2');
26+
expect(process.env.AWS_DEFAULT_REGION).toBe('ap-southeast-2');
27+
});
28+
29+
it('returns a restore function that clears env vars when they were previously unset', () => {
30+
const restore = applyTargetRegionToEnv('eu-west-1');
31+
restore();
32+
expect(process.env.AWS_REGION).toBeUndefined();
33+
expect(process.env.AWS_DEFAULT_REGION).toBeUndefined();
34+
});
35+
36+
it('returns a restore function that restores previous env var values', () => {
37+
process.env.AWS_REGION = 'us-east-1';
38+
process.env.AWS_DEFAULT_REGION = 'us-east-1';
39+
40+
const restore = applyTargetRegionToEnv('ap-south-1');
41+
expect(process.env.AWS_REGION).toBe('ap-south-1');
42+
expect(process.env.AWS_DEFAULT_REGION).toBe('ap-south-1');
43+
44+
restore();
45+
expect(process.env.AWS_REGION).toBe('us-east-1');
46+
expect(process.env.AWS_DEFAULT_REGION).toBe('us-east-1');
47+
});
48+
49+
it('restores each env var independently (only one was previously set)', () => {
50+
process.env.AWS_REGION = 'us-west-2';
51+
// AWS_DEFAULT_REGION intentionally left unset
52+
53+
const restore = applyTargetRegionToEnv('eu-central-1');
54+
expect(process.env.AWS_REGION).toBe('eu-central-1');
55+
expect(process.env.AWS_DEFAULT_REGION).toBe('eu-central-1');
56+
57+
restore();
58+
expect(process.env.AWS_REGION).toBe('us-west-2');
59+
expect(process.env.AWS_DEFAULT_REGION).toBeUndefined();
60+
});
61+
});
62+
63+
describe('withTargetRegion', () => {
64+
it('applies region inside the callback and restores afterwards', async () => {
65+
let seenRegion: string | undefined;
66+
let seenDefaultRegion: string | undefined;
67+
68+
await withTargetRegion('ap-northeast-1', () => {
69+
seenRegion = process.env.AWS_REGION;
70+
seenDefaultRegion = process.env.AWS_DEFAULT_REGION;
71+
return Promise.resolve();
72+
});
73+
74+
expect(seenRegion).toBe('ap-northeast-1');
75+
expect(seenDefaultRegion).toBe('ap-northeast-1');
76+
expect(process.env.AWS_REGION).toBeUndefined();
77+
expect(process.env.AWS_DEFAULT_REGION).toBeUndefined();
78+
});
79+
80+
it('restores env vars even when the callback throws', async () => {
81+
process.env.AWS_REGION = 'us-east-1';
82+
83+
await expect(
84+
withTargetRegion('sa-east-1', () => {
85+
expect(process.env.AWS_REGION).toBe('sa-east-1');
86+
return Promise.reject(new Error('boom'));
87+
})
88+
).rejects.toThrow('boom');
89+
90+
expect(process.env.AWS_REGION).toBe('us-east-1');
91+
expect(process.env.AWS_DEFAULT_REGION).toBeUndefined();
92+
});
93+
94+
it('returns the callback result', async () => {
95+
const result = await withTargetRegion('eu-west-2', () => Promise.resolve(42));
96+
expect(result).toBe(42);
97+
});
98+
});
99+
});

src/cli/aws/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { detectAwsContext, type AwsContext } from './aws-context';
22
export { detectAccount, getCredentialProvider } from './account';
33
export { detectRegion, type RegionDetectionResult } from './region';
4+
export { withTargetRegion } from './target-region';
45
export {
56
invokeBedrockSync,
67
invokeClaude,

src/cli/aws/target-region.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Make a deployment target's region authoritative for downstream AWS SDK calls.
3+
*
4+
* The AWS SDK (and CDK toolkit-lib's internal clients) resolve region from
5+
* AWS_REGION / AWS_DEFAULT_REGION when constructed without an explicit `region`
6+
* option. aws-targets.json is the user's source of truth for where resources
7+
* should be created, so we promote the target's region onto the environment for
8+
* the operation and restore any prior values afterwards.
9+
*
10+
* Without this override, a user with a non-default region in aws-targets.json
11+
* but no AWS_DEFAULT_REGION set would see resources created in the SDK's default
12+
* region — see https://github.com/aws/agentcore-cli/issues/924.
13+
*/
14+
15+
type RestoreEnv = () => void;
16+
17+
/**
18+
* Set AWS_REGION / AWS_DEFAULT_REGION to `region` and return a restore function.
19+
* Callers that cannot wrap their work in a callback (e.g. CLI entrypoints that
20+
* span many helpers) should use this, and invoke the returned function in a
21+
* `finally` block.
22+
*/
23+
export function applyTargetRegionToEnv(region: string): RestoreEnv {
24+
const prevRegion = process.env.AWS_REGION;
25+
const prevDefaultRegion = process.env.AWS_DEFAULT_REGION;
26+
27+
process.env.AWS_REGION = region;
28+
process.env.AWS_DEFAULT_REGION = region;
29+
30+
return () => {
31+
if (prevRegion === undefined) {
32+
delete process.env.AWS_REGION;
33+
} else {
34+
process.env.AWS_REGION = prevRegion;
35+
}
36+
if (prevDefaultRegion === undefined) {
37+
delete process.env.AWS_DEFAULT_REGION;
38+
} else {
39+
process.env.AWS_DEFAULT_REGION = prevDefaultRegion;
40+
}
41+
};
42+
}
43+
44+
/**
45+
* Run `fn` with `region` applied to AWS_REGION / AWS_DEFAULT_REGION, restoring
46+
* the prior values on return (including when `fn` throws).
47+
*/
48+
export async function withTargetRegion<T>(region: string, fn: () => Promise<T>): Promise<T> {
49+
const restore = applyTargetRegionToEnv(region);
50+
try {
51+
return await fn();
52+
} finally {
53+
restore();
54+
}
55+
}

src/cli/commands/deploy/actions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ConfigIO, SecureCredentials } from '../../../lib';
22
import type { AgentCoreMcpSpec, DeployedState } from '../../../schema';
33
import { validateAwsCredentials } from '../../aws/account';
4+
import { applyTargetRegionToEnv } from '../../aws/target-region';
45
import { createSwitchableIoHost } from '../../cdk/toolkit-lib';
56
import {
67
buildDeployedState,
@@ -48,6 +49,7 @@ const MEMORY_ONLY_NEXT_STEPS = ['agentcore add agent', 'agentcore status'];
4849

4950
export async function handleDeploy(options: ValidatedDeployOptions): Promise<DeployResult> {
5051
let toolkitWrapper = null;
52+
let restoreEnv: (() => void) | null = null;
5153
const logger = new ExecLogger({ command: 'deploy' });
5254
const { onProgress } = options;
5355
let currentStepName = '';
@@ -79,6 +81,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
7981
logPath: logger.getRelativeLogPath(),
8082
};
8183
}
84+
// Make the resolved target region authoritative for downstream SDK / CDK
85+
// calls that don't receive an explicit region option.
86+
// See https://github.com/aws/agentcore-cli/issues/924.
87+
restoreEnv = applyTargetRegionToEnv(target.region);
8288
endStep('success');
8389

8490
// Read project spec for gateway information (used later for deploy step name and outputs)
@@ -485,5 +491,6 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
485491
if (toolkitWrapper) {
486492
await toolkitWrapper.dispose();
487493
}
494+
restoreEnv?.();
488495
}
489496
}

src/cli/operations/deploy/teardown.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CONFIG_DIR, ConfigIO } from '../../../lib';
22
import type { AwsDeploymentTarget } from '../../../schema';
3+
import { withTargetRegion } from '../../aws/target-region';
34
import { CdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib';
45
import { type DiscoveredStack, findStack } from '../../cloudformation/stack-discovery';
56
import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib';
@@ -60,12 +61,18 @@ export async function destroyTarget(options: DestroyTargetOptions): Promise<void
6061
ioHost: silentIoHost,
6162
});
6263

63-
await toolkit.initialize();
64-
await toolkit.destroy({
65-
stacks: {
66-
strategy: StackSelectionStrategy.PATTERN_MUST_MATCH,
67-
patterns: [target.stack.stackName],
68-
},
64+
// aws-targets.json is authoritative for the destroy region; promote it onto
65+
// the env so CDK toolkit-lib's internal SDK clients hit the right region even
66+
// when AWS_REGION / AWS_DEFAULT_REGION are unset.
67+
// See https://github.com/aws/agentcore-cli/issues/924.
68+
await withTargetRegion(target.target.region, async () => {
69+
await toolkit.initialize();
70+
await toolkit.destroy({
71+
stacks: {
72+
strategy: StackSelectionStrategy.PATTERN_MUST_MATCH,
73+
patterns: [target.stack.stackName],
74+
},
75+
});
6976
});
7077

7178
// Clean up deployed-state.json after successful destroy

src/cli/tui/hooks/useCdkPreflight.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ConfigIO, SecureCredentials } from '../../../lib';
22
import type { DeployedState } from '../../../schema';
33
import { AwsCredentialsError, validateAwsCredentials } from '../../aws/account';
4+
import { applyTargetRegionToEnv } from '../../aws/target-region';
45
import { type CdkToolkitWrapper, type SwitchableIoHost, createSwitchableIoHost } from '../../cdk/toolkit-lib';
56
import { getErrorMessage, isExpiredTokenError, isNoCredentialsError } from '../../errors';
67
import type { ExecLogger } from '../../logging';
@@ -137,6 +138,11 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
137138
const isRunningRef = useRef(false);
138139
// Keep a ref to the wrapper so we can dispose it when starting a new run
139140
const wrapperRef = useRef<CdkToolkitWrapper | null>(null);
141+
// Restore function for AWS_REGION / AWS_DEFAULT_REGION overrides, applied
142+
// after target resolution so downstream SDK / CDK toolkit-lib clients use the
143+
// aws-targets.json region rather than whatever the SDK default chain resolves.
144+
// See https://github.com/aws/agentcore-cli/issues/924.
145+
const restoreRegionEnvRef = useRef<(() => void) | null>(null);
140146

141147
const updateStep = (index: number, update: Partial<Step>) => {
142148
setSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...update } : s)));
@@ -158,18 +164,26 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
158164
}
159165
}, []);
160166

167+
// Restore AWS_REGION / AWS_DEFAULT_REGION (no-op when nothing was applied)
168+
const restoreRegionEnv = useCallback(() => {
169+
restoreRegionEnvRef.current?.();
170+
restoreRegionEnvRef.current = null;
171+
}, []);
172+
161173
const startPreflight = useCallback(async () => {
162174
if (isRunningRef.current) return;
163175
// Dispose any existing wrapper before starting a new run
164176
await disposeWrapper();
177+
// Restore any previously-applied region env override before re-running
178+
restoreRegionEnv();
165179
resetSteps();
166180
setCdkToolkitWrapper(null);
167181
setStackNames([]);
168182
setBootstrapContext(null);
169183
setHasTokenExpiredError(false); // Reset token expired state when retrying
170184
setHasCredentialsError(false); // Reset credentials error state when retrying
171185
setPhase('running');
172-
}, [disposeWrapper]);
186+
}, [disposeWrapper, restoreRegionEnv]);
173187

174188
const clearTokenExpiredError = useCallback(() => {
175189
setHasTokenExpiredError(false);
@@ -183,6 +197,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
183197
useEffect(() => {
184198
const handleInterrupt = () => {
185199
void disposeWrapper();
200+
restoreRegionEnv();
186201
};
187202

188203
process.on('SIGINT', handleInterrupt);
@@ -193,8 +208,9 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
193208
process.off('SIGTERM', handleInterrupt);
194209
// Dispose on unmount (user navigated away)
195210
void disposeWrapper();
211+
restoreRegionEnv();
196212
};
197-
}, [disposeWrapper]);
213+
}, [disposeWrapper, restoreRegionEnv]);
198214

199215
const confirmTeardown = useCallback(() => {
200216
// Mark teardown as confirmed and restart the preflight flow
@@ -206,7 +222,8 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
206222
const cancelTeardown = useCallback(() => {
207223
setPhase('error');
208224
isRunningRef.current = false;
209-
}, []);
225+
restoreRegionEnv();
226+
}, [restoreRegionEnv]);
210227

211228
const confirmBootstrap = useCallback(() => {
212229
setPhase('bootstrapping');
@@ -274,6 +291,15 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
274291
try {
275292
preflightContext = await validateProject();
276293
setContext(preflightContext);
294+
// Make aws-targets.json region authoritative for downstream SDK / CDK
295+
// toolkit-lib clients that bypass explicit region options. Restored on
296+
// unmount, teardown rejection, or subsequent preflight start.
297+
// See https://github.com/aws/agentcore-cli/issues/924.
298+
const firstTarget = preflightContext.awsTargets[0];
299+
if (firstTarget) {
300+
restoreRegionEnv();
301+
restoreRegionEnvRef.current = applyTargetRegionToEnv(firstTarget.region);
302+
}
277303
logger.endStep('success');
278304
updateStep(STEP_VALIDATE, { status: 'success' });
279305
} catch (err) {
@@ -493,7 +519,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
493519
return () => {
494520
process.off('unhandledRejection', handleUnhandledRejection);
495521
};
496-
}, [phase, logger, switchableIoHost, isInteractive, skipIdentityCheck, teardownConfirmed]);
522+
}, [phase, logger, switchableIoHost, isInteractive, skipIdentityCheck, teardownConfirmed, restoreRegionEnv]);
497523

498524
// Handle identity-setup phase (after user provides credentials)
499525
useEffect(() => {

0 commit comments

Comments
 (0)