Skip to content

Commit 392b55e

Browse files
author
bgagent
committed
fix(cdk): auto-select AgentCore-supported AZs by default (#353) Addresses review on #358: a manual AZ pin alone left the default deploy path broken for affected accounts. The stack now auto-selects AgentCore-supported availability zones, with the context override kept as a validated safety valve. - New constructs/agentcore-azs.ts: - AGENTCORE_SUPPORTED_AZ_IDS: per-region map of supported physical zone IDs (from the AWS AgentCore VPC docs), not a us-east-1-only comment presented as universal. - resolveAgentCoreAzOverride: validates agentcore:availabilityZones, mirroring resolveBedrockModelIds (rejects non-array / empty / non- string entries and <2 zones for HA) with a clear synth error. - selectSupportedAzNames: pure name<->id intersection. - resolveAgentCoreAzs: override wins; else, when synth has a concrete account+region, DescribeAvailabilityZones -> intersect -> pin AZ names; env-agnostic synth / unknown region / lookup failure fall back to CDK's default selection (surfaced via a synth warning, not a masked empty result). - main.ts is now async and threads the resolved names into AgentStack (new AgentStackProps.availabilityZones) -> AgentVpc. - Docs: DEPLOYMENT_GUIDE 'Known deployment issues' + QUICK_START note and troubleshooting row cover the discover-mapping -> pick-IDs -> set- context operator path. - Tests: agent-vpc asserts subnet AvailabilityZone == pinned names plus an env-agnostic Fn::GetAZs fallback; agentcore-azs covers the map, override validation, selection, and resolver branches. Adds @aws-sdk/client-ec2 for the synth-time lookup (loaded lazily).
1 parent 7936d16 commit 392b55e

12 files changed

Lines changed: 867 additions & 68 deletions

File tree

cdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@aws-sdk/client-bedrock-agentcore": "^3.1078.0",
2020
"@aws-sdk/client-bedrock-runtime": "^3.1078.0",
2121
"@aws-sdk/client-dynamodb": "^3.1078.0",
22+
"@aws-sdk/client-ec2": "^3.1078.0",
2223
"@aws-sdk/client-ecs": "^3.1078.0",
2324
"@aws-sdk/client-lambda": "^3.1078.0",
2425
"@aws-sdk/client-s3": "^3.1078.0",

cdk/src/constructs/agent-vpc.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,16 @@ export interface AgentVpcProps {
5454
*
5555
* Pin this to AZ names whose physical zone IDs are AgentCore-supported to
5656
* make a fresh deploy deterministic regardless of the account's
57-
* name → zone-ID mapping. Discover the mapping with:
57+
* name → zone-ID mapping. The supported zone-ID set differs per region and
58+
* can change over time — see the AWS
59+
* {@link https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones Supported Availability Zones}
60+
* table, and the per-region `AGENTCORE_SUPPORTED_AZ_IDS` map in
61+
* `constructs/agentcore-azs.ts`.
5862
*
59-
* ```sh
60-
* aws ec2 describe-availability-zones --region <region> \
61-
* --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text
62-
* ```
63-
*
64-
* then choose names whose zone IDs are in the AgentCore-supported set for
65-
* the region (for `us-east-1` at time of writing: `use1-az1`, `use1-az2`,
66-
* `use1-az4`). The error message returned by a failed Runtime creation also
67-
* lists the currently supported zone IDs.
63+
* Callers normally don't set this directly: `resolveAgentCoreAzs` (invoked
64+
* from `main.ts`) auto-selects supported zone names for the target account,
65+
* or honors the validated `agentcore:availabilityZones` context override, and
66+
* passes the result through {@link AgentStackProps.availabilityZones}.
6867
*
6968
* When provided, takes precedence over {@link maxAzs}.
7069
* @default - CDK selects the first `maxAzs` zones by name
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { Annotations, Token } from 'aws-cdk-lib';
21+
import { Construct, Node } from 'constructs';
22+
23+
/**
24+
* AgentCore-supported physical **Availability Zone IDs** per region.
25+
*
26+
* AgentCore Runtime (and the built-in Code Interpreter / Browser tools) only
27+
* places its elastic network interfaces in a subset of each region's zones. If
28+
* the VPC subnets land in an unsupported zone the
29+
* `AWS::BedrockAgentCore::Runtime` resource fails to stabilize
30+
* (`NotStabilized` — "subnets are in unsupported availability zones") and rolls
31+
* back the whole stack.
32+
*
33+
* The constraint is published in terms of **zone IDs** (e.g. `use1-az1`), which
34+
* are stable across accounts, NOT zone *names* (e.g. `us-east-1a`) which are
35+
* aliased per-account. Keep this map aligned with the AWS documentation:
36+
* https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones
37+
*
38+
* A region absent from this map is treated as "no known constraint" — the
39+
* auto-pin logic leaves AZ selection to CDK's default and operators can still
40+
* pin explicitly via the {@link AGENTCORE_AZS_CONTEXT_KEY} context override.
41+
*/
42+
export const AGENTCORE_SUPPORTED_AZ_IDS: Readonly<Record<string, readonly string[]>> = {
43+
'us-east-1': ['use1-az1', 'use1-az2', 'use1-az4'],
44+
'us-east-2': ['use2-az1', 'use2-az2', 'use2-az3'],
45+
'us-west-2': ['usw2-az1', 'usw2-az2', 'usw2-az3'],
46+
'ap-southeast-1': ['apse1-az1', 'apse1-az2', 'apse1-az3'],
47+
'ap-southeast-2': ['apse2-az1', 'apse2-az2', 'apse2-az3'],
48+
'ap-south-1': ['aps1-az1', 'aps1-az2', 'aps1-az3'],
49+
'ap-northeast-1': ['apne1-az1', 'apne1-az2', 'apne1-az4'],
50+
'eu-west-1': ['euw1-az1', 'euw1-az2', 'euw1-az3'],
51+
'eu-central-1': ['euc1-az1', 'euc1-az2', 'euc1-az3'],
52+
};
53+
54+
/**
55+
* CDK context key whose value (a JSON array of AZ **names**, e.g.
56+
* `["us-east-1b", "us-east-1c"]`) overrides the auto-selected zones. Set it in
57+
* `cdk.context.json`, `cdk.json` `context`, or via
58+
* `-c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]'`.
59+
*/
60+
export const AGENTCORE_AZS_CONTEXT_KEY = 'agentcore:availabilityZones';
61+
62+
/** Minimum AZ count — AgentCore high-availability guidance is >=2 zones. */
63+
const MIN_AGENTCORE_AZS = 2;
64+
65+
/** A single Availability Zone's name and its stable physical zone ID. */
66+
export interface AvailabilityZoneInfo {
67+
/** Account-aliased zone name, e.g. `us-east-1a`. */
68+
readonly zoneName: string;
69+
/** Stable physical zone ID, e.g. `use1-az1`. */
70+
readonly zoneId: string;
71+
}
72+
73+
/** Signature for the injectable `DescribeAvailabilityZones` lookup. */
74+
export type DescribeAzsFn = (region: string) => Promise<AvailabilityZoneInfo[]>;
75+
76+
/** Options for {@link resolveAgentCoreAzs}. */
77+
export interface ResolveAgentCoreAzsOptions {
78+
/** Scope used to read context and surface synth-time warnings (the `App`). */
79+
readonly scope: Construct;
80+
/** Target account (from `CDK_DEFAULT_ACCOUNT`); undefined/token = env-agnostic. */
81+
readonly account?: string;
82+
/** Target region (from `CDK_DEFAULT_REGION`); undefined/token = env-agnostic. */
83+
readonly region?: string;
84+
/**
85+
* Availability-zone lookup. Defaults to a live EC2
86+
* `DescribeAvailabilityZones` call; injectable so tests need no AWS access.
87+
*/
88+
readonly describeAzs?: DescribeAzsFn;
89+
}
90+
91+
/**
92+
* Validates and returns the optional {@link AGENTCORE_AZS_CONTEXT_KEY} override.
93+
*
94+
* Mirrors the loud-fail contract of `resolveBedrockModelIds` (bedrock-models.ts):
95+
* a malformed override fails synth with a clear message naming the key and the
96+
* expected JSON shape, rather than silently pinning nothing.
97+
*
98+
* @returns the validated AZ-name array, or `undefined` when the key is unset.
99+
* @throws if the value is not a JSON array, contains a non-string/empty entry,
100+
* or lists fewer than {@link MIN_AGENTCORE_AZS} zones.
101+
*/
102+
export function resolveAgentCoreAzOverride(node: Node): string[] | undefined {
103+
const override = node.tryGetContext(AGENTCORE_AZS_CONTEXT_KEY);
104+
if (override === undefined || override === null) {
105+
return undefined;
106+
}
107+
if (!Array.isArray(override)) {
108+
throw new Error(
109+
`Context '${AGENTCORE_AZS_CONTEXT_KEY}' must be a JSON array of availability-zone names `
110+
+ `(e.g. ["us-east-1b", "us-east-1c"]); got ${JSON.stringify(override)}.`,
111+
);
112+
}
113+
for (const az of override) {
114+
if (typeof az !== 'string' || az.trim().length === 0) {
115+
throw new Error(
116+
`Context '${AGENTCORE_AZS_CONTEXT_KEY}' entries must be non-empty availability-zone-name `
117+
+ `strings; got ${JSON.stringify(az)}.`,
118+
);
119+
}
120+
}
121+
if (override.length < MIN_AGENTCORE_AZS) {
122+
throw new Error(
123+
`Context '${AGENTCORE_AZS_CONTEXT_KEY}' must list at least ${MIN_AGENTCORE_AZS} zones for `
124+
+ `AgentCore high availability; got ${JSON.stringify(override)}.`,
125+
);
126+
}
127+
return override as string[];
128+
}
129+
130+
/**
131+
* Pure selection: given the account's AZ (name, id) pairs, returns the zone
132+
* *names* whose physical zone IDs are AgentCore-supported for `region`.
133+
*
134+
* Returns an empty array when the region has no known constraint (absent from
135+
* {@link AGENTCORE_SUPPORTED_AZ_IDS}) or none of the account's zones match.
136+
*/
137+
export function selectSupportedAzNames(region: string, zones: readonly AvailabilityZoneInfo[]): string[] {
138+
const supported = AGENTCORE_SUPPORTED_AZ_IDS[region];
139+
if (!supported) {
140+
return [];
141+
}
142+
const supportedIds = new Set(supported);
143+
return zones.filter(zone => supportedIds.has(zone.zoneId)).map(zone => zone.zoneName);
144+
}
145+
146+
/**
147+
* Live `DescribeAvailabilityZones` lookup (default {@link DescribeAzsFn}).
148+
*
149+
* The `@aws-sdk/client-ec2` module is imported dynamically so it is only loaded
150+
* when auto-pin actually runs (concrete env, no override) — not during
151+
* env-agnostic synth or in unit tests, which inject their own lookup.
152+
*/
153+
async function defaultDescribeAzs(region: string): Promise<AvailabilityZoneInfo[]> {
154+
const { EC2Client, DescribeAvailabilityZonesCommand } = await import('@aws-sdk/client-ec2');
155+
const client = new EC2Client({ region });
156+
const response = await client.send(
157+
new DescribeAvailabilityZonesCommand({
158+
// Standard AZs only — exclude Local Zones / Wavelength / Outposts.
159+
Filters: [{ Name: 'zone-type', Values: ['availability-zone'] }],
160+
}),
161+
);
162+
const zones: AvailabilityZoneInfo[] = [];
163+
for (const zone of response.AvailabilityZones ?? []) {
164+
if (zone.ZoneName && zone.ZoneId) {
165+
zones.push({ zoneName: zone.ZoneName, zoneId: zone.ZoneId });
166+
}
167+
}
168+
return zones;
169+
}
170+
171+
/**
172+
* Resolves the Availability-Zone *names* the AgentCore VPC should pin to.
173+
*
174+
* Resolution order:
175+
* 1. **Operator override** — a validated {@link AGENTCORE_AZS_CONTEXT_KEY}
176+
* context value always wins (works even in env-agnostic synth, which is how
177+
* the CI-built artifact and production stack pin zones).
178+
* 2. **Auto-pin (default path)** — when synth has a concrete account + region,
179+
* resolve the account's name -> zone-ID mapping and intersect it with
180+
* {@link AGENTCORE_SUPPORTED_AZ_IDS} for the region, so a fresh local
181+
* `cdk deploy` lands only in supported zones without account-specific
182+
* guesswork.
183+
* 3. **Fallback** — env-agnostic synth (token/undefined account or region), an
184+
* unknown region, a failed lookup, or fewer than {@link MIN_AGENTCORE_AZS}
185+
* supported zones all return `undefined`, leaving CDK's default AZ selection
186+
* in place. Degraded cases (2 & 3 boundary) surface a synth warning rather
187+
* than failing, so unaffected regions and offline synth still deploy.
188+
*
189+
* @returns AZ names to pass to `ec2.Vpc({ availabilityZones })`, or `undefined`
190+
* to keep the default `maxAzs` selection.
191+
*/
192+
export async function resolveAgentCoreAzs(options: ResolveAgentCoreAzsOptions): Promise<string[] | undefined> {
193+
const { scope, account, region } = options;
194+
195+
// 1. Explicit, validated override wins (throws loudly if malformed).
196+
const override = resolveAgentCoreAzOverride(scope.node);
197+
if (override) {
198+
return override;
199+
}
200+
201+
// 2. Auto-pin needs a concrete account + region. Env-agnostic synth uses
202+
// token placeholders (the CI-built artifact + production stack) — pin via
203+
// the override there instead.
204+
if (!account || !region || Token.isUnresolved(account) || Token.isUnresolved(region)) {
205+
return undefined;
206+
}
207+
208+
// 3. No published constraint for this region — don't guess.
209+
if (!AGENTCORE_SUPPORTED_AZ_IDS[region]) {
210+
return undefined;
211+
}
212+
213+
const describeAzs = options.describeAzs ?? defaultDescribeAzs;
214+
215+
// The return sits outside the try/catch so a failed lookup degrades to the
216+
// default AZ selection (surfaced as a warning) instead of masking the error
217+
// with an empty result inside the catch (ts-silent-success-masking / AI004).
218+
let pinnedZones: string[] | undefined;
219+
try {
220+
const zones = await describeAzs(region);
221+
const names = selectSupportedAzNames(region, zones);
222+
if (names.length >= MIN_AGENTCORE_AZS) {
223+
pinnedZones = names;
224+
} else {
225+
Annotations.of(scope).addWarning(
226+
`[AgentCore AZs] Found only ${names.length} AgentCore-supported availability zone(s) in `
227+
+ `${region} (need >=${MIN_AGENTCORE_AZS}); using CDK's default AZ selection. Pin zones `
228+
+ `explicitly via context '${AGENTCORE_AZS_CONTEXT_KEY}' if the deploy hits unsupported zones.`,
229+
);
230+
}
231+
} catch (err) {
232+
Annotations.of(scope).addWarning(
233+
`[AgentCore AZs] Could not resolve AgentCore-supported availability zones for ${region} `
234+
+ `(${err instanceof Error ? err.message : String(err)}); using CDK's default AZ selection. `
235+
+ `Pin zones explicitly via context '${AGENTCORE_AZS_CONTEXT_KEY}' if the deploy hits unsupported zones.`,
236+
);
237+
}
238+
return pinnedZones;
239+
}

0 commit comments

Comments
 (0)