From 00011f0d8802937a886abcde366154ef53217dc4 Mon Sep 17 00:00:00 2001 From: bgagent Date: Tue, 16 Jun 2026 15:32:27 -0400 Subject: [PATCH 1/5] fix(cdk): allow pinning AgentVpc to AgentCore-supported availability zones (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentCore only supports a subset of physical availability zones per region. AZ names are aliased per-account to physical zone IDs, so the default maxAzs selection can land in a zone AgentCore does not support, causing the AWS::BedrockAgentCore::Runtime resource to fail with NotStabilized. Changes: - Add optional `availabilityZones` prop to AgentVpcProps — when provided it takes precedence over maxAzs so the VPC is pinned to specific AZ names. - Wire up the CDK context key `agentcore:availabilityZones` in agent.ts so affected accounts can set it in cdk.context.json or via -c flag without touching construct code. - Add tests for the new prop (explicit AZs override maxAzs, 3-zone case). Usage for affected accounts: cdk deploy -c agentcore:availabilityZones='["us-east-1b","us-east-1c"]' Or in cdk.context.json: { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } Closes #353 --- cdk/src/constructs/agent-vpc.ts | 41 ++++++++++++++++++++++++++- cdk/src/stacks/agent.ts | 18 +++++++++++- cdk/test/constructs/agent-vpc.test.ts | 29 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/cdk/src/constructs/agent-vpc.ts b/cdk/src/constructs/agent-vpc.ts index d8d5546e4..529c07591 100644 --- a/cdk/src/constructs/agent-vpc.ts +++ b/cdk/src/constructs/agent-vpc.ts @@ -32,10 +32,45 @@ const HTTPS_PORT = 443; export interface AgentVpcProps { /** * Maximum number of availability zones to use. + * + * Ignored when {@link availabilityZones} is provided (CDK does not allow + * both `maxAzs` and an explicit zone list on the same VPC). * @default 2 */ readonly maxAzs?: number; + /** + * Explicit list of availability-zone *names* (e.g. `['us-east-1b', 'us-east-1c']`) + * to place the VPC — and therefore the AgentCore Runtime ENIs — into. + * + * AgentCore only supports a subset of the physical availability zones in a + * region, and AZ *names* are aliased per-account to physical zone IDs (so + * `us-east-1a` is not the same physical zone across accounts). When CDK is + * left to pick zones by name (the `maxAzs` default) it can land the Runtime + * subnets in a zone AgentCore does not support, and the + * `AWS::BedrockAgentCore::Runtime` resource fails to stabilize with + * `NotStabilized` ("subnets are in unsupported availability zones"), rolling + * back the whole stack. + * + * Pin this to AZ names whose physical zone IDs are AgentCore-supported to + * make a fresh deploy deterministic regardless of the account's + * name → zone-ID mapping. Discover the mapping with: + * + * ```sh + * aws ec2 describe-availability-zones --region \ + * --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text + * ``` + * + * then choose names whose zone IDs are in the AgentCore-supported set for + * the region (for `us-east-1` at time of writing: `use1-az1`, `use1-az2`, + * `use1-az4`). The error message returned by a failed Runtime creation also + * lists the currently supported zone IDs. + * + * When provided, takes precedence over {@link maxAzs}. + * @default - CDK selects the first `maxAzs` zones by name + */ + readonly availabilityZones?: string[]; + /** * Number of NAT gateways to provision. * @default 1 @@ -71,8 +106,12 @@ export class AgentVpc extends Construct { const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; // --- VPC --- + // When explicit AZs are provided (to target AgentCore-supported physical + // zones), pass them directly and omit maxAzs — CDK does not allow both. this.vpc = new ec2.Vpc(this, 'Vpc', { - maxAzs, + ...(props.availabilityZones + ? { availabilityZones: props.availabilityZones } + : { maxAzs }), natGateways, restrictDefaultSecurityGroup: true, subnetConfiguration: [ diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 17e13ebbc..1fbbca186 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -199,7 +199,23 @@ export class AgentStack extends Stack { ]); // Network isolation — VPC with restricted egress - const agentVpc = new AgentVpc(this, 'AgentVpc'); + // AgentCore only supports a subset of physical availability zones per + // region (for us-east-1: use1-az1, use1-az2, use1-az4). AZ *names* are + // aliased per-account, so the default maxAzs selection can land in an + // unsupported zone and cause a deploy failure. Use the CDK context key + // `agentcore:availabilityZones` to pin to account-specific AZ names whose + // physical zone IDs are AgentCore-supported. + // + // Discover your mapping: + // aws ec2 describe-availability-zones --region us-east-1 \ + // --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text + // + // Then set in cdk.context.json or via -c: + // "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] + const agentCoreAzs = this.node.tryGetContext('agentcore:availabilityZones') as string[] | undefined; + const agentVpc = new AgentVpc(this, 'AgentVpc', { + ...(agentCoreAzs ? { availabilityZones: agentCoreAzs } : {}), + }); // DNS Firewall — domain-level egress filtering (observation mode for initial deployment) const additionalDomains = [...new Set(blueprints.flatMap(b => b.egressAllowlist))]; diff --git a/cdk/test/constructs/agent-vpc.test.ts b/cdk/test/constructs/agent-vpc.test.ts index 49614d8db..2fcc1c882 100644 --- a/cdk/test/constructs/agent-vpc.test.ts +++ b/cdk/test/constructs/agent-vpc.test.ts @@ -149,4 +149,33 @@ describe('AgentVpc with custom props', () => { template.resourceCountIs('AWS::EC2::NatGateway', 2); }); + + test('accepts explicit availabilityZones and ignores maxAzs', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + new AgentVpc(stack, 'AgentVpc', { + availabilityZones: ['us-east-1b', 'us-east-1c'], + maxAzs: 3, // should be ignored when availabilityZones is provided + }); + const template = Template.fromStack(stack); + + // 2 explicit AZs × 2 subnet types = 4 subnets + template.resourceCountIs('AWS::EC2::Subnet', 4); + }); + + test('availabilityZones with 3 zones creates 6 subnets', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + new AgentVpc(stack, 'AgentVpc', { + availabilityZones: ['us-east-1b', 'us-east-1c', 'us-east-1d'], + }); + const template = Template.fromStack(stack); + + // 3 AZs × 2 subnet types = 6 subnets + template.resourceCountIs('AWS::EC2::Subnet', 6); + }); }); From 392b55e3b462c0001953a59fb268005c824ebfc8 Mon Sep 17 00:00:00 2001 From: bgagent Date: Wed, 15 Jul 2026 11:12:18 -0400 Subject: [PATCH 2/5] 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). --- cdk/package.json | 1 + cdk/src/constructs/agent-vpc.ts | 19 +- cdk/src/constructs/agentcore-azs.ts | 239 +++++++++++++++++ cdk/src/main.ts | 106 +++++--- cdk/src/stacks/agent.ts | 41 +-- cdk/test/constructs/agent-vpc.test.ts | 22 ++ cdk/test/constructs/agentcore-azs.test.ts | 199 ++++++++++++++ docs/guides/DEPLOYMENT_GUIDE.md | 25 ++ docs/guides/QUICK_START.mdx | 3 +- .../docs/getting-started/Deployment-guide.md | 25 ++ .../docs/getting-started/Quick-start.mdx | 3 +- yarn.lock | 252 ++++++++++++++++++ 12 files changed, 867 insertions(+), 68 deletions(-) create mode 100644 cdk/src/constructs/agentcore-azs.ts create mode 100644 cdk/test/constructs/agentcore-azs.test.ts diff --git a/cdk/package.json b/cdk/package.json index be16744ad..e99d82343 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -19,6 +19,7 @@ "@aws-sdk/client-bedrock-agentcore": "^3.1078.0", "@aws-sdk/client-bedrock-runtime": "^3.1078.0", "@aws-sdk/client-dynamodb": "^3.1078.0", + "@aws-sdk/client-ec2": "^3.1078.0", "@aws-sdk/client-ecs": "^3.1078.0", "@aws-sdk/client-lambda": "^3.1078.0", "@aws-sdk/client-s3": "^3.1078.0", diff --git a/cdk/src/constructs/agent-vpc.ts b/cdk/src/constructs/agent-vpc.ts index 529c07591..12d7fc500 100644 --- a/cdk/src/constructs/agent-vpc.ts +++ b/cdk/src/constructs/agent-vpc.ts @@ -54,17 +54,16 @@ export interface AgentVpcProps { * * Pin this to AZ names whose physical zone IDs are AgentCore-supported to * make a fresh deploy deterministic regardless of the account's - * name → zone-ID mapping. Discover the mapping with: + * name → zone-ID mapping. The supported zone-ID set differs per region and + * can change over time — see the AWS + * {@link https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones Supported Availability Zones} + * table, and the per-region `AGENTCORE_SUPPORTED_AZ_IDS` map in + * `constructs/agentcore-azs.ts`. * - * ```sh - * aws ec2 describe-availability-zones --region \ - * --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text - * ``` - * - * then choose names whose zone IDs are in the AgentCore-supported set for - * the region (for `us-east-1` at time of writing: `use1-az1`, `use1-az2`, - * `use1-az4`). The error message returned by a failed Runtime creation also - * lists the currently supported zone IDs. + * Callers normally don't set this directly: `resolveAgentCoreAzs` (invoked + * from `main.ts`) auto-selects supported zone names for the target account, + * or honors the validated `agentcore:availabilityZones` context override, and + * passes the result through {@link AgentStackProps.availabilityZones}. * * When provided, takes precedence over {@link maxAzs}. * @default - CDK selects the first `maxAzs` zones by name diff --git a/cdk/src/constructs/agentcore-azs.ts b/cdk/src/constructs/agentcore-azs.ts new file mode 100644 index 000000000..2d3d1a8e4 --- /dev/null +++ b/cdk/src/constructs/agentcore-azs.ts @@ -0,0 +1,239 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Annotations, Token } from 'aws-cdk-lib'; +import { Construct, Node } from 'constructs'; + +/** + * AgentCore-supported physical **Availability Zone IDs** per region. + * + * AgentCore Runtime (and the built-in Code Interpreter / Browser tools) only + * places its elastic network interfaces in a subset of each region's zones. If + * the VPC subnets land in an unsupported zone the + * `AWS::BedrockAgentCore::Runtime` resource fails to stabilize + * (`NotStabilized` — "subnets are in unsupported availability zones") and rolls + * back the whole stack. + * + * The constraint is published in terms of **zone IDs** (e.g. `use1-az1`), which + * are stable across accounts, NOT zone *names* (e.g. `us-east-1a`) which are + * aliased per-account. Keep this map aligned with the AWS documentation: + * https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones + * + * A region absent from this map is treated as "no known constraint" — the + * auto-pin logic leaves AZ selection to CDK's default and operators can still + * pin explicitly via the {@link AGENTCORE_AZS_CONTEXT_KEY} context override. + */ +export const AGENTCORE_SUPPORTED_AZ_IDS: Readonly> = { + 'us-east-1': ['use1-az1', 'use1-az2', 'use1-az4'], + 'us-east-2': ['use2-az1', 'use2-az2', 'use2-az3'], + 'us-west-2': ['usw2-az1', 'usw2-az2', 'usw2-az3'], + 'ap-southeast-1': ['apse1-az1', 'apse1-az2', 'apse1-az3'], + 'ap-southeast-2': ['apse2-az1', 'apse2-az2', 'apse2-az3'], + 'ap-south-1': ['aps1-az1', 'aps1-az2', 'aps1-az3'], + 'ap-northeast-1': ['apne1-az1', 'apne1-az2', 'apne1-az4'], + 'eu-west-1': ['euw1-az1', 'euw1-az2', 'euw1-az3'], + 'eu-central-1': ['euc1-az1', 'euc1-az2', 'euc1-az3'], +}; + +/** + * CDK context key whose value (a JSON array of AZ **names**, e.g. + * `["us-east-1b", "us-east-1c"]`) overrides the auto-selected zones. Set it in + * `cdk.context.json`, `cdk.json` `context`, or via + * `-c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]'`. + */ +export const AGENTCORE_AZS_CONTEXT_KEY = 'agentcore:availabilityZones'; + +/** Minimum AZ count — AgentCore high-availability guidance is >=2 zones. */ +const MIN_AGENTCORE_AZS = 2; + +/** A single Availability Zone's name and its stable physical zone ID. */ +export interface AvailabilityZoneInfo { + /** Account-aliased zone name, e.g. `us-east-1a`. */ + readonly zoneName: string; + /** Stable physical zone ID, e.g. `use1-az1`. */ + readonly zoneId: string; +} + +/** Signature for the injectable `DescribeAvailabilityZones` lookup. */ +export type DescribeAzsFn = (region: string) => Promise; + +/** Options for {@link resolveAgentCoreAzs}. */ +export interface ResolveAgentCoreAzsOptions { + /** Scope used to read context and surface synth-time warnings (the `App`). */ + readonly scope: Construct; + /** Target account (from `CDK_DEFAULT_ACCOUNT`); undefined/token = env-agnostic. */ + readonly account?: string; + /** Target region (from `CDK_DEFAULT_REGION`); undefined/token = env-agnostic. */ + readonly region?: string; + /** + * Availability-zone lookup. Defaults to a live EC2 + * `DescribeAvailabilityZones` call; injectable so tests need no AWS access. + */ + readonly describeAzs?: DescribeAzsFn; +} + +/** + * Validates and returns the optional {@link AGENTCORE_AZS_CONTEXT_KEY} override. + * + * Mirrors the loud-fail contract of `resolveBedrockModelIds` (bedrock-models.ts): + * a malformed override fails synth with a clear message naming the key and the + * expected JSON shape, rather than silently pinning nothing. + * + * @returns the validated AZ-name array, or `undefined` when the key is unset. + * @throws if the value is not a JSON array, contains a non-string/empty entry, + * or lists fewer than {@link MIN_AGENTCORE_AZS} zones. + */ +export function resolveAgentCoreAzOverride(node: Node): string[] | undefined { + const override = node.tryGetContext(AGENTCORE_AZS_CONTEXT_KEY); + if (override === undefined || override === null) { + return undefined; + } + if (!Array.isArray(override)) { + throw new Error( + `Context '${AGENTCORE_AZS_CONTEXT_KEY}' must be a JSON array of availability-zone names ` + + `(e.g. ["us-east-1b", "us-east-1c"]); got ${JSON.stringify(override)}.`, + ); + } + for (const az of override) { + if (typeof az !== 'string' || az.trim().length === 0) { + throw new Error( + `Context '${AGENTCORE_AZS_CONTEXT_KEY}' entries must be non-empty availability-zone-name ` + + `strings; got ${JSON.stringify(az)}.`, + ); + } + } + if (override.length < MIN_AGENTCORE_AZS) { + throw new Error( + `Context '${AGENTCORE_AZS_CONTEXT_KEY}' must list at least ${MIN_AGENTCORE_AZS} zones for ` + + `AgentCore high availability; got ${JSON.stringify(override)}.`, + ); + } + return override as string[]; +} + +/** + * Pure selection: given the account's AZ (name, id) pairs, returns the zone + * *names* whose physical zone IDs are AgentCore-supported for `region`. + * + * Returns an empty array when the region has no known constraint (absent from + * {@link AGENTCORE_SUPPORTED_AZ_IDS}) or none of the account's zones match. + */ +export function selectSupportedAzNames(region: string, zones: readonly AvailabilityZoneInfo[]): string[] { + const supported = AGENTCORE_SUPPORTED_AZ_IDS[region]; + if (!supported) { + return []; + } + const supportedIds = new Set(supported); + return zones.filter(zone => supportedIds.has(zone.zoneId)).map(zone => zone.zoneName); +} + +/** + * Live `DescribeAvailabilityZones` lookup (default {@link DescribeAzsFn}). + * + * The `@aws-sdk/client-ec2` module is imported dynamically so it is only loaded + * when auto-pin actually runs (concrete env, no override) — not during + * env-agnostic synth or in unit tests, which inject their own lookup. + */ +async function defaultDescribeAzs(region: string): Promise { + const { EC2Client, DescribeAvailabilityZonesCommand } = await import('@aws-sdk/client-ec2'); + const client = new EC2Client({ region }); + const response = await client.send( + new DescribeAvailabilityZonesCommand({ + // Standard AZs only — exclude Local Zones / Wavelength / Outposts. + Filters: [{ Name: 'zone-type', Values: ['availability-zone'] }], + }), + ); + const zones: AvailabilityZoneInfo[] = []; + for (const zone of response.AvailabilityZones ?? []) { + if (zone.ZoneName && zone.ZoneId) { + zones.push({ zoneName: zone.ZoneName, zoneId: zone.ZoneId }); + } + } + return zones; +} + +/** + * Resolves the Availability-Zone *names* the AgentCore VPC should pin to. + * + * Resolution order: + * 1. **Operator override** — a validated {@link AGENTCORE_AZS_CONTEXT_KEY} + * context value always wins (works even in env-agnostic synth, which is how + * the CI-built artifact and production stack pin zones). + * 2. **Auto-pin (default path)** — when synth has a concrete account + region, + * resolve the account's name -> zone-ID mapping and intersect it with + * {@link AGENTCORE_SUPPORTED_AZ_IDS} for the region, so a fresh local + * `cdk deploy` lands only in supported zones without account-specific + * guesswork. + * 3. **Fallback** — env-agnostic synth (token/undefined account or region), an + * unknown region, a failed lookup, or fewer than {@link MIN_AGENTCORE_AZS} + * supported zones all return `undefined`, leaving CDK's default AZ selection + * in place. Degraded cases (2 & 3 boundary) surface a synth warning rather + * than failing, so unaffected regions and offline synth still deploy. + * + * @returns AZ names to pass to `ec2.Vpc({ availabilityZones })`, or `undefined` + * to keep the default `maxAzs` selection. + */ +export async function resolveAgentCoreAzs(options: ResolveAgentCoreAzsOptions): Promise { + const { scope, account, region } = options; + + // 1. Explicit, validated override wins (throws loudly if malformed). + const override = resolveAgentCoreAzOverride(scope.node); + if (override) { + return override; + } + + // 2. Auto-pin needs a concrete account + region. Env-agnostic synth uses + // token placeholders (the CI-built artifact + production stack) — pin via + // the override there instead. + if (!account || !region || Token.isUnresolved(account) || Token.isUnresolved(region)) { + return undefined; + } + + // 3. No published constraint for this region — don't guess. + if (!AGENTCORE_SUPPORTED_AZ_IDS[region]) { + return undefined; + } + + const describeAzs = options.describeAzs ?? defaultDescribeAzs; + + // The return sits outside the try/catch so a failed lookup degrades to the + // default AZ selection (surfaced as a warning) instead of masking the error + // with an empty result inside the catch (ts-silent-success-masking / AI004). + let pinnedZones: string[] | undefined; + try { + const zones = await describeAzs(region); + const names = selectSupportedAzNames(region, zones); + if (names.length >= MIN_AGENTCORE_AZS) { + pinnedZones = names; + } else { + Annotations.of(scope).addWarning( + `[AgentCore AZs] Found only ${names.length} AgentCore-supported availability zone(s) in ` + + `${region} (need >=${MIN_AGENTCORE_AZS}); using CDK's default AZ selection. Pin zones ` + + `explicitly via context '${AGENTCORE_AZS_CONTEXT_KEY}' if the deploy hits unsupported zones.`, + ); + } + } catch (err) { + Annotations.of(scope).addWarning( + `[AgentCore AZs] Could not resolve AgentCore-supported availability zones for ${region} ` + + `(${err instanceof Error ? err.message : String(err)}); using CDK's default AZ selection. ` + + `Pin zones explicitly via context '${AGENTCORE_AZS_CONTEXT_KEY}' if the deploy hits unsupported zones.`, + ); + } + return pinnedZones; +} diff --git a/cdk/src/main.ts b/cdk/src/main.ts index c8bc2cc88..cc594df4e 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -19,6 +19,7 @@ import { App, Aspects, Tags } from 'aws-cdk-lib'; import { AwsSolutionsChecks } from 'cdk-nag'; +import { resolveAgentCoreAzs } from './constructs/agentcore-azs'; import { AgentStack } from './stacks/agent'; // for development, use account/region from cdk cli @@ -27,53 +28,78 @@ const devEnv = { region: process.env.CDK_DEFAULT_REGION, }; -const app = new App(); +/** + * Synthesizes the app. Async because AgentCore-supported availability zones are + * resolved from the account's zone mapping at synth time (a live + * `DescribeAvailabilityZones` call) when a concrete account/region is bound. + * Env-agnostic synth and the validated context override never touch AWS. + */ +async function main(): Promise { + const app = new App(); + + Aspects.of(app).add(new AwsSolutionsChecks()); + + const stackName = app.node.tryGetContext('stackName') ?? 'backgroundagent-dev'; -Aspects.of(app).add(new AwsSolutionsChecks()); + // Auto-pin the VPC to AgentCore-supported AZs (or honor the validated + // `agentcore:availabilityZones` override). Undefined => CDK default selection. + const availabilityZones = await resolveAgentCoreAzs({ + scope: app, + account: devEnv.account, + region: devEnv.region, + }); -const stackName = app.node.tryGetContext('stackName') ?? 'backgroundagent-dev'; + const stack = new AgentStack( + app, + stackName, + { + env: devEnv, + availabilityZones, + description: 'ABCA Development Stack (uksb-wt64nei4u6)', + }, + ); -const stack = new AgentStack( - app, - stackName, - { - env: devEnv, - description: 'ABCA Development Stack (uksb-wt64nei4u6)', - }, -); + const computeType = app.node.tryGetContext('compute_type') ?? 'agentcore'; -const computeType = app.node.tryGetContext('compute_type') ?? 'agentcore'; + // Route53 Resolver resources where tag changes trigger replacement cascades. + // Config: treats ANY property change (including tags) as requiring replacement. + // Association: depends on Config's physical ID; if Config is replaced, the + // Association update fails on the one-association-per-VPC constraint. + const excludeResourceTypes = [ + 'AWS::Route53Resolver::ResolverQueryLoggingConfig', + 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation', + ]; -// Route53 Resolver resources where tag changes trigger replacement cascades. -// Config: treats ANY property change (including tags) as requiring replacement. -// Association: depends on Config's physical ID; if Config is replaced, the -// Association update fails on the one-association-per-VPC constraint. -const excludeResourceTypes = [ - 'AWS::Route53Resolver::ResolverQueryLoggingConfig', - 'AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation', -]; + Tags.of(stack).add('compute_type', computeType, { excludeResourceTypes }); -Tags.of(stack).add('compute_type', computeType, { excludeResourceTypes }); + const githubTagKeys = [ + 'sha', + 'ref', + 'ref-type', + 'actor', + 'head-ref', + 'base-ref', + 'pr-number', + 'run-id', + 'run-attempt', + 'event', + 'workflow', + 'repository', + 'clean', + ] as const; -const githubTagKeys = [ - 'sha', - 'ref', - 'ref-type', - 'actor', - 'head-ref', - 'base-ref', - 'pr-number', - 'run-id', - 'run-attempt', - 'event', - 'workflow', - 'repository', - 'clean', -] as const; + for (const key of githubTagKeys) { + const value = app.node.tryGetContext(`github:${key}`); + Tags.of(stack).add(`github:${key}`, value || 'none', { excludeResourceTypes }); + } -for (const key of githubTagKeys) { - const value = app.node.tryGetContext(`github:${key}`); - Tags.of(stack).add(`github:${key}`, value || 'none', { excludeResourceTypes }); + app.synth(); } -app.synth(); +// Surface any synth-time failure (e.g. a malformed `agentcore:availabilityZones` +// override) as a non-zero exit. `void` satisfies no-floating-promises; throwing +// from the handler triggers an unhandled rejection so the CDK CLI fails loudly. +void main().catch((err: unknown) => { + process.exitCode = 1; + throw err; +}); diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index d748c0d36..924cd750a 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -69,8 +69,23 @@ const RUNTIME_SESSION_TIMEOUT_HOURS = 8; /** Index of the stage segment in a split API Gateway URL. */ const API_URL_STAGE_SEGMENT_INDEX = 3; +/** Properties for {@link AgentStack}. */ +export interface AgentStackProps extends StackProps { + /** + * Availability-zone *names* to pin the AgentCore VPC (and therefore the + * Runtime ENIs) into, so they land only in AgentCore-supported zones. + * + * Resolved in `main.ts` via `resolveAgentCoreAzs` — the validated + * `agentcore:availabilityZones` context override, else auto-selected from the + * account's supported zones when synth has a concrete account/region. Leave + * `undefined` (env-agnostic synth, unknown region) to keep CDK's default + * `maxAzs` selection. + */ + readonly availabilityZones?: string[]; +} + export class AgentStack extends Stack { - constructor(scope: Construct, id: string, props: StackProps = {}) { + constructor(scope: Construct, id: string, props: AgentStackProps = {}) { super(scope, id, props); // Build context is repo root (not agent/) so the Dockerfile can COPY @@ -200,23 +215,17 @@ export class AgentStack extends Stack { }, ]); - // Network isolation — VPC with restricted egress + // Network isolation — VPC with restricted egress. // AgentCore only supports a subset of physical availability zones per - // region (for us-east-1: use1-az1, use1-az2, use1-az4). AZ *names* are - // aliased per-account, so the default maxAzs selection can land in an - // unsupported zone and cause a deploy failure. Use the CDK context key - // `agentcore:availabilityZones` to pin to account-specific AZ names whose - // physical zone IDs are AgentCore-supported. - // - // Discover your mapping: - // aws ec2 describe-availability-zones --region us-east-1 \ - // --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text - // - // Then set in cdk.context.json or via -c: - // "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] - const agentCoreAzs = this.node.tryGetContext('agentcore:availabilityZones') as string[] | undefined; + // region, and AZ *names* are aliased per-account, so the default maxAzs + // selection can land the Runtime ENIs in an unsupported zone and fail the + // deploy. `props.availabilityZones` carries the AZ names resolved in + // main.ts (`resolveAgentCoreAzs`): the validated `agentcore:availabilityZones` + // override, else auto-selected from the account's AgentCore-supported zones + // when synth has a concrete account/region. Left undefined otherwise, so the + // construct keeps CDK's default AZ selection. See constructs/agentcore-azs.ts. const agentVpc = new AgentVpc(this, 'AgentVpc', { - ...(agentCoreAzs ? { availabilityZones: agentCoreAzs } : {}), + ...(props.availabilityZones ? { availabilityZones: props.availabilityZones } : {}), }); // DNS Firewall — domain-level egress filtering (observation mode for initial deployment) diff --git a/cdk/test/constructs/agent-vpc.test.ts b/cdk/test/constructs/agent-vpc.test.ts index 2fcc1c882..1c206662d 100644 --- a/cdk/test/constructs/agent-vpc.test.ts +++ b/cdk/test/constructs/agent-vpc.test.ts @@ -163,6 +163,28 @@ describe('AgentVpc with custom props', () => { // 2 explicit AZs × 2 subnet types = 4 subnets template.resourceCountIs('AWS::EC2::Subnet', 4); + + // Subnets are pinned to the requested AZ *names* — the whole point of the + // fix (a wrong-count assertion would pass even if AZs were unpinned). + template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1b' }); + template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1c' }); + }); + + test('env-agnostic synth falls back to Fn::GetAZs (no pinning, no crash)', () => { + const app = new App(); + // No env → account/region are tokens. The production AgentStack synthesizes + // this way, so auto-pin is skipped and CDK selects AZs at deploy time. + const stack = new Stack(app, 'TestStack'); + new AgentVpc(stack, 'AgentVpc'); + const template = Template.fromStack(stack); + + // Default maxAzs (2) → 4 subnets; AZ resolved at deploy via Fn::GetAZs. + template.resourceCountIs('AWS::EC2::Subnet', 4); + template.hasResourceProperties('AWS::EC2::Subnet', { + AvailabilityZone: { + 'Fn::Select': Match.arrayWith([{ 'Fn::GetAZs': '' }]), + }, + }); }); test('availabilityZones with 3 zones creates 6 subnets', () => { diff --git a/cdk/test/constructs/agentcore-azs.test.ts b/cdk/test/constructs/agentcore-azs.test.ts new file mode 100644 index 000000000..1e2bde425 --- /dev/null +++ b/cdk/test/constructs/agentcore-azs.test.ts @@ -0,0 +1,199 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { + AGENTCORE_AZS_CONTEXT_KEY, + AGENTCORE_SUPPORTED_AZ_IDS, + AvailabilityZoneInfo, + resolveAgentCoreAzOverride, + resolveAgentCoreAzs, + selectSupportedAzNames, +} from '../../src/constructs/agentcore-azs'; + +function nodeWithContext(context?: Record) { + const app = new App({ context }); + return new Stack(app, 'TestStack').node; +} + +function scopeWithContext(context?: Record): Stack { + const app = new App({ context }); + return new Stack(app, 'TestStack'); +} + +// Realistic us-east-1 mapping: supported IDs are use1-az1/az2/az4. +const US_EAST_1_ZONES: AvailabilityZoneInfo[] = [ + { zoneName: 'us-east-1a', zoneId: 'use1-az2' }, // supported + { zoneName: 'us-east-1b', zoneId: 'use1-az4' }, // supported + { zoneName: 'us-east-1c', zoneId: 'use1-az6' }, // unsupported + { zoneName: 'us-east-1d', zoneId: 'use1-az1' }, // supported + { zoneName: 'us-east-1e', zoneId: 'use1-az3' }, // unsupported + { zoneName: 'us-east-1f', zoneId: 'use1-az5' }, // unsupported +]; + +describe('AGENTCORE_SUPPORTED_AZ_IDS', () => { + it('covers the documented AgentCore VPC regions', () => { + for (const region of [ + 'us-east-1', 'us-east-2', 'us-west-2', + 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ap-northeast-1', + 'eu-west-1', 'eu-central-1', + ]) { + expect(AGENTCORE_SUPPORTED_AZ_IDS[region]).toBeDefined(); + } + }); + + it('lists at least two unique physical zone IDs per region', () => { + for (const [region, ids] of Object.entries(AGENTCORE_SUPPORTED_AZ_IDS)) { + expect(ids.length).toBeGreaterThanOrEqual(2); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) { + // Physical zone-ID shape, e.g. use1-az1 (NOT a zone name like us-east-1a). + expect(id).toMatch(/^[a-z]+[0-9]+-az[0-9]+$/); + } + expect(region).toMatch(/^[a-z]{2}-[a-z]+-\d$/); + } + }); +}); + +describe('resolveAgentCoreAzOverride', () => { + it('returns undefined when the context key is unset', () => { + expect(resolveAgentCoreAzOverride(nodeWithContext())).toBeUndefined(); + }); + + it('returns the validated array when provided', () => { + const override = ['us-east-1b', 'us-east-1c']; + expect( + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: override })), + ).toEqual(override); + }); + + it('throws on a non-array override (typo guard)', () => { + expect(() => + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' })), + ).toThrow(/must be a JSON array/); + }); + + it('throws on a non-string / empty entry', () => { + expect(() => + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', ''] })), + ).toThrow(/non-empty availability-zone-name/); + }); + + it('throws when fewer than two zones are listed (HA guidance)', () => { + expect(() => + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b'] })), + ).toThrow(/at least 2 zones/); + }); +}); + +describe('selectSupportedAzNames', () => { + it('returns the zone names whose IDs are AgentCore-supported', () => { + expect(selectSupportedAzNames('us-east-1', US_EAST_1_ZONES)).toEqual([ + 'us-east-1a', + 'us-east-1b', + 'us-east-1d', + ]); + }); + + it('returns an empty array for a region with no known constraint', () => { + expect(selectSupportedAzNames('eu-north-1', US_EAST_1_ZONES)).toEqual([]); + }); + + it('returns an empty array when no account zone matches the supported set', () => { + const zones: AvailabilityZoneInfo[] = [{ zoneName: 'us-east-1c', zoneId: 'use1-az6' }]; + expect(selectSupportedAzNames('us-east-1', zones)).toEqual([]); + }); +}); + +describe('resolveAgentCoreAzs', () => { + it('returns the validated override without calling the AZ lookup', async () => { + const describeAzs = jest.fn, [string]>(); + const result = await resolveAgentCoreAzs({ + scope: scopeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1c'] }), + account: '123456789012', + region: 'us-east-1', + describeAzs, + }); + expect(result).toEqual(['us-east-1b', 'us-east-1c']); + expect(describeAzs).not.toHaveBeenCalled(); + }); + + it('rethrows a malformed override (fails synth loudly)', async () => { + await expect( + resolveAgentCoreAzs({ + scope: scopeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' }), + account: '123456789012', + region: 'us-east-1', + }), + ).rejects.toThrow(/must be a JSON array/); + }); + + it('falls back (undefined) for env-agnostic synth without touching AWS', async () => { + const describeAzs = jest.fn, [string]>(); + expect(await resolveAgentCoreAzs({ scope: scopeWithContext(), account: undefined, region: undefined, describeAzs })) + .toBeUndefined(); + expect(await resolveAgentCoreAzs({ scope: scopeWithContext(), account: '123456789012', region: undefined, describeAzs })) + .toBeUndefined(); + expect(describeAzs).not.toHaveBeenCalled(); + }); + + it('falls back (undefined) for a region with no known constraint', async () => { + const describeAzs = jest.fn, [string]>(); + const result = await resolveAgentCoreAzs({ + scope: scopeWithContext(), + account: '123456789012', + region: 'eu-north-1', + describeAzs, + }); + expect(result).toBeUndefined(); + expect(describeAzs).not.toHaveBeenCalled(); + }); + + it('auto-pins to the account supported zone names for a concrete env', async () => { + const describeAzs = jest.fn, [string]>().mockResolvedValue(US_EAST_1_ZONES); + const result = await resolveAgentCoreAzs({ + scope: scopeWithContext(), + account: '123456789012', + region: 'us-east-1', + describeAzs, + }); + expect(result).toEqual(['us-east-1a', 'us-east-1b', 'us-east-1d']); + expect(describeAzs).toHaveBeenCalledWith('us-east-1'); + }); + + it('warns and falls back when fewer than two supported zones are found', async () => { + const scope = scopeWithContext(); + const describeAzs = jest.fn, [string]>().mockResolvedValue([ + { zoneName: 'us-east-1a', zoneId: 'use1-az1' }, + { zoneName: 'us-east-1c', zoneId: 'use1-az6' }, + ]); + const result = await resolveAgentCoreAzs({ scope, account: '123456789012', region: 'us-east-1', describeAzs }); + expect(result).toBeUndefined(); + Annotations.fromStack(scope).hasWarning('*', Match.stringLikeRegexp('AgentCore AZs')); + }); + + it('warns and falls back when the AZ lookup fails', async () => { + const scope = scopeWithContext(); + const describeAzs = jest.fn, [string]>().mockRejectedValue(new Error('DescribeAZ boom')); + const result = await resolveAgentCoreAzs({ scope, account: '123456789012', region: 'us-east-1', describeAzs }); + expect(result).toBeUndefined(); + Annotations.fromStack(scope).hasWarning('*', Match.stringLikeRegexp('AgentCore AZs')); + }); +}); diff --git a/docs/guides/DEPLOYMENT_GUIDE.md b/docs/guides/DEPLOYMENT_GUIDE.md index a32b09221..c27ec196c 100644 --- a/docs/guides/DEPLOYMENT_GUIDE.md +++ b/docs/guides/DEPLOYMENT_GUIDE.md @@ -163,6 +163,31 @@ Triggers via `workflow_run` when `build.yml` completes successfully. The pipelin ## Known deployment issues +### AgentCore unsupported Availability Zones + +**Affects:** Fresh deploys in accounts whose default Availability Zones don't line up with the zones AgentCore supports for the region. + +**Symptom:** The `AWS::BedrockAgentCore::Runtime` resource fails to stabilize (`NotStabilized` — "subnets are in unsupported availability zones") and the stack rolls back. + +**Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones) table for the per-region set. + +**Default behavior (auto-pin):** When the stack is synthesized with a concrete account and region — the usual local `cdk deploy` / `mise //cdk:deploy` path — it resolves your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the AZ *names* whose IDs are AgentCore-supported. No action needed for supported regions. + +**When you must pin manually:** Env-agnostic synthesis skips auto-pin (there's no bound account to resolve the mapping) — this includes the CI-built artifact deployed by `deploy.yml`, and any region not yet in the built-in supported-AZ map. In those cases, set the `agentcore:availabilityZones` context override: + +1. Discover your account's zone name-to-ID mapping: + ```bash + aws ec2 describe-availability-zones --region \ + --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text + ``` +2. Pick the zone **names** whose **IDs** are in the AgentCore-supported set for the region (at least two, for high availability). +3. Set the override in `cdk/cdk.context.json` (or via `-c`), then redeploy: + ```json + { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } + ``` + +The override is validated at synth time: a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. + ### DNS Query Log Config replacement cascade (upgrading from pre-v0.5) **Affects:** Stacks deployed *before* the tag-exclusion fix ([#222](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/pull/222)). Stacks created after this fix are not affected. diff --git a/docs/guides/QUICK_START.mdx b/docs/guides/QUICK_START.mdx index 45e851518..ee0d33ca9 100644 --- a/docs/guides/QUICK_START.mdx +++ b/docs/guides/QUICK_START.mdx @@ -72,7 +72,7 @@ If `lib/bin/bgagent.js` is missing, run `mise run build` from `cli/` (or repeat ::: -> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build. +> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build. A local deploy uses that same call to auto-pin the VPC to AgentCore-supported zones; if you deploy from an env-agnostic artifact or hit "unsupported availability zones", set the `agentcore:availabilityZones` override — see [Known deployment issues](./DEPLOYMENT_GUIDE.md#agentcore-unsupported-availability-zones). ## Step 2 - Prepare a repository @@ -514,6 +514,7 @@ Here is what the platform did after you ran `node lib/bin/bgagent.js submit`: | CDK deploy prompts for approval and hangs | Non-interactive terminal (CI/CD, scripts) | Pass `--require-approval never` to `cdk deploy` (Step 3 uses it) or use an interactive terminal | | Deploy rolled back; can't redeploy (`ROLLBACK_COMPLETE`) | A first-create failure leaves the stack un-updatable | `mise //cdk:destroy` (or delete the stack), then deploy again. Do **not** force-delete past stuck VPC resources — it orphans the VPC, and VPCs are quota-capped per Region | | Stack stuck in `DELETE_FAILED` on a security group / subnet | AgentCore's service-managed (Hyperplane) ENIs reclaim asynchronously after the runtime is gone | Wait ~20–40 min for AWS to release the ENIs, then retry `mise //cdk:destroy`. You cannot force-detach an `amazon-aws`-owned ENI | +| Runtime rollback: `NotStabilized` / "subnets are in unsupported availability zones" | VPC subnets landed in AZs AgentCore doesn't support (auto-pin skipped for env-agnostic synth, or region not in the built-in map) | Set the `agentcore:availabilityZones` override to supported zone names — see [Known deployment issues](./DEPLOYMENT_GUIDE.md#agentcore-unsupported-availability-zones) | | `put-secret-value` returns double-dot endpoint | `REGION` variable is empty | Set `REGION=us-east-1` (or your actual region) before running the command | | Model / Bedrock errors in logs (`not available on your bedrock`, zero tokens) | Model not entitled for the account or Region, wrong `modelId` shape, or missing Marketplace / FTU steps | Follow **Amazon Bedrock before your first task** above; confirm [model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) and use an [inference profile](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html) ID such as `us.anthropic.claude-sonnet-4-6` where required; keep `grantInvoke` in `agent.ts` aligned with that model | | `REPO_NOT_ONBOARDED` on task submit | Blueprint `repo` does not match what you passed to the CLI | Confirm `BLUEPRINT_REPO`, CDK context `blueprintRepo`, or the `repo` prop on the `Blueprint` in `cdk/src/stacks/agent.ts` resolves to exactly the same `owner/repo` you pass to the CLI | diff --git a/docs/src/content/docs/getting-started/Deployment-guide.md b/docs/src/content/docs/getting-started/Deployment-guide.md index 01f6e2ce5..c36653b19 100644 --- a/docs/src/content/docs/getting-started/Deployment-guide.md +++ b/docs/src/content/docs/getting-started/Deployment-guide.md @@ -167,6 +167,31 @@ Triggers via `workflow_run` when `build.yml` completes successfully. The pipelin ## Known deployment issues +### AgentCore unsupported Availability Zones + +**Affects:** Fresh deploys in accounts whose default Availability Zones don't line up with the zones AgentCore supports for the region. + +**Symptom:** The `AWS::BedrockAgentCore::Runtime` resource fails to stabilize (`NotStabilized` — "subnets are in unsupported availability zones") and the stack rolls back. + +**Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones) table for the per-region set. + +**Default behavior (auto-pin):** When the stack is synthesized with a concrete account and region — the usual local `cdk deploy` / `mise //cdk:deploy` path — it resolves your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the AZ *names* whose IDs are AgentCore-supported. No action needed for supported regions. + +**When you must pin manually:** Env-agnostic synthesis skips auto-pin (there's no bound account to resolve the mapping) — this includes the CI-built artifact deployed by `deploy.yml`, and any region not yet in the built-in supported-AZ map. In those cases, set the `agentcore:availabilityZones` context override: + +1. Discover your account's zone name-to-ID mapping: + ```bash + aws ec2 describe-availability-zones --region \ + --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text + ``` +2. Pick the zone **names** whose **IDs** are in the AgentCore-supported set for the region (at least two, for high availability). +3. Set the override in `cdk/cdk.context.json` (or via `-c`), then redeploy: + ```json + { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } + ``` + +The override is validated at synth time: a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. + ### DNS Query Log Config replacement cascade (upgrading from pre-v0.5) **Affects:** Stacks deployed *before* the tag-exclusion fix ([#222](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/pull/222)). Stacks created after this fix are not affected. diff --git a/docs/src/content/docs/getting-started/Quick-start.mdx b/docs/src/content/docs/getting-started/Quick-start.mdx index e12fb871c..a1a32ae08 100644 --- a/docs/src/content/docs/getting-started/Quick-start.mdx +++ b/docs/src/content/docs/getting-started/Quick-start.mdx @@ -72,7 +72,7 @@ If `lib/bin/bgagent.js` is missing, run `mise run build` from `cli/` (or repeat ::: -> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build. +> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build. A local deploy uses that same call to auto-pin the VPC to AgentCore-supported zones; if you deploy from an env-agnostic artifact or hit "unsupported availability zones", set the `agentcore:availabilityZones` override — see [Known deployment issues](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide#agentcore-unsupported-availability-zones). ## Step 2 - Prepare a repository @@ -514,6 +514,7 @@ Here is what the platform did after you ran `node lib/bin/bgagent.js submit`: | CDK deploy prompts for approval and hangs | Non-interactive terminal (CI/CD, scripts) | Pass `--require-approval never` to `cdk deploy` (Step 3 uses it) or use an interactive terminal | | Deploy rolled back; can't redeploy (`ROLLBACK_COMPLETE`) | A first-create failure leaves the stack un-updatable | `mise //cdk:destroy` (or delete the stack), then deploy again. Do **not** force-delete past stuck VPC resources — it orphans the VPC, and VPCs are quota-capped per Region | | Stack stuck in `DELETE_FAILED` on a security group / subnet | AgentCore's service-managed (Hyperplane) ENIs reclaim asynchronously after the runtime is gone | Wait ~20–40 min for AWS to release the ENIs, then retry `mise //cdk:destroy`. You cannot force-detach an `amazon-aws`-owned ENI | +| Runtime rollback: `NotStabilized` / "subnets are in unsupported availability zones" | VPC subnets landed in AZs AgentCore doesn't support (auto-pin skipped for env-agnostic synth, or region not in the built-in map) | Set the `agentcore:availabilityZones` override to supported zone names — see [Known deployment issues](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide#agentcore-unsupported-availability-zones) | | `put-secret-value` returns double-dot endpoint | `REGION` variable is empty | Set `REGION=us-east-1` (or your actual region) before running the command | | Model / Bedrock errors in logs (`not available on your bedrock`, zero tokens) | Model not entitled for the account or Region, wrong `modelId` shape, or missing Marketplace / FTU steps | Follow **Amazon Bedrock before your first task** above; confirm [model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) and use an [inference profile](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html) ID such as `us.anthropic.claude-sonnet-4-6` where required; keep `grantInvoke` in `agent.ts` aligned with that model | | `REPO_NOT_ONBOARDED` on task submit | Blueprint `repo` does not match what you passed to the CLI | Confirm `BLUEPRINT_REPO`, CDK context `blueprintRepo`, or the `repo` prop on the `Blueprint` in `cdk/src/stacks/agent.ts` resolves to exactly the same `owner/repo` you pass to the CLI | diff --git a/yarn.lock b/yarn.lock index 04b6df251..71cda37c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -478,6 +478,21 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/client-ec2@^3.1078.0": + version "3.1087.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-ec2/-/client-ec2-3.1087.0.tgz#eb234305bbaada457aa7d75d75956c3e210df672" + integrity sha512-4QcrDvc5mBA3mfhWTTiv32ZIYUhVdxT89b1faimTfU29B4e7Kb2//+WtXN1hbIx6W+tZZ/wriwylapRfL2AhEQ== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/credential-provider-node" "^3.972.68" + "@aws-sdk/middleware-sdk-ec2" "^3.972.46" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/fetch-http-handler" "^5.6.5" + "@smithy/node-http-handler" "^4.9.5" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/client-ecs@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-ecs/-/client-ecs-3.1081.0.tgz#05f57706640d9f3e6853fbb5d302c7940b91f32a" @@ -565,6 +580,20 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/core@^3.975.2": + version "3.975.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.975.2.tgz#743f5ccf805721da3fd330155f5e374e8dbc8969" + integrity sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg== + dependencies: + "@aws-sdk/types" "^3.974.1" + "@aws-sdk/xml-builder" "^3.972.35" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.29.3" + "@smithy/signature-v4" "^5.6.3" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz#1129acf8860db362a30a02a531387fd399448268" @@ -576,6 +605,17 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@^3.972.58": + version "3.972.58" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.58.tgz#74592896db48dc9fec7852ba0e842c06c13f0f52" + integrity sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@^3.972.57": version "3.972.57" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz#4f73bb2f0e03525ab11e001ac18c39cb120dd14c" @@ -589,6 +629,19 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@^3.972.60": + version "3.972.60" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.60.tgz#da1d2646fb1bee732436b75ab5cda18f06554727" + integrity sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/fetch-http-handler" "^5.6.5" + "@smithy/node-http-handler" "^4.9.5" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@^3.972.62": version "3.972.62" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz#a2ca7fc7a1899c0a4a38e501baa666cf1449f2e9" @@ -608,6 +661,25 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@^3.973.2": + version "3.973.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.2.tgz#c66b59528b985baeb620926a4582f8d821f7f5f4" + integrity sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/credential-provider-env" "^3.972.58" + "@aws-sdk/credential-provider-http" "^3.972.60" + "@aws-sdk/credential-provider-login" "^3.972.64" + "@aws-sdk/credential-provider-process" "^3.972.58" + "@aws-sdk/credential-provider-sso" "^3.973.2" + "@aws-sdk/credential-provider-web-identity" "^3.972.64" + "@aws-sdk/nested-clients" "^3.997.32" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/credential-provider-imds" "^4.4.7" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-login@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz#f5ba696bae9af3505ae5f3e2a70d7e216d0d37f6" @@ -620,6 +692,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-login@^3.972.64": + version "3.972.64" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.64.tgz#d323b16bf9857f2248b234e5f83e033f3981d50d" + integrity sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/nested-clients" "^3.997.32" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@^3.972.61", "@aws-sdk/credential-provider-node@^3.972.64": version "3.972.64" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz#282456c24bad616faefe2a6c68701913f8289c10" @@ -637,6 +721,23 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@^3.972.68": + version "3.972.68" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.68.tgz#300fd3c97c01f9669c34dc2def1f7e29d3b0ff7c" + integrity sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.58" + "@aws-sdk/credential-provider-http" "^3.972.60" + "@aws-sdk/credential-provider-ini" "^3.973.2" + "@aws-sdk/credential-provider-process" "^3.972.58" + "@aws-sdk/credential-provider-sso" "^3.973.2" + "@aws-sdk/credential-provider-web-identity" "^3.972.64" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/credential-provider-imds" "^4.4.7" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz#bebd30d0065ca34f64e14a870f54a09f23cf11e2" @@ -648,6 +749,17 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@^3.972.58": + version "3.972.58" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.58.tgz#ac55b2271ea5672e8b00e390f6b00f9f2433ad2b" + integrity sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz#8c15846c65d2f03bfca3347626e1fcd1a0f40726" @@ -661,6 +773,19 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@^3.973.2": + version "3.973.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.2.tgz#bdf55f712b8ddf70b18be483a4c8432c6e2e876c" + integrity sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/nested-clients" "^3.997.32" + "@aws-sdk/token-providers" "3.1087.0" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz#1938cc425e6156acd673484f10b437c5c4c85158" @@ -673,6 +798,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@^3.972.64": + version "3.972.64" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.64.tgz#fdeee7ac25a1d9e6c791184ba93eb18c74eeea34" + integrity sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/nested-clients" "^3.997.32" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/dynamodb-codec@^3.973.26", "@aws-sdk/dynamodb-codec@^3.973.29": version "3.973.29" resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.29.tgz#ea72996b2e1f2c687254c69c357fa4278e42f1da" @@ -744,6 +881,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/middleware-sdk-ec2@^3.972.46": + version "3.972.46" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.46.tgz#9fb2f43ab8033084ee9684785ec4d81b9fac5db6" + integrity sha512-H73wH+QnWnXHFKIRpewMkLLczp+Fkmb/bkMutW13ABb3xLKbfPU/TTdOADZ+f0ZE+RbQ6K1y+PfjEziL2cRxPA== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/signature-v4" "^5.6.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/middleware-sdk-s3@^3.972.60": version "3.972.60" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.60.tgz#2aef54e9f4b352268f2dc9a0b3fe628795826512" @@ -783,6 +932,20 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/nested-clients@^3.997.32": + version "3.997.32" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.32.tgz#3ddb2ccfc5f8a50041eedc5981c441dd984f419b" + integrity sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/signature-v4-multi-region" "^3.996.40" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/fetch-http-handler" "^5.6.5" + "@smithy/node-http-handler" "^4.9.5" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/s3-presigned-post@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.1081.0.tgz#ed27961af8e772e23745e7ff34f34083bc69c291" @@ -818,6 +981,16 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/signature-v4-multi-region@^3.996.40": + version "3.996.40" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.40.tgz#f8caa07c2463e24d7a13bf2d5a77d7083dc0ecda" + integrity sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g== + dependencies: + "@aws-sdk/types" "^3.974.1" + "@smithy/signature-v4" "^5.6.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.1078.0": version "3.1078.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz#554be2f2c42f21191f31aead718bcedf2047926d" @@ -842,6 +1015,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/token-providers@3.1087.0": + version "3.1087.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1087.0.tgz#0cd1b7c41a092340a287a2d009027ef88bc7635b" + integrity sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ== + dependencies: + "@aws-sdk/core" "^3.975.2" + "@aws-sdk/nested-clients" "^3.997.32" + "@aws-sdk/types" "^3.974.1" + "@smithy/core" "^3.29.3" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.15": version "3.973.15" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.15.tgz#98a4860bed33c32c7088924d0ab52f9eabbdf7c3" @@ -850,6 +1035,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/types@^3.974.1": + version "3.974.1" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.1.tgz#ac3f8943d2a04c1ced470452494b7723b926714e" + integrity sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/util-dynamodb@^3.996.5": version "3.996.5" resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.5.tgz#2ad647e1532c20c76570a878e49f058a8d21e012" @@ -865,6 +1058,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.35": + version "3.972.35" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.35.tgz#8affa66cb8836168b66dd3a76eeebb8165ed9672" + integrity sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws/durable-execution-sdk-js@^2.0.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-2.1.0.tgz#a020f8f3eae0fd3b577ad55397e454241a9e3029" @@ -2744,6 +2945,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/core@^3.29.3", "@smithy/core@^3.29.4": + version "3.29.4" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.29.4.tgz#ff4f9ba70e0276c892cd3dad5dbef54fc4945464" + integrity sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^4.4.5": version "4.4.6" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz#048ad961282016fc2d46d457f43beac8ca8f0e24" @@ -2753,6 +2962,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/credential-provider-imds@^4.4.7": + version "4.4.9" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.9.tgz#7ae40b9e1faab4256197335353463b3788591e13" + integrity sha512-2nfV4qRKiYeXU4zD2vvSCfg5dfp/BuhrM73vt7q9gzBhxs4rbPxXY21wo+kyI3bRmXcEGRnCLTaW8O437jzHIg== + dependencies: + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/fetch-http-handler@^5.6.2": version "5.6.3" resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz#7b4aec28f89bd1e3aa00dfae0d0bd30079c58341" @@ -2762,6 +2980,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/fetch-http-handler@^5.6.5": + version "5.6.6" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.6.tgz#eee36126fb4f9cd4aee278ae8f20fb6a83f02b07" + integrity sha512-NHLgAlORUFZjn5ZfhYuyyKMlXA1WLYOdGxEhyNxrPpbJzoacGbl0chn1lN2KiZ8mpNVk0tV5607CSYlYs/OFgw== + dependencies: + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/is-array-buffer@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" @@ -2778,6 +3005,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/node-http-handler@^4.9.5": + version "4.9.6" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.6.tgz#a1756af48d8f1250aaa7225e74a98d95900383fb" + integrity sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg== + dependencies: + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/protocol-http@^5.5.5": version "5.5.6" resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.5.6.tgz#62db7e22b4446168ad091b8fe90d71b05c0fd8bd" @@ -2795,6 +3031,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/signature-v4@^5.6.3": + version "5.6.5" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.5.tgz#163dce1523903d251c86da30a0725e6801072d20" + integrity sha512-MO5VEhwVl0BN7xVoVeNrZfiUFoQtqxUbgl6/RwOTlMMxCSjblG8twSrVTwz3J4w9WZxd2rBfBAUXjH77agspBg== + dependencies: + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/types@^4.15.1": version "4.15.1" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.15.1.tgz#cdad0fdf4c5d1f5788dc21a3f1fb6af523d79da0" @@ -2802,6 +3047,13 @@ dependencies: tslib "^2.6.2" +"@smithy/types@^4.16.1": + version "4.16.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== + dependencies: + tslib "^2.6.2" + "@smithy/util-buffer-from@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" From 2891801edf8af7a6c56589ea6a4fa10b21560604 Mon Sep 17 00:00:00 2001 From: bgagent Date: Wed, 15 Jul 2026 15:33:29 -0400 Subject: [PATCH 3/5] fix(cdk): accept -c JSON-string AZ override + cap auto-pin at 2 (#353) Follow-up to the auto-pin review on #358. - resolveAgentCoreAzOverride now JSON-parses a string context value, so the documented '-c agentcore:availabilityZones=[...]' recovery path works identically to the cdk.context.json array form (CDK delivers -c values as raw strings). A non-JSON string is left as-is and still fails the array check with the same clear, key-named error, so true typos error out. - Auto-pin now pins the first MIN_AGENTCORE_AZS (2) supported zones instead of every match, matching AgentVpc's default maxAzs so enabling auto-pin no longer silently widens a working 2-AZ account to all supported zones (e.g. 3 AZs / 6 subnets). - DEPLOYMENT_GUIDE: sharpened the contrast that auto-pin is local-deploy-only and the CI/CD (deploy.yml) artifact is env-agnostic and must set the override; added an explicit -c example now that it parses. - Tests: added JSON-string override (success) and JSON-non-array (throws) cases; updated the auto-pin test to assert the 2-zone cap. --- cdk/src/constructs/agentcore-azs.ts | 32 +++++++++++++++---- cdk/test/constructs/agentcore-azs.test.ts | 22 +++++++++++-- docs/guides/DEPLOYMENT_GUIDE.md | 14 +++++--- .../docs/getting-started/Deployment-guide.md | 14 +++++--- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/cdk/src/constructs/agentcore-azs.ts b/cdk/src/constructs/agentcore-azs.ts index 2d3d1a8e4..fb7ad06a3 100644 --- a/cdk/src/constructs/agentcore-azs.ts +++ b/cdk/src/constructs/agentcore-azs.ts @@ -100,10 +100,23 @@ export interface ResolveAgentCoreAzsOptions { * or lists fewer than {@link MIN_AGENTCORE_AZS} zones. */ export function resolveAgentCoreAzOverride(node: Node): string[] | undefined { - const override = node.tryGetContext(AGENTCORE_AZS_CONTEXT_KEY); - if (override === undefined || override === null) { + const raw = node.tryGetContext(AGENTCORE_AZS_CONTEXT_KEY); + if (raw === undefined || raw === null) { return undefined; } + // `cdk.context.json` delivers a real array, but `-c key=value` on the CLI + // (the recovery path this feature exists for) delivers a raw string. Parse the + // string form so both behave identically. A non-JSON string — a true typo — + // is left as-is and fails the Array.isArray check below with the same clear, + // key-named error. + let override: unknown = raw; + if (typeof raw === 'string') { + try { + override = JSON.parse(raw); + } catch { + override = raw; + } + } if (!Array.isArray(override)) { throw new Error( `Context '${AGENTCORE_AZS_CONTEXT_KEY}' must be a JSON array of availability-zone names ` @@ -176,10 +189,11 @@ async function defaultDescribeAzs(region: string): Promise zone-ID mapping and intersect it with - * {@link AGENTCORE_SUPPORTED_AZ_IDS} for the region, so a fresh local - * `cdk deploy` lands only in supported zones without account-specific - * guesswork. + * resolve the account's name -> zone-ID mapping, intersect it with + * {@link AGENTCORE_SUPPORTED_AZ_IDS} for the region, and pin the first + * {@link MIN_AGENTCORE_AZS} supported zones (matching AgentVpc's default + * `maxAzs`), so a fresh local `cdk deploy` lands only in supported zones + * without account-specific guesswork or widening the topology. * 3. **Fallback** — env-agnostic synth (token/undefined account or region), an * unknown region, a failed lookup, or fewer than {@link MIN_AGENTCORE_AZS} * supported zones all return `undefined`, leaving CDK's default AZ selection @@ -220,7 +234,11 @@ export async function resolveAgentCoreAzs(options: ResolveAgentCoreAzsOptions): const zones = await describeAzs(region); const names = selectSupportedAzNames(region, zones); if (names.length >= MIN_AGENTCORE_AZS) { - pinnedZones = names; + // Pin exactly MIN_AGENTCORE_AZS zones — the HA floor, matching AgentVpc's + // default `maxAzs` (2). Pinning every supported zone would silently widen + // an account that deploys fine today from 2 AZs to all supported (often 3 + // -> 6 subnets), so cap the selection to keep the topology stable. + pinnedZones = names.slice(0, MIN_AGENTCORE_AZS); } else { Annotations.of(scope).addWarning( `[AgentCore AZs] Found only ${names.length} AgentCore-supported availability zone(s) in ` diff --git a/cdk/test/constructs/agentcore-azs.test.ts b/cdk/test/constructs/agentcore-azs.test.ts index 1e2bde425..1bc6a7006 100644 --- a/cdk/test/constructs/agentcore-azs.test.ts +++ b/cdk/test/constructs/agentcore-azs.test.ts @@ -84,12 +84,26 @@ describe('resolveAgentCoreAzOverride', () => { ).toEqual(override); }); - it('throws on a non-array override (typo guard)', () => { + it('throws on a bare (non-JSON) string override (typo guard)', () => { expect(() => resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' })), ).toThrow(/must be a JSON array/); }); + it('parses a JSON-string array (the `-c key=value` CLI form)', () => { + // CDK delivers `-c agentcore:availabilityZones=[...]` as a raw string, not a + // parsed array — the documented mid-rollback recovery path must still work. + expect( + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: '["us-east-1b","us-east-1c"]' })), + ).toEqual(['us-east-1b', 'us-east-1c']); + }); + + it('throws on a JSON string that does not parse to an array', () => { + expect(() => + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: '"us-east-1b"' })), + ).toThrow(/must be a JSON array/); + }); + it('throws on a non-string / empty entry', () => { expect(() => resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', ''] })), @@ -166,7 +180,9 @@ describe('resolveAgentCoreAzs', () => { expect(describeAzs).not.toHaveBeenCalled(); }); - it('auto-pins to the account supported zone names for a concrete env', async () => { + it('auto-pins the first two supported zone names (capped) for a concrete env', async () => { + // US_EAST_1_ZONES has three supported zones (az2/az4/az1 -> a/b/d); auto-pin + // caps at two to match AgentVpc's default maxAzs and avoid widening topology. const describeAzs = jest.fn, [string]>().mockResolvedValue(US_EAST_1_ZONES); const result = await resolveAgentCoreAzs({ scope: scopeWithContext(), @@ -174,7 +190,7 @@ describe('resolveAgentCoreAzs', () => { region: 'us-east-1', describeAzs, }); - expect(result).toEqual(['us-east-1a', 'us-east-1b', 'us-east-1d']); + expect(result).toEqual(['us-east-1a', 'us-east-1b']); expect(describeAzs).toHaveBeenCalledWith('us-east-1'); }); diff --git a/docs/guides/DEPLOYMENT_GUIDE.md b/docs/guides/DEPLOYMENT_GUIDE.md index c27ec196c..5d3de7e33 100644 --- a/docs/guides/DEPLOYMENT_GUIDE.md +++ b/docs/guides/DEPLOYMENT_GUIDE.md @@ -171,22 +171,26 @@ Triggers via `workflow_run` when `build.yml` completes successfully. The pipelin **Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones) table for the per-region set. -**Default behavior (auto-pin):** When the stack is synthesized with a concrete account and region — the usual local `cdk deploy` / `mise //cdk:deploy` path — it resolves your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the AZ *names* whose IDs are AgentCore-supported. No action needed for supported regions. +**Default behavior (auto-pin) — local deploy only:** When the stack is synthesized with a concrete account and region — a local `cdk deploy` / `mise //cdk:deploy`, which resolves credentials at synth time — it reads your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the first two AZ *names* whose IDs are AgentCore-supported (two matches AgentVpc's default `maxAzs`, so enabling this doesn't widen an already-working topology). No action needed on that path for the regions in the built-in map. -**When you must pin manually:** Env-agnostic synthesis skips auto-pin (there's no bound account to resolve the mapping) — this includes the CI-built artifact deployed by `deploy.yml`, and any region not yet in the built-in supported-AZ map. In those cases, set the `agentcore:availabilityZones` context override: +**The CI/CD-deployed stack is NOT auto-pinned.** Auto-pin needs a bound account at synth time, and the pipeline doesn't have one: `build.yml` synthesizes `cdk.out` credential-less (env-agnostic), and `deploy.yml` deploys that pre-built artifact (`--app cdk/cdk.out`). So a team deploying through the pipeline falls back to CDK's default AZ selection and **must set the `agentcore:availabilityZones` override to pin zones**. The same applies to any region not yet in the built-in supported-AZ map. To set the override: 1. Discover your account's zone name-to-ID mapping: ```bash aws ec2 describe-availability-zones --region \ --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text ``` -2. Pick the zone **names** whose **IDs** are in the AgentCore-supported set for the region (at least two, for high availability). -3. Set the override in `cdk/cdk.context.json` (or via `-c`), then redeploy: +2. Pick at least two zone **names** whose **IDs** are in the AgentCore-supported set for the region (high-availability floor). +3. Set the override — either in `cdk/cdk.context.json`: ```json { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } ``` + or on the CLI (note the value is a JSON array): + ```bash + cdk deploy -c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]' + ``` -The override is validated at synth time: a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. +The override is validated at synth time (both forms parse identically): a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. ### DNS Query Log Config replacement cascade (upgrading from pre-v0.5) diff --git a/docs/src/content/docs/getting-started/Deployment-guide.md b/docs/src/content/docs/getting-started/Deployment-guide.md index c36653b19..2035170fa 100644 --- a/docs/src/content/docs/getting-started/Deployment-guide.md +++ b/docs/src/content/docs/getting-started/Deployment-guide.md @@ -175,22 +175,26 @@ Triggers via `workflow_run` when `build.yml` completes successfully. The pipelin **Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones) table for the per-region set. -**Default behavior (auto-pin):** When the stack is synthesized with a concrete account and region — the usual local `cdk deploy` / `mise //cdk:deploy` path — it resolves your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the AZ *names* whose IDs are AgentCore-supported. No action needed for supported regions. +**Default behavior (auto-pin) — local deploy only:** When the stack is synthesized with a concrete account and region — a local `cdk deploy` / `mise //cdk:deploy`, which resolves credentials at synth time — it reads your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the first two AZ *names* whose IDs are AgentCore-supported (two matches AgentVpc's default `maxAzs`, so enabling this doesn't widen an already-working topology). No action needed on that path for the regions in the built-in map. -**When you must pin manually:** Env-agnostic synthesis skips auto-pin (there's no bound account to resolve the mapping) — this includes the CI-built artifact deployed by `deploy.yml`, and any region not yet in the built-in supported-AZ map. In those cases, set the `agentcore:availabilityZones` context override: +**The CI/CD-deployed stack is NOT auto-pinned.** Auto-pin needs a bound account at synth time, and the pipeline doesn't have one: `build.yml` synthesizes `cdk.out` credential-less (env-agnostic), and `deploy.yml` deploys that pre-built artifact (`--app cdk/cdk.out`). So a team deploying through the pipeline falls back to CDK's default AZ selection and **must set the `agentcore:availabilityZones` override to pin zones**. The same applies to any region not yet in the built-in supported-AZ map. To set the override: 1. Discover your account's zone name-to-ID mapping: ```bash aws ec2 describe-availability-zones --region \ --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text ``` -2. Pick the zone **names** whose **IDs** are in the AgentCore-supported set for the region (at least two, for high availability). -3. Set the override in `cdk/cdk.context.json` (or via `-c`), then redeploy: +2. Pick at least two zone **names** whose **IDs** are in the AgentCore-supported set for the region (high-availability floor). +3. Set the override — either in `cdk/cdk.context.json`: ```json { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } ``` + or on the CLI (note the value is a JSON array): + ```bash + cdk deploy -c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]' + ``` -The override is validated at synth time: a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. +The override is validated at synth time (both forms parse identically): a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. ### DNS Query Log Config replacement cascade (upgrading from pre-v0.5) From d1fcec5debb51ad961d535035468d930f1546a0b Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 20 Jul 2026 12:56:17 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(cdk):=20accept=20-c=20JSON-string=20bed?= =?UTF-8?q?rockModels=20override=20(#628)=20resolveBedrockModelIds=20rejec?= =?UTF-8?q?ted=20any=20non-array=20context=20value,=20but=20CDK=20delivers?= =?UTF-8?q?=20a=20`-c=20key=3Dvalue`=20context=20value=20as=20a=20raw=20st?= =?UTF-8?q?ring=20=E2=80=94=20so=20the=20`-c=20bedrockModels=3D'[...]'`=20?= =?UTF-8?q?form=20the=20function's=20own=20JSDoc=20advertises=20threw=20at?= =?UTF-8?q?=20synth,=20while=20the=20cdk.context.json=20array=20form=20wor?= =?UTF-8?q?ked.=20JSON-parse=20a=20string=20context=20value=20before=20the?= =?UTF-8?q?=20array=20check=20so=20both=20forms=20behave=20identically.=20?= =?UTF-8?q?A=20non-JSON=20string=20(a=20true=20typo)=20is=20left=20as-is?= =?UTF-8?q?=20and=20still=20fails=20the=20array=20check=20with=20the=20sam?= =?UTF-8?q?e=20clear,=20key-named=20error,=20so=20loud-fail=20on=20malform?= =?UTF-8?q?ed=20input=20is=20preserved.=20Same=20fix=20shape=20as=20resolv?= =?UTF-8?q?eAgentCoreAzOverride=20(#358=20review=20follow-up).=20Tests:=20?= =?UTF-8?q?added=20-c=20JSON-string=20array=20(success)=20and=20JSON-strin?= =?UTF-8?q?g-non-array=20(throws)=20cases.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cdk/src/constructs/bedrock-models.ts | 16 ++++++++++++++-- cdk/test/constructs/bedrock-models.test.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/cdk/src/constructs/bedrock-models.ts b/cdk/src/constructs/bedrock-models.ts index df3e127c1..76bd52fa0 100644 --- a/cdk/src/constructs/bedrock-models.ts +++ b/cdk/src/constructs/bedrock-models.ts @@ -58,10 +58,22 @@ export const BEDROCK_MODELS_CONTEXT_KEY = 'bedrockModels'; * granting nothing or an invalid ARN. */ export function resolveBedrockModelIds(node: Node): readonly string[] { - const override = node.tryGetContext(BEDROCK_MODELS_CONTEXT_KEY); - if (override === undefined || override === null) { + const raw = node.tryGetContext(BEDROCK_MODELS_CONTEXT_KEY); + if (raw === undefined || raw === null) { return DEFAULT_BEDROCK_MODEL_IDS; } + // `cdk.context.json` delivers a real array, but the `-c key=value` form + // documented above delivers a raw string. Parse the string form so both + // behave identically. A non-JSON string — a true typo — is left as-is and + // fails the array check below with the same clear, key-named error. + let override: unknown = raw; + if (typeof raw === 'string') { + try { + override = JSON.parse(raw); + } catch { + override = raw; + } + } if (!Array.isArray(override) || override.length === 0) { throw new Error( `Context '${BEDROCK_MODELS_CONTEXT_KEY}' must be a non-empty array of foundation-model IDs ` diff --git a/cdk/test/constructs/bedrock-models.test.ts b/cdk/test/constructs/bedrock-models.test.ts index e39ff504d..1fc2aa23e 100644 --- a/cdk/test/constructs/bedrock-models.test.ts +++ b/cdk/test/constructs/bedrock-models.test.ts @@ -41,6 +41,21 @@ describe('resolveBedrockModelIds', () => { expect(ids).toEqual(override); }); + it('parses a JSON-string override (the `-c key=value` CLI form)', () => { + // CDK delivers `-c bedrockModels=[...]` as a raw string, not a parsed array; + // the documented `-c` form must work identically to the file/array form. + const ids = resolveBedrockModelIds( + nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: '["anthropic.claude-opus-4-8","anthropic.claude-sonnet-4-6"]' }), + ); + expect(ids).toEqual(['anthropic.claude-opus-4-8', 'anthropic.claude-sonnet-4-6']); + }); + + it('throws on a JSON string that does not parse to an array', () => { + expect(() => + resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: '"anthropic.claude-sonnet-4-6"' })), + ).toThrow(/must be a non-empty array/); + }); + it('throws on a non-array override (typo guard)', () => { expect(() => resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: 'anthropic.claude-opus-4-8' })), From 9f0f24774b1f486b365daced567f82512e6ed03a Mon Sep 17 00:00:00 2001 From: bgagent Date: Thu, 27 Aug 2026 13:19:18 -0400 Subject: [PATCH 5/5] fix(cdk): make AgentCore AZ pinning observable, complete, and enforceable (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the five blocking findings from the from-scratch re-review of 8e95d1a. 1. Degraded paths were silent. resolveAgentCoreAzs annotated the App node, but CDK only collects annotations that hang off a *stack*, so every diagnostic was dropped: a denied/throttled/proxied DescribeAvailabilityZones produced a clean exit-0 synth, an unpinned VPC, and then the NotStabilized rollback this feature exists to prevent. The resolver now returns {zones, diagnostics} and main.ts attaches them to the stack via applyAgentCoreAzDiagnostics after it exists. Failure to complete an attempted auto-pin is now error-level (fail closed — verified against the CDK CLI: error annotations make it exit 1 with "Synthesis finished with errors"); genuinely-unknowable cases stay warnings. The old tests passed a Stack, a scope production never used, so they guarded the wrong seam; the new test/main.test.ts drives buildApp() and asserts on stack-artifact messages, and fails if the annotation target regresses. 2. The region map covered 9 of 20 published regions; the other 11 took the "unknown region" branch and reproduced the bug. Completed from the AWS source table (incl. ca-central-1's cac1-az4), added a snapshot date and a documented update path following microvm-regions.ts. The old test asserted the map's key list against itself — it could not detect drift or a wrong ID, and used eu-north-1 (which IS supported) as its unsupported example. Now deep-equalled against an independently transcribed literal. 3. The source URL 404'd in all four places it appeared. Replaced with the canonical docs.aws.amazon.com page (verified 200). 4. The documented CI/CD remedy could not be applied on the CI/CD path: cdk.context.json is gitignored AND regenerated by build.yml, and -c at deploy time cannot change an already-synthesized assembly. build.yml's "Generate CDK context" step now folds in an AGENTCORE_AVAILABILITY_ZONES repo variable, and the guide documents that hook, the two dead ends, and an upgrade note for existing stacks (subnet AZ is create-only). 5. The override was shape-validated only, so ["us-east-1b","us-east-1b"] (single-AZ wearing a two-AZ costume) and ["use1-az2","use1-az4"] (zone IDs, i.e. column 2 of the command the guide prints) both synthesized. Now requires >=2 distinct zone *names*, rejects zone-ID-shaped values with a pointed message, requires the region prefix when the region is concrete, and cross-checks against the supported set whenever the mapping is knowable. Should-fix items: deterministic pin (sorted, not DescribeAZ response order, so a reshuffle cannot replace every subnet); sts:GetCallerIdentity guard so `cdk deploy --profile prod` cannot read the mapping from the default profile's account; makeClient() for solution UA (#319); 5s timeouts + maxAttempts=2 + state=available filter; error *name* not message (authz text echoes caller ARNs into cdk.out, which CI uploads); AgentVpc throws on availabilityZones + maxAzs instead of silently dropping maxAzs; prop renamed to agentCoreAvailabilityZones to stop colliding with Stack.availabilityZones; MIN_AGENTCORE_AZS split from AUTO_PIN_AZ_COUNT with the AgentVpc coupling asserted; removed the untrue AI004 comment (masking scan still clean). Tests: all 5 surviving mutations from the review now fail the suite (verified by re-applying them); agentcore-azs.ts goes from 75% to 100% function coverage via mocked live-lookup tests that also assert the filters, timeouts and UA. Verified end-to-end with the real CLI: env-agnostic synth is clean+warned and unpinned; env-agnostic + override pins subnets to the requested zones; a zone-ID override is rejected; a failed lookup exits 1. --- .github/workflows/build.yml | 15 +- cdk/package.json | 1 + cdk/src/constructs/agent-vpc.ts | 43 +- cdk/src/constructs/agentcore-azs.ts | 495 ++++++++++++++---- cdk/src/main.ts | 93 +++- cdk/src/stacks/agent.ts | 14 +- cdk/test/constructs/agent-vpc.test.ts | 23 +- .../agentcore-azs-live-lookup.test.ts | 121 +++++ cdk/test/constructs/agentcore-azs.test.ts | 405 ++++++++++---- cdk/test/main.test.ts | 155 ++++++ docs/guides/DEPLOYMENT_GUIDE.md | 46 +- docs/guides/QUICK_START.mdx | 5 +- .../docs/getting-started/Deployment-guide.md | 46 +- .../docs/getting-started/Quick-start.mdx | 5 +- yarn.lock | 15 + 15 files changed, 1243 insertions(+), 239 deletions(-) create mode 100644 cdk/test/constructs/agentcore-azs-live-lookup.test.ts create mode 100644 cdk/test/main.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c292e6325..cf7c21922 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -191,10 +191,20 @@ jobs: TAG_EVENT: ${{ github.event_name }} TAG_WORKFLOW: ${{ github.workflow }} TAG_REPOSITORY: ${{ github.repository }} + # AgentCore-supported AZ pin for the deployed stack. The assembly this + # step feeds is synthesized WITHOUT credentials (env-agnostic), so + # auto-pin cannot run and this override is the only way the pipeline + # can keep the Runtime ENIs out of unsupported zones. Set the repo/env + # variable AGENTCORE_AVAILABILITY_ZONES to a JSON array of zone NAMES + # whose zone IDs are AgentCore-supported in the deploy account, e.g. + # ["us-east-1b","us-east-1c"]. Unset => not pinned (synth warns). + # See docs/guides/DEPLOYMENT_GUIDE.md "AgentCore unsupported Availability Zones". + AGENTCORE_AZS: ${{ vars.AGENTCORE_AVAILABILITY_ZONES }} run: | jq -n \ --arg compute_type "$COMPUTE_TYPE" \ --arg stackName "$STACK_NAME" \ + --arg agentcore_azs "$AGENTCORE_AZS" \ --arg sha "$TAG_SHA" \ --arg ref "$TAG_REF" \ --arg ref_type "$TAG_REF_TYPE" \ @@ -223,7 +233,10 @@ jobs: "github:workflow": $workflow, "github:repository": $repository, "github:clean": "true" - }' > cdk/cdk.context.json + } + + (if $agentcore_azs == "" then {} + else {"agentcore:availabilityZones": ($agentcore_azs | fromjson)} end)' \ + > cdk/cdk.context.json cat cdk/cdk.context.json - name: Install mise uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1 diff --git a/cdk/package.json b/cdk/package.json index 172d89639..e901429c0 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -26,6 +26,7 @@ "@aws-sdk/client-lambda-microvms": "^3.1098.0", "@aws-sdk/client-s3": "^3.1078.0", "@aws-sdk/client-secrets-manager": "^3.1078.0", + "@aws-sdk/client-sts": "^3.1078.0", "@aws-sdk/credential-provider-node": "^3.972.61", "@aws-sdk/lib-dynamodb": "^3.1078.0", "@aws-sdk/s3-presigned-post": "^3.1078.0", diff --git a/cdk/src/constructs/agent-vpc.ts b/cdk/src/constructs/agent-vpc.ts index 12d7fc500..91e06d6f2 100644 --- a/cdk/src/constructs/agent-vpc.ts +++ b/cdk/src/constructs/agent-vpc.ts @@ -26,6 +26,17 @@ import { Construct } from 'constructs'; /** HTTPS port — the only egress allowed from the Runtime ENIs. */ const HTTPS_PORT = 443; +/** + * Default zone count. Kept equal to `AUTO_PIN_AZ_COUNT` in + * `constructs/agentcore-azs.ts` so auto-pinning does not change the topology of + * a stack that deploys fine today; the coupling is asserted in + * `test/constructs/agentcore-azs.test.ts`. + */ +const DEFAULT_AGENT_VPC_AZS = 2; + +/** AgentCore high-availability floor: at least two zones. */ +const MIN_AGENT_VPC_AZS = 2; + /** * Properties for the AgentVpc construct. */ @@ -56,16 +67,18 @@ export interface AgentVpcProps { * make a fresh deploy deterministic regardless of the account's * name → zone-ID mapping. The supported zone-ID set differs per region and * can change over time — see the AWS - * {@link https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones Supported Availability Zones} + * {@link https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-supported-azs Supported Availability Zones} * table, and the per-region `AGENTCORE_SUPPORTED_AZ_IDS` map in * `constructs/agentcore-azs.ts`. * * Callers normally don't set this directly: `resolveAgentCoreAzs` (invoked * from `main.ts`) auto-selects supported zone names for the target account, * or honors the validated `agentcore:availabilityZones` context override, and - * passes the result through {@link AgentStackProps.availabilityZones}. + * passes the result through `AgentStackProps.agentCoreAvailabilityZones`. * - * When provided, takes precedence over {@link maxAzs}. + * Mutually exclusive with {@link maxAzs} — supplying both throws, matching + * `ec2.Vpc`'s own contract rather than silently ignoring one of them. + * Must list at least two zones (AgentCore high-availability guidance). * @default - CDK selects the first `maxAzs` zones by name */ readonly availabilityZones?: string[]; @@ -100,7 +113,25 @@ export class AgentVpc extends Construct { constructor(scope: Construct, id: string, props: AgentVpcProps = {}) { super(scope, id); - const maxAzs = props.maxAzs ?? 2; + const pinnedAzs = props.availabilityZones; + + // `ec2.Vpc` rejects `availabilityZones` + `maxAzs` together. Surface that as + // our own error instead of spreading one away silently: a caller who set + // both has a wrong mental model and should hear about it. + if (pinnedAzs?.length && props.maxAzs !== undefined) { + throw new Error( + 'AgentVpc supports availabilityZones or maxAzs, but not both — ' + + 'an explicit zone list already fixes the zone count.', + ); + } + if (pinnedAzs && pinnedAzs.length < MIN_AGENT_VPC_AZS) { + throw new Error( + `AgentVpc requires at least ${MIN_AGENT_VPC_AZS} availability zones for AgentCore high ` + + `availability; got ${JSON.stringify(pinnedAzs)}.`, + ); + } + + const maxAzs = props.maxAzs ?? DEFAULT_AGENT_VPC_AZS; const natGateways = props.natGateways ?? 1; const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; @@ -108,8 +139,8 @@ export class AgentVpc extends Construct { // When explicit AZs are provided (to target AgentCore-supported physical // zones), pass them directly and omit maxAzs — CDK does not allow both. this.vpc = new ec2.Vpc(this, 'Vpc', { - ...(props.availabilityZones - ? { availabilityZones: props.availabilityZones } + ...(pinnedAzs?.length + ? { availabilityZones: pinnedAzs } : { maxAzs }), natGateways, restrictDefaultSecurityGroup: true, diff --git a/cdk/src/constructs/agentcore-azs.ts b/cdk/src/constructs/agentcore-azs.ts index fb7ad06a3..7665136f6 100644 --- a/cdk/src/constructs/agentcore-azs.ts +++ b/cdk/src/constructs/agentcore-azs.ts @@ -19,6 +19,7 @@ import { Annotations, Token } from 'aws-cdk-lib'; import { Construct, Node } from 'constructs'; +import { makeClient } from '../handlers/shared/ua'; /** * AgentCore-supported physical **Availability Zone IDs** per region. @@ -32,35 +33,99 @@ import { Construct, Node } from 'constructs'; * * The constraint is published in terms of **zone IDs** (e.g. `use1-az1`), which * are stable across accounts, NOT zone *names* (e.g. `us-east-1a`) which are - * aliased per-account. Keep this map aligned with the AWS documentation: - * https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones + * aliased per-account. Note `ca-central-1` and `us-east-1` / `ap-northeast-1` + * skip `-az3`: the sets are not uniformly `az1..az3`, so they cannot be derived. * - * A region absent from this map is treated as "no known constraint" — the - * auto-pin logic leaves AZ selection to CDK's default and operators can still - * pin explicitly via the {@link AGENTCORE_AZS_CONTEXT_KEY} context override. + * ## Documented update path (this list rots by design) + * + * When AWS changes a region's supported set, or adds a region: + * + * 1. Re-read the source table (below) and edit the entry here — this is the + * ONLY place the mapping is declared; do not copy it. + * 2. Update {@link AGENTCORE_SUPPORTED_AZ_IDS_SNAPSHOT_DATE}. + * 3. Ship it. A region absent from this map is not pinned (auto-pin emits a + * warning and leaves CDK's default selection), and operators can always + * pin explicitly via the {@link AGENTCORE_AZS_CONTEXT_KEY} override — so a + * stale list is a friction bug, never a hard block. + * + * Source: "Supported Availability Zones" in the Amazon Bedrock AgentCore + * developer guide — + * https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-supported-azs */ export const AGENTCORE_SUPPORTED_AZ_IDS: Readonly> = { + // North America 'us-east-1': ['use1-az1', 'use1-az2', 'use1-az4'], 'us-east-2': ['use2-az1', 'use2-az2', 'use2-az3'], 'us-west-2': ['usw2-az1', 'usw2-az2', 'usw2-az3'], - 'ap-southeast-1': ['apse1-az1', 'apse1-az2', 'apse1-az3'], - 'ap-southeast-2': ['apse2-az1', 'apse2-az2', 'apse2-az3'], - 'ap-south-1': ['aps1-az1', 'aps1-az2', 'aps1-az3'], - 'ap-northeast-1': ['apne1-az1', 'apne1-az2', 'apne1-az4'], + 'ca-central-1': ['cac1-az1', 'cac1-az2', 'cac1-az4'], + // South America + 'sa-east-1': ['sae1-az1', 'sae1-az2', 'sae1-az3'], + // Europe 'eu-west-1': ['euw1-az1', 'euw1-az2', 'euw1-az3'], + 'eu-west-2': ['euw2-az1', 'euw2-az2', 'euw2-az3'], + 'eu-west-3': ['euw3-az1', 'euw3-az2', 'euw3-az3'], 'eu-central-1': ['euc1-az1', 'euc1-az2', 'euc1-az3'], + 'eu-north-1': ['eun1-az1', 'eun1-az2', 'eun1-az3'], + 'eu-south-1': ['eus1-az1', 'eus1-az2', 'eus1-az3'], + 'eu-south-2': ['eus2-az1', 'eus2-az2', 'eus2-az3'], + // Asia Pacific + 'ap-south-1': ['aps1-az1', 'aps1-az2', 'aps1-az3'], + 'ap-northeast-1': ['apne1-az1', 'apne1-az2', 'apne1-az4'], + 'ap-northeast-2': ['apne2-az1', 'apne2-az2', 'apne2-az3'], + 'ap-southeast-1': ['apse1-az1', 'apse1-az2', 'apse1-az3'], + 'ap-southeast-2': ['apse2-az1', 'apse2-az2', 'apse2-az3'], + 'ap-southeast-5': ['apse5-az1', 'apse5-az2', 'apse5-az3'], + 'ap-southeast-7': ['apse7-az1', 'apse7-az2', 'apse7-az3'], + // GovCloud + 'us-gov-west-1': ['usgw1-az1', 'usgw1-az2', 'usgw1-az3'], }; +/** + * Date {@link AGENTCORE_SUPPORTED_AZ_IDS} was last reconciled with the AWS + * source table. Bump it whenever the map is edited. + */ +export const AGENTCORE_SUPPORTED_AZ_IDS_SNAPSHOT_DATE = '2026-08-11'; + /** * CDK context key whose value (a JSON array of AZ **names**, e.g. - * `["us-east-1b", "us-east-1c"]`) overrides the auto-selected zones. Set it in - * `cdk.context.json`, `cdk.json` `context`, or via - * `-c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]'`. + * `["us-east-1b", "us-east-1c"]`) overrides the auto-selected zones. + * + * This is the ONLY mechanism that works for an env-agnostic assembly (the + * CI-built artifact `deploy.yml` ships), because auto-pin needs a bound account + * to resolve the name -> zone-ID mapping. Set it in `cdk/cdk.json` `context`, + * through the pipeline's "Generate CDK context" step, or via + * `-c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]'` at synth time. + * + * Note `cdk/cdk.context.json` is gitignored AND regenerated by `build.yml`, so + * it is not a durable place to set this for pipeline deploys. */ export const AGENTCORE_AZS_CONTEXT_KEY = 'agentcore:availabilityZones'; -/** Minimum AZ count — AgentCore high-availability guidance is >=2 zones. */ -const MIN_AGENTCORE_AZS = 2; +/** + * High-availability floor: AgentCore guidance is "at least two private subnets + * in different Availability Zones". Used to validate an operator override and + * to decide whether auto-pin found enough supported zones to be useful. + */ +export const MIN_AGENTCORE_AZS = 2; + +/** + * How many zones auto-pin selects when more are supported. + * + * Kept equal to `AgentVpc`'s default `maxAzs` so enabling auto-pin does not + * silently widen an account that deploys fine today (3 supported zones would + * otherwise mean 6 subnets instead of 4). The coupling to `AgentVpc` is + * asserted in `test/constructs/agentcore-azs.test.ts`. + */ +export const AUTO_PIN_AZ_COUNT = 2; + +/** Physical zone-ID shape, e.g. `use1-az1`, `apse5-az2`, `usgw1-az3`. */ +const ZONE_ID_SHAPE = /^[a-z]+[0-9]+-az[0-9]+$/; + +/** Timeouts so a blackholed endpoint cannot hang every synth indefinitely. */ +const LOOKUP_TIMEOUT_MS = 5_000; + +/** Total attempts for the AZ lookup (1 retry). */ +const LOOKUP_MAX_ATTEMPTS = 2; /** A single Availability Zone's name and its stable physical zone ID. */ export interface AvailabilityZoneInfo { @@ -73,42 +138,116 @@ export interface AvailabilityZoneInfo { /** Signature for the injectable `DescribeAvailabilityZones` lookup. */ export type DescribeAzsFn = (region: string) => Promise; +/** Signature for the injectable caller-account lookup (`sts:GetCallerIdentity`). */ +export type ResolveCallerAccountFn = (region: string) => Promise; + +/** Severity of an {@link AgentCoreAzDiagnostic}. */ +export type AgentCoreAzDiagnosticLevel = 'error' | 'warning'; + +/** + * A synth-time finding produced while resolving AZs. + * + * Returned to the caller rather than annotated in place: the resolver runs + * before the stack exists, and CDK only collects annotations that hang off a + * **stack**'s construct tree (App-node metadata reaches no stack artifact and + * is silently dropped). {@link applyAgentCoreAzDiagnostics} attaches these once + * the stack is constructed. + */ +export interface AgentCoreAzDiagnostic { + readonly level: AgentCoreAzDiagnosticLevel; + readonly message: string; +} + +/** Result of {@link resolveAgentCoreAzs}. */ +export interface AgentCoreAzResolution { + /** + * AZ names to pass to `ec2.Vpc({ availabilityZones })`, or `undefined` to keep + * CDK's default `maxAzs` selection. + */ + readonly zones?: string[]; + /** Findings to surface on the stack — see {@link applyAgentCoreAzDiagnostics}. */ + readonly diagnostics: readonly AgentCoreAzDiagnostic[]; +} + /** Options for {@link resolveAgentCoreAzs}. */ export interface ResolveAgentCoreAzsOptions { - /** Scope used to read context and surface synth-time warnings (the `App`). */ - readonly scope: Construct; - /** Target account (from `CDK_DEFAULT_ACCOUNT`); undefined/token = env-agnostic. */ + /** Node used to read CDK context (the `App`'s node). */ + readonly node: Node; + /** Target account (from `CDK_DEFAULT_ACCOUNT`); absent = env-agnostic synth. */ readonly account?: string; - /** Target region (from `CDK_DEFAULT_REGION`); undefined/token = env-agnostic. */ + /** Target region (from `CDK_DEFAULT_REGION`); absent = env-agnostic synth. */ readonly region?: string; /** * Availability-zone lookup. Defaults to a live EC2 * `DescribeAvailabilityZones` call; injectable so tests need no AWS access. */ readonly describeAzs?: DescribeAzsFn; + /** + * Caller-account lookup used to confirm the ambient credentials point at the + * account being deployed to. Defaults to `sts:GetCallerIdentity`. + */ + readonly resolveCallerAccount?: ResolveCallerAccountFn; +} + +/** + * Attaches {@link AgentCoreAzResolution.diagnostics} to a construct inside the + * **stack** being synthesized. + * + * `error`-level diagnostics fail synth (fail closed): every one of them means + * "the zones could not be verified", and a clean synth in that state produces a + * template that looks pinned but is not — which is the original bug. + */ +export function applyAgentCoreAzDiagnostics(scope: Construct, resolution: AgentCoreAzResolution): void { + const annotations = Annotations.of(scope); + for (const diagnostic of resolution.diagnostics) { + if (diagnostic.level === 'error') { + annotations.addError(diagnostic.message); + } else { + annotations.addWarning(diagnostic.message); + } + } } +/** Prefix shared by every diagnostic so operators can grep one token. */ +const DIAG = '[AgentCore AZs]'; + +/** Standard tail pointing at the escape hatch. */ +const OVERRIDE_HINT = `Pin zones explicitly via CDK context '${AGENTCORE_AZS_CONTEXT_KEY}' ` + + '(see docs/guides/DEPLOYMENT_GUIDE.md — "AgentCore unsupported Availability Zones").'; + /** * Validates and returns the optional {@link AGENTCORE_AZS_CONTEXT_KEY} override. * * Mirrors the loud-fail contract of `resolveBedrockModelIds` (bedrock-models.ts): * a malformed override fails synth with a clear message naming the key and the - * expected JSON shape, rather than silently pinning nothing. + * expected JSON shape, rather than silently pinning nothing or pinning something + * that only looks right. * + * Shape and content rules, all of which fail synth: + * - must be a JSON array (the `-c key=value` string form is parsed first); + * - entries must be non-empty strings; + * - at least {@link MIN_AGENTCORE_AZS} **distinct** entries (`["a","a"]` is a + * single-AZ deploy wearing a two-AZ costume); + * - entries must be zone *names*, not zone *IDs* — `use1-az2` is the value + * `describe-availability-zones` prints in column 2 and is a common + * transcription slip that CDK would otherwise accept verbatim; + * - when `region` is known, entries must be prefixed by it, which catches + * wrong-region names. + * + * @param node - node to read context from. + * @param region - target region when known, enabling the prefix check. * @returns the validated AZ-name array, or `undefined` when the key is unset. - * @throws if the value is not a JSON array, contains a non-string/empty entry, - * or lists fewer than {@link MIN_AGENTCORE_AZS} zones. */ -export function resolveAgentCoreAzOverride(node: Node): string[] | undefined { +export function resolveAgentCoreAzOverride(node: Node, region?: string): string[] | undefined { const raw = node.tryGetContext(AGENTCORE_AZS_CONTEXT_KEY); if (raw === undefined || raw === null) { return undefined; } - // `cdk.context.json` delivers a real array, but `-c key=value` on the CLI - // (the recovery path this feature exists for) delivers a raw string. Parse the - // string form so both behave identically. A non-JSON string — a true typo — - // is left as-is and fails the Array.isArray check below with the same clear, - // key-named error. + // `cdk.json` / `cdk.context.json` deliver a real array, but `-c key=value` on + // the CLI (the documented mid-rollback recovery path) delivers a raw string. + // Parse the string form so both behave identically. A non-JSON string — a true + // typo — is left as-is and fails the Array.isArray check below with the same + // clear, key-named error. let override: unknown = raw; if (typeof raw === 'string') { try { @@ -130,11 +269,27 @@ export function resolveAgentCoreAzOverride(node: Node): string[] | undefined { + `strings; got ${JSON.stringify(az)}.`, ); } + if (ZONE_ID_SHAPE.test(az)) { + throw new Error( + `Context '${AGENTCORE_AZS_CONTEXT_KEY}' entry ${JSON.stringify(az)} is a zone *ID*, not a ` + + 'zone *name*. AgentCore publishes its constraint as zone IDs, but a VPC is built from ' + + 'zone names, which are aliased per-account. Map the ID to the name for your account: ' + + "aws ec2 describe-availability-zones --region --query 'AvailabilityZones[].[ZoneName,ZoneId]' " + + '--output text — then pass the ZoneName (column 1).', + ); + } + if (region && !az.startsWith(region)) { + throw new Error( + `Context '${AGENTCORE_AZS_CONTEXT_KEY}' entry ${JSON.stringify(az)} is not in the target ` + + `region ${region}; availability-zone names are region-prefixed (e.g. ${region}a).`, + ); + } } - if (override.length < MIN_AGENTCORE_AZS) { + const distinct = new Set(override as string[]); + if (distinct.size < MIN_AGENTCORE_AZS) { throw new Error( - `Context '${AGENTCORE_AZS_CONTEXT_KEY}' must list at least ${MIN_AGENTCORE_AZS} zones for ` - + `AgentCore high availability; got ${JSON.stringify(override)}.`, + `Context '${AGENTCORE_AZS_CONTEXT_KEY}' must list at least ${MIN_AGENTCORE_AZS} distinct zones ` + + `for AgentCore high availability; got ${JSON.stringify(override)}.`, ); } return override as string[]; @@ -144,6 +299,13 @@ export function resolveAgentCoreAzOverride(node: Node): string[] | undefined { * Pure selection: given the account's AZ (name, id) pairs, returns the zone * *names* whose physical zone IDs are AgentCore-supported for `region`. * + * Sorted by name so the pin is deterministic. `DescribeAvailabilityZones` does + * not contract a response order, and unlike CDK's `availability-zones` context + * provider this lookup is not cached into `cdk.context.json` — an unstable order + * would let a later synth pick different zones, and `Subnet.AvailabilityZone` is + * create-only, so that means replacing every subnet (and route tables, NAT/EIP, + * endpoints) on an existing stack. + * * Returns an empty array when the region has no known constraint (absent from * {@link AGENTCORE_SUPPORTED_AZ_IDS}) or none of the account's zones match. */ @@ -153,23 +315,40 @@ export function selectSupportedAzNames(region: string, zones: readonly Availabil return []; } const supportedIds = new Set(supported); - return zones.filter(zone => supportedIds.has(zone.zoneId)).map(zone => zone.zoneName); + return zones + .filter(zone => supportedIds.has(zone.zoneId)) + .map(zone => zone.zoneName) + .sort((a, b) => a.localeCompare(b)); } /** * Live `DescribeAvailabilityZones` lookup (default {@link DescribeAzsFn}). * - * The `@aws-sdk/client-ec2` module is imported dynamically so it is only loaded - * when auto-pin actually runs (concrete env, no override) — not during - * env-agnostic synth or in unit tests, which inject their own lookup. + * `@aws-sdk/client-ec2` is imported dynamically so it is only loaded when + * auto-pin actually runs (concrete env, no override) — not during env-agnostic + * synth or in unit tests, which inject their own lookup. + * + * Note on packaging: this and `@aws-sdk/client-sts` are declared in + * `dependencies`, not `devDependencies`, even though they are only used at synth + * time. `import-x/no-extraneous-dependencies` only permits devDependencies under + * `test/` and `build-tools/`, and this module lives in `src/`; moving them would + * mean widening that allowlist for a file that is genuinely part of the app. */ async function defaultDescribeAzs(region: string): Promise { const { EC2Client, DescribeAvailabilityZonesCommand } = await import('@aws-sdk/client-ec2'); - const client = new EC2Client({ region }); + const client = makeClient(EC2Client, { + region, + maxAttempts: LOOKUP_MAX_ATTEMPTS, + requestHandler: { requestTimeout: LOOKUP_TIMEOUT_MS, connectionTimeout: LOOKUP_TIMEOUT_MS }, + }); const response = await client.send( new DescribeAvailabilityZonesCommand({ - // Standard AZs only — exclude Local Zones / Wavelength / Outposts. - Filters: [{ Name: 'zone-type', Values: ['availability-zone'] }], + Filters: [ + // Standard AZs only — exclude Local Zones / Wavelength / Outposts. + { Name: 'zone-type', Values: ['availability-zone'] }, + // An impaired/unavailable zone must not become a pin target. + { Name: 'state', Values: ['available'] }, + ], }), ); const zones: AvailabilityZoneInfo[] = []; @@ -181,77 +360,213 @@ async function defaultDescribeAzs(region: string): Promise { + const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts'); + const client = makeClient(STSClient, { + region, + maxAttempts: LOOKUP_MAX_ATTEMPTS, + requestHandler: { requestTimeout: LOOKUP_TIMEOUT_MS, connectionTimeout: LOOKUP_TIMEOUT_MS }, + }); + const identity = await client.send(new GetCallerIdentityCommand({})); + return identity.Account; +} + +/** Error identity without the message — authz failures echo caller ARNs. */ +function errorLabel(err: unknown): string { + if (err instanceof Error) { + const code = (err as { name?: string }).name; + return code || 'Error'; + } + return 'unknown error'; +} + /** * Resolves the Availability-Zone *names* the AgentCore VPC should pin to. * * Resolution order: * 1. **Operator override** — a validated {@link AGENTCORE_AZS_CONTEXT_KEY} - * context value always wins (works even in env-agnostic synth, which is how - * the CI-built artifact and production stack pin zones). - * 2. **Auto-pin (default path)** — when synth has a concrete account + region, - * resolve the account's name -> zone-ID mapping, intersect it with - * {@link AGENTCORE_SUPPORTED_AZ_IDS} for the region, and pin the first - * {@link MIN_AGENTCORE_AZS} supported zones (matching AgentVpc's default - * `maxAzs`), so a fresh local `cdk deploy` lands only in supported zones - * without account-specific guesswork or widening the topology. - * 3. **Fallback** — env-agnostic synth (token/undefined account or region), an - * unknown region, a failed lookup, or fewer than {@link MIN_AGENTCORE_AZS} - * supported zones all return `undefined`, leaving CDK's default AZ selection - * in place. Degraded cases (2 & 3 boundary) surface a synth warning rather - * than failing, so unaffected regions and offline synth still deploy. - * - * @returns AZ names to pass to `ec2.Vpc({ availabilityZones })`, or `undefined` - * to keep the default `maxAzs` selection. + * context value always wins, and is the only mechanism available to an + * env-agnostic assembly. When a bound account makes the mapping knowable, + * the override is additionally cross-checked against + * {@link AGENTCORE_SUPPORTED_AZ_IDS} and any mismatch is an error. + * 2. **Auto-pin (default path)** — with a concrete account + region, confirm + * the ambient credentials match that account, resolve its name -> zone-ID + * mapping, intersect with the supported set, and pin + * {@link AUTO_PIN_AZ_COUNT} zones. + * 3. **Unpinned** — env-agnostic synth or an unlisted region yields no pin and + * a warning, leaving CDK's default AZ selection. A *failure* to complete an + * attempted auto-pin yields no pin and an **error**, so it cannot pass + * silently. */ -export async function resolveAgentCoreAzs(options: ResolveAgentCoreAzsOptions): Promise { - const { scope, account, region } = options; +export async function resolveAgentCoreAzs(options: ResolveAgentCoreAzsOptions): Promise { + const { node, account, region } = options; + const diagnostics: AgentCoreAzDiagnostic[] = []; + + // `Token.isUnresolved` is defensive: today's caller passes plain strings from + // the CLI environment, but a future caller passing `stack.account` would hand + // over an unresolved token that must not be treated as a real account id. + const envAgnostic = !account || !region || Token.isUnresolved(account) || Token.isUnresolved(region); + const concreteRegion = envAgnostic ? undefined : region; // 1. Explicit, validated override wins (throws loudly if malformed). - const override = resolveAgentCoreAzOverride(scope.node); + const override = resolveAgentCoreAzOverride(node, concreteRegion); if (override) { - return override; + if (concreteRegion && AGENTCORE_SUPPORTED_AZ_IDS[concreteRegion]) { + diagnostics.push(...await verifyOverride(options, concreteRegion, override)); + } + return { zones: override, diagnostics }; } - // 2. Auto-pin needs a concrete account + region. Env-agnostic synth uses - // token placeholders (the CI-built artifact + production stack) — pin via - // the override there instead. - if (!account || !region || Token.isUnresolved(account) || Token.isUnresolved(region)) { - return undefined; + // 2. Env-agnostic synth cannot resolve the mapping — this is the CI-built + // assembly, and it is exactly where the original failure was reported, so + // say so rather than reporting nothing. + if (envAgnostic) { + diagnostics.push({ + level: 'warning', + message: `${DIAG} Synthesizing without a bound account/region, so AgentCore-supported zones ` + + 'cannot be auto-selected; CDK\'s default AZ selection is used and may land the Runtime ENIs ' + + `in an unsupported zone. ${OVERRIDE_HINT}`, + }); + return { zones: undefined, diagnostics }; } - // 3. No published constraint for this region — don't guess. - if (!AGENTCORE_SUPPORTED_AZ_IDS[region]) { - return undefined; + // 3. No published constraint for this region — don't guess, but don't be silent. + if (!AGENTCORE_SUPPORTED_AZ_IDS[region!]) { + diagnostics.push({ + level: 'warning', + message: `${DIAG} No supported-zone data for region ${region} (map snapshot ` + + `${AGENTCORE_SUPPORTED_AZ_IDS_SNAPSHOT_DATE}); using CDK's default AZ selection. If AgentCore ` + + `now supports ${region}, add it to AGENTCORE_SUPPORTED_AZ_IDS in ` + + `cdk/src/constructs/agentcore-azs.ts. ${OVERRIDE_HINT}`, + }); + return { zones: undefined, diagnostics }; + } + + return autoPin(options, region!, diagnostics); +} + +/** + * Cross-checks an operator override against the account's real mapping. + * Best-effort: an unusable lookup downgrades to a warning, because the override + * is a deliberate escape hatch and must keep working when the lookup cannot. + */ +async function verifyOverride( + options: ResolveAgentCoreAzsOptions, + region: string, + override: readonly string[], +): Promise { + const diagnostics: AgentCoreAzDiagnostic[] = []; + const describeAzs = options.describeAzs ?? defaultDescribeAzs; + let zones: AvailabilityZoneInfo[] | undefined; + try { + zones = await describeAzs(region); + } catch (err) { + diagnostics.push({ + level: 'warning', + message: `${DIAG} Could not verify the '${AGENTCORE_AZS_CONTEXT_KEY}' override against ` + + `${region}'s zone mapping (${errorLabel(err)}); using it as given.`, + }); + } + if (!zones) { + return diagnostics; + } + const idByName = new Map(zones.map(zone => [zone.zoneName, zone.zoneId])); + const supported = new Set(AGENTCORE_SUPPORTED_AZ_IDS[region]); + const unknown = override.filter(name => !idByName.has(name)); + const unsupported = override.filter(name => { + const id = idByName.get(name); + return id !== undefined && !supported.has(id); + }); + if (unknown.length > 0) { + diagnostics.push({ + level: 'error', + message: `${DIAG} '${AGENTCORE_AZS_CONTEXT_KEY}' lists zone(s) that do not exist in ${region}: ` + + `${unknown.join(', ')}.`, + }); } + if (unsupported.length > 0) { + diagnostics.push({ + level: 'error', + message: `${DIAG} '${AGENTCORE_AZS_CONTEXT_KEY}' lists zone(s) whose physical zone IDs are not ` + + `AgentCore-supported in ${region}: ` + + `${unsupported.map(name => `${name} (${idByName.get(name)})`).join(', ')}. Supported IDs: ` + + `${AGENTCORE_SUPPORTED_AZ_IDS[region].join(', ')}.`, + }); + } + return diagnostics; +} +/** + * Auto-pin path: confirm the credential account, resolve the mapping, intersect + * with the supported set. Any failure to *complete* this is an error-level + * diagnostic, never a silent fallback. + */ +async function autoPin( + options: ResolveAgentCoreAzsOptions, + region: string, + diagnostics: AgentCoreAzDiagnostic[], +): Promise { const describeAzs = options.describeAzs ?? defaultDescribeAzs; + const resolveCallerAccount = options.resolveCallerAccount ?? defaultResolveCallerAccount; - // The return sits outside the try/catch so a failed lookup degrades to the - // default AZ selection (surfaced as a warning) instead of masking the error - // with an empty result inside the catch (ts-silent-success-masking / AI004). - let pinnedZones: string[] | undefined; + // Confirm ambient credentials belong to the target account before trusting + // any name -> zone-ID mapping they return. + let callerAccount: string | undefined; try { - const zones = await describeAzs(region); - const names = selectSupportedAzNames(region, zones); - if (names.length >= MIN_AGENTCORE_AZS) { - // Pin exactly MIN_AGENTCORE_AZS zones — the HA floor, matching AgentVpc's - // default `maxAzs` (2). Pinning every supported zone would silently widen - // an account that deploys fine today from 2 AZs to all supported (often 3 - // -> 6 subnets), so cap the selection to keep the topology stable. - pinnedZones = names.slice(0, MIN_AGENTCORE_AZS); - } else { - Annotations.of(scope).addWarning( - `[AgentCore AZs] Found only ${names.length} AgentCore-supported availability zone(s) in ` - + `${region} (need >=${MIN_AGENTCORE_AZS}); using CDK's default AZ selection. Pin zones ` - + `explicitly via context '${AGENTCORE_AZS_CONTEXT_KEY}' if the deploy hits unsupported zones.`, - ); - } + callerAccount = await resolveCallerAccount(region); } catch (err) { - Annotations.of(scope).addWarning( - `[AgentCore AZs] Could not resolve AgentCore-supported availability zones for ${region} ` - + `(${err instanceof Error ? err.message : String(err)}); using CDK's default AZ selection. ` - + `Pin zones explicitly via context '${AGENTCORE_AZS_CONTEXT_KEY}' if the deploy hits unsupported zones.`, - ); + diagnostics.push({ + level: 'error', + message: `${DIAG} Could not confirm which account the synth credentials belong to ` + + `(${errorLabel(err)}), so AgentCore-supported zones cannot be verified for ${region}. ` + + `${OVERRIDE_HINT}`, + }); + return { zones: undefined, diagnostics }; + } + if (callerAccount && callerAccount !== options.account) { + diagnostics.push({ + level: 'error', + message: `${DIAG} Synth credentials resolve to account ${callerAccount} but the stack targets ` + + `${options.account}. Availability-zone names are aliased per-account, so the zone mapping ` + + 'from the wrong account would pin physically unsupported zones. Export the matching profile ' + + `(e.g. AWS_PROFILE) so the app process and the CDK CLI agree. ${OVERRIDE_HINT}`, + }); + return { zones: undefined, diagnostics }; + } + + let zones: AvailabilityZoneInfo[]; + try { + zones = await describeAzs(region); + } catch (err) { + diagnostics.push({ + level: 'error', + message: `${DIAG} Could not resolve AgentCore-supported availability zones for ${region} ` + + `(${errorLabel(err)}). Grant ec2:DescribeAvailabilityZones to the synth credentials, or ` + + `${OVERRIDE_HINT}`, + }); + return { zones: undefined, diagnostics }; + } + + const names = selectSupportedAzNames(region, zones); + if (names.length < MIN_AGENTCORE_AZS) { + diagnostics.push({ + level: 'error', + message: `${DIAG} Found only ${names.length} AgentCore-supported availability zone(s) in ` + + `${region} for this account (need >=${MIN_AGENTCORE_AZS}). Supported zone IDs: ` + + `${AGENTCORE_SUPPORTED_AZ_IDS[region].join(', ')}. ${OVERRIDE_HINT}`, + }); + return { zones: undefined, diagnostics }; } - return pinnedZones; + return { zones: names.slice(0, AUTO_PIN_AZ_COUNT), diagnostics }; } diff --git a/cdk/src/main.ts b/cdk/src/main.ts index ca1ded893..ac442c8dd 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -17,9 +17,14 @@ * SOFTWARE. */ -import { App, AspectPriority, Aspects, Tags } from 'aws-cdk-lib'; +import { App, AppProps, AspectPriority, Aspects, Tags } from 'aws-cdk-lib'; import { AwsSolutionsChecks } from 'cdk-nag'; -import { resolveAgentCoreAzs } from './constructs/agentcore-azs'; +import { + applyAgentCoreAzDiagnostics, + DescribeAzsFn, + ResolveCallerAccountFn, + resolveAgentCoreAzs, +} from './constructs/agentcore-azs'; import { buildAppId, SolutionUaAspect } from './constructs/solution-ua-aspect'; import { AgentStack } from './stacks/agent'; @@ -29,37 +34,69 @@ const devEnv = { region: process.env.CDK_DEFAULT_REGION, }; +/** Test seams for {@link buildApp} — all default to production behavior. */ +export interface BuildAppOptions { + /** Target account. @default process.env.CDK_DEFAULT_ACCOUNT */ + readonly account?: string; + /** Target region. @default process.env.CDK_DEFAULT_REGION */ + readonly region?: string; + /** Extra `App` props (e.g. `context`) for tests. */ + readonly appProps?: AppProps; + /** Injectable AZ lookup so tests need no AWS access. */ + readonly describeAzs?: DescribeAzsFn; + /** Injectable caller-account lookup so tests need no AWS access. */ + readonly resolveCallerAccount?: ResolveCallerAccountFn; +} + /** - * Synthesizes the app. Async because AgentCore-supported availability zones are - * resolved from the account's zone mapping at synth time (a live - * `DescribeAvailabilityZones` call) when a concrete account/region is bound. - * Env-agnostic synth and the validated context override never touch AWS. + * Builds the fully wired `App` **without synthesizing**. + * + * Exported so tests can drive the real production wiring — the AZ resolution, + * the diagnostics attachment, and the prop threading into `AgentStack` — rather + * than a re-implementation of it in the test file. + * + * Async because AgentCore-supported availability zones are resolved from the + * account's zone mapping at synth time (live `DescribeAvailabilityZones` + + * `sts:GetCallerIdentity`) when a concrete account/region is bound. Env-agnostic + * synth and the validated context override never touch AWS. */ -async function main(): Promise { - const app = new App(); +export async function buildApp(options: BuildAppOptions = {}): Promise { + const app = new App(options.appProps); Aspects.of(app).add(new AwsSolutionsChecks()); const stackName = app.node.tryGetContext('stackName') ?? 'backgroundagent-dev'; + const env = { + account: options.account ?? devEnv.account, + region: options.region ?? devEnv.region, + }; + // Auto-pin the VPC to AgentCore-supported AZs (or honor the validated - // `agentcore:availabilityZones` override). Undefined => CDK default selection. - const availabilityZones = await resolveAgentCoreAzs({ - scope: app, - account: devEnv.account, - region: devEnv.region, + // `agentcore:availabilityZones` override). `zones` undefined => CDK default + // selection; `diagnostics` are attached to the stack below, because CDK only + // collects annotations that hang off a stack's tree — App-node metadata would + // be silently dropped, which is how a failed lookup used to pass unnoticed. + const azResolution = await resolveAgentCoreAzs({ + node: app.node, + account: env.account, + region: env.region, + describeAzs: options.describeAzs, + resolveCallerAccount: options.resolveCallerAccount, }); const stack = new AgentStack( app, stackName, { - env: devEnv, - availabilityZones, + env, + agentCoreAvailabilityZones: azResolution.zones, description: 'ABCA Development Stack (uksb-wt64nei4u6)', }, ); + applyAgentCoreAzDiagnostics(stack, azResolution); + // Outbound SDK solution attribution (#319): set AWS_SDK_UA_APP_ID on every // Lambda so the SDK emits `app/uksb-wt64nei4u6#{stackName}` natively. One // Aspect covers current and future functions structurally. Override via @@ -114,13 +151,23 @@ async function main(): Promise { Tags.of(stack).add(`github:${key}`, value || 'none', { excludeResourceTypes }); } - app.synth(); + return app; +} + +/** Builds and synthesizes — the CDK app entrypoint (`cdk.json` `app`). */ +export async function main(): Promise { + (await buildApp()).synth(); } -// Surface any synth-time failure (e.g. a malformed `agentcore:availabilityZones` -// override) as a non-zero exit. `void` satisfies no-floating-promises; throwing -// from the handler triggers an unhandled rejection so the CDK CLI fails loudly. -void main().catch((err: unknown) => { - process.exitCode = 1; - throw err; -}); +// Only auto-run when executed as the app entrypoint, so importing this module +// from a test drives `buildApp` without triggering a real synth. +/* istanbul ignore next -- entrypoint guard: not reachable under jest import */ +if (require.main === module) { + // Surface any synth-time failure (e.g. a malformed `agentcore:availabilityZones` + // override) as a non-zero exit. `void` satisfies no-floating-promises; throwing + // from the handler triggers an unhandled rejection so the CDK CLI fails loudly. + void main().catch((err: unknown) => { + process.exitCode = 1; + throw err; + }); +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 9e4896fc4..9ecce06c4 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -94,10 +94,14 @@ export interface AgentStackProps extends StackProps { * Resolved in `main.ts` via `resolveAgentCoreAzs` — the validated * `agentcore:availabilityZones` context override, else auto-selected from the * account's supported zones when synth has a concrete account/region. Leave - * `undefined` (env-agnostic synth, unknown region) to keep CDK's default + * `undefined` (env-agnostic synth, unlisted region) to keep CDK's default * `maxAzs` selection. + * + * Deliberately NOT named `availabilityZones`: `Stack` already exposes an + * `availabilityZones` getter returning the *unpinned* set, and two different + * values under one name in one class is a trap for `Stack.of(x)` callers. */ - readonly availabilityZones?: string[]; + readonly agentCoreAvailabilityZones?: string[]; } export class AgentStack extends Stack { @@ -345,13 +349,15 @@ export class AgentStack extends Stack { // AgentCore only supports a subset of physical availability zones per // region, and AZ *names* are aliased per-account, so the default maxAzs // selection can land the Runtime ENIs in an unsupported zone and fail the - // deploy. `props.availabilityZones` carries the AZ names resolved in + // deploy. `props.agentCoreAvailabilityZones` carries the AZ names resolved in // main.ts (`resolveAgentCoreAzs`): the validated `agentcore:availabilityZones` // override, else auto-selected from the account's AgentCore-supported zones // when synth has a concrete account/region. Left undefined otherwise, so the // construct keeps CDK's default AZ selection. See constructs/agentcore-azs.ts. const agentVpc = new AgentVpc(this, 'AgentVpc', { - ...(props.availabilityZones ? { availabilityZones: props.availabilityZones } : {}), + ...(props.agentCoreAvailabilityZones?.length + ? { availabilityZones: props.agentCoreAvailabilityZones } + : {}), }); // DNS Firewall — domain-level egress filtering (observation mode for initial deployment) diff --git a/cdk/test/constructs/agent-vpc.test.ts b/cdk/test/constructs/agent-vpc.test.ts index 1c206662d..6b287bf9a 100644 --- a/cdk/test/constructs/agent-vpc.test.ts +++ b/cdk/test/constructs/agent-vpc.test.ts @@ -150,14 +150,13 @@ describe('AgentVpc with custom props', () => { template.resourceCountIs('AWS::EC2::NatGateway', 2); }); - test('accepts explicit availabilityZones and ignores maxAzs', () => { + test('accepts explicit availabilityZones', () => { const app = new App(); const stack = new Stack(app, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' }, }); new AgentVpc(stack, 'AgentVpc', { availabilityZones: ['us-east-1b', 'us-east-1c'], - maxAzs: 3, // should be ignored when availabilityZones is provided }); const template = Template.fromStack(stack); @@ -170,6 +169,26 @@ describe('AgentVpc with custom props', () => { template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1c' }); }); + test('throws when both availabilityZones and maxAzs are supplied', () => { + // ec2.Vpc rejects the combination; surface it rather than silently dropping + // maxAzs, which hid a wrong mental model at the call site. + const stack = new Stack(new App(), 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + expect(() => new AgentVpc(stack, 'AgentVpc', { + availabilityZones: ['us-east-1b', 'us-east-1c'], + maxAzs: 3, + })).toThrow(/availabilityZones or maxAzs, but not both/); + }); + + test('throws when fewer than two availabilityZones are supplied', () => { + const stack = new Stack(new App(), 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + expect(() => new AgentVpc(stack, 'AgentVpc', { availabilityZones: ['us-east-1b'] })) + .toThrow(/at least 2 availability zones/); + }); + test('env-agnostic synth falls back to Fn::GetAZs (no pinning, no crash)', () => { const app = new App(); // No env → account/region are tokens. The production AgentStack synthesizes diff --git a/cdk/test/constructs/agentcore-azs-live-lookup.test.ts b/cdk/test/constructs/agentcore-azs-live-lookup.test.ts new file mode 100644 index 000000000..434d25046 --- /dev/null +++ b/cdk/test/constructs/agentcore-azs-live-lookup.test.ts @@ -0,0 +1,121 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Covers the **default** (live) lookups in `agentcore-azs.ts`, which every other + * test replaces by injection. The SDK modules are mocked, so nothing here talks + * to AWS; what is asserted is the request shape those lookups depend on — + * `state=available` filtering, bounded timeouts/attempts, and the solution + * User-Agent required by AGENTS.md (#319). + */ + +const ec2Send = jest.fn(); +const stsSend = jest.fn(); +const ec2Ctor = jest.fn(); +const stsCtor = jest.fn(); + +jest.mock('@aws-sdk/client-ec2', () => ({ + EC2Client: jest.fn().mockImplementation((cfg: Record) => { + ec2Ctor(cfg); + return { send: ec2Send }; + }), + DescribeAvailabilityZonesCommand: jest.fn().mockImplementation((input: unknown) => ({ input })), +})); + +jest.mock('@aws-sdk/client-sts', () => ({ + STSClient: jest.fn().mockImplementation((cfg: Record) => { + stsCtor(cfg); + return { send: stsSend }; + }), + GetCallerIdentityCommand: jest.fn().mockImplementation((input: unknown) => ({ input, isGetCallerIdentity: true })), +})); + +import { App, Stack } from 'aws-cdk-lib'; +import { resolveAgentCoreAzs } from '../../src/constructs/agentcore-azs'; + +const ACCOUNT = '123456789012'; + +function node() { + return new Stack(new App(), 'LiveLookupStack').node; +} + +describe('default live lookups', () => { + beforeEach(() => { + jest.clearAllMocks(); + stsSend.mockResolvedValue({ Account: ACCOUNT }); + ec2Send.mockResolvedValue({ + AvailabilityZones: [ + { ZoneName: 'us-east-1a', ZoneId: 'use1-az2' }, + { ZoneName: 'us-east-1b', ZoneId: 'use1-az4' }, + { ZoneName: 'us-east-1c', ZoneId: 'use1-az6' }, + // Malformed entries must be skipped, not crash the resolver. + { ZoneName: undefined, ZoneId: 'use1-az1' }, + { ZoneName: 'us-east-1z', ZoneId: undefined }, + ], + }); + }); + + it('auto-pins through the real EC2 + STS code paths', async () => { + const result = await resolveAgentCoreAzs({ node: node(), account: ACCOUNT, region: 'us-east-1' }); + expect(result.zones).toEqual(['us-east-1a', 'us-east-1b']); + expect(result.diagnostics).toEqual([]); + expect(stsSend).toHaveBeenCalledTimes(1); + expect(ec2Send).toHaveBeenCalledTimes(1); + }); + + it('requests only available standard zones', async () => { + await resolveAgentCoreAzs({ node: node(), account: ACCOUNT, region: 'us-east-1' }); + const command = ec2Send.mock.calls[0][0] as { input: { Filters: { Name: string; Values: string[] }[] } }; + expect(command.input.Filters).toEqual( + expect.arrayContaining([ + { Name: 'zone-type', Values: ['availability-zone'] }, + { Name: 'state', Values: ['available'] }, + ]), + ); + }); + + it('bounds both clients with timeouts, retries, region and solution UA', async () => { + await resolveAgentCoreAzs({ node: node(), account: ACCOUNT, region: 'us-east-1' }); + for (const ctor of [ec2Ctor, stsCtor]) { + const cfg = ctor.mock.calls[0][0] as Record; + expect(cfg.region).toBe('us-east-1'); + expect(cfg.maxAttempts).toBe(2); + expect(cfg.requestHandler).toEqual({ requestTimeout: 5000, connectionTimeout: 5000 }); + // makeClient() attribution — a naked client would drop the md/ segment. + expect(cfg.customUserAgent).toEqual([['md/uksb-wt64nei4u6', expect.any(String)]]); + } + }); + + it('errors (fail closed) when the live AZ lookup rejects', async () => { + ec2Send.mockRejectedValue(Object.assign(new Error('nope'), { name: 'UnauthorizedOperation' })); + const result = await resolveAgentCoreAzs({ node: node(), account: ACCOUNT, region: 'us-east-1' }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('UnauthorizedOperation'); + }); + + it('errors (fail closed) when the live account lookup rejects', async () => { + stsSend.mockRejectedValue(Object.assign(new Error('nope'), { name: 'ExpiredTokenException' })); + const result = await resolveAgentCoreAzs({ node: node(), account: ACCOUNT, region: 'us-east-1' }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('ExpiredTokenException'); + expect(ec2Send).not.toHaveBeenCalled(); + }); +}); diff --git a/cdk/test/constructs/agentcore-azs.test.ts b/cdk/test/constructs/agentcore-azs.test.ts index 1bc6a7006..782d34e2c 100644 --- a/cdk/test/constructs/agentcore-azs.test.ts +++ b/cdk/test/constructs/agentcore-azs.test.ts @@ -18,27 +18,62 @@ */ import { App, Stack } from 'aws-cdk-lib'; -import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { Template } from 'aws-cdk-lib/assertions'; +import { AgentVpc } from '../../src/constructs/agent-vpc'; import { AGENTCORE_AZS_CONTEXT_KEY, AGENTCORE_SUPPORTED_AZ_IDS, + AGENTCORE_SUPPORTED_AZ_IDS_SNAPSHOT_DATE, + AUTO_PIN_AZ_COUNT, + AgentCoreAzResolution, AvailabilityZoneInfo, + DescribeAzsFn, + MIN_AGENTCORE_AZS, + ResolveCallerAccountFn, + applyAgentCoreAzDiagnostics, resolveAgentCoreAzOverride, resolveAgentCoreAzs, selectSupportedAzNames, } from '../../src/constructs/agentcore-azs'; +const ACCOUNT = '123456789012'; + function nodeWithContext(context?: Record) { const app = new App({ context }); return new Stack(app, 'TestStack').node; } -function scopeWithContext(context?: Record): Stack { - const app = new App({ context }); - return new Stack(app, 'TestStack'); -} +/** + * Independent transcription of the AWS source table, kept separate from the + * production constant on purpose: asserting the map against itself (its own key + * list, its own shape) cannot detect drift or a mis-typed zone ID. + * + * Source: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-supported-azs + */ +const EXPECTED_SUPPORTED_AZ_IDS: Record = { + 'us-east-1': ['use1-az1', 'use1-az2', 'use1-az4'], + 'us-east-2': ['use2-az1', 'use2-az2', 'use2-az3'], + 'us-west-2': ['usw2-az1', 'usw2-az2', 'usw2-az3'], + 'ca-central-1': ['cac1-az1', 'cac1-az2', 'cac1-az4'], + 'sa-east-1': ['sae1-az1', 'sae1-az2', 'sae1-az3'], + 'eu-west-1': ['euw1-az1', 'euw1-az2', 'euw1-az3'], + 'eu-west-2': ['euw2-az1', 'euw2-az2', 'euw2-az3'], + 'eu-west-3': ['euw3-az1', 'euw3-az2', 'euw3-az3'], + 'eu-central-1': ['euc1-az1', 'euc1-az2', 'euc1-az3'], + 'eu-north-1': ['eun1-az1', 'eun1-az2', 'eun1-az3'], + 'eu-south-1': ['eus1-az1', 'eus1-az2', 'eus1-az3'], + 'eu-south-2': ['eus2-az1', 'eus2-az2', 'eus2-az3'], + 'ap-south-1': ['aps1-az1', 'aps1-az2', 'aps1-az3'], + 'ap-northeast-1': ['apne1-az1', 'apne1-az2', 'apne1-az4'], + 'ap-northeast-2': ['apne2-az1', 'apne2-az2', 'apne2-az3'], + 'ap-southeast-1': ['apse1-az1', 'apse1-az2', 'apse1-az3'], + 'ap-southeast-2': ['apse2-az1', 'apse2-az2', 'apse2-az3'], + 'ap-southeast-5': ['apse5-az1', 'apse5-az2', 'apse5-az3'], + 'ap-southeast-7': ['apse7-az1', 'apse7-az2', 'apse7-az3'], + 'us-gov-west-1': ['usgw1-az1', 'usgw1-az2', 'usgw1-az3'], +}; -// Realistic us-east-1 mapping: supported IDs are use1-az1/az2/az4. +/** Realistic us-east-1 mapping; supported IDs are use1-az1/az2/az4. */ const US_EAST_1_ZONES: AvailabilityZoneInfo[] = [ { zoneName: 'us-east-1a', zoneId: 'use1-az2' }, // supported { zoneName: 'us-east-1b', zoneId: 'use1-az4' }, // supported @@ -48,28 +83,76 @@ const US_EAST_1_ZONES: AvailabilityZoneInfo[] = [ { zoneName: 'us-east-1f', zoneId: 'use1-az5' }, // unsupported ]; +const okAccount: ResolveCallerAccountFn = async () => ACCOUNT; +const okZones: DescribeAzsFn = async () => US_EAST_1_ZONES; + +/** Resolve with concrete env and injected lookups (no AWS access). */ +function resolveConcrete(overrides: { + context?: Record; + region?: string; + account?: string; + describeAzs?: DescribeAzsFn; + resolveCallerAccount?: ResolveCallerAccountFn; +} = {}): Promise { + return resolveAgentCoreAzs({ + node: nodeWithContext(overrides.context), + account: overrides.account ?? ACCOUNT, + region: overrides.region ?? 'us-east-1', + describeAzs: overrides.describeAzs ?? okZones, + resolveCallerAccount: overrides.resolveCallerAccount ?? okAccount, + }); +} + describe('AGENTCORE_SUPPORTED_AZ_IDS', () => { - it('covers the documented AgentCore VPC regions', () => { + it('matches the AWS source table exactly (drift detector)', () => { + // Deep-equal against an independently transcribed literal: catches a wrong + // zone ID ('us-east-1': ['usw2-az1']) and a dropped/added region, neither of + // which a self-referential assertion can see. + expect(AGENTCORE_SUPPORTED_AZ_IDS).toEqual(EXPECTED_SUPPORTED_AZ_IDS); + }); + + it('covers the regions AWS publishes, including the ones that skip -az3', () => { + // ca-central-1, us-east-1 and ap-northeast-1 use az4 rather than az3, so the + // sets cannot be derived as az1..az3. + expect(AGENTCORE_SUPPORTED_AZ_IDS['ca-central-1']).toContain('cac1-az4'); + expect(AGENTCORE_SUPPORTED_AZ_IDS['ca-central-1']).not.toContain('cac1-az3'); + expect(AGENTCORE_SUPPORTED_AZ_IDS['us-east-1']).toContain('use1-az4'); + expect(AGENTCORE_SUPPORTED_AZ_IDS['ap-northeast-1']).toContain('apne1-az4'); + // Regions the earlier revision of this map omitted entirely. for (const region of [ - 'us-east-1', 'us-east-2', 'us-west-2', - 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ap-northeast-1', - 'eu-west-1', 'eu-central-1', + 'ap-northeast-2', 'ap-southeast-5', 'ap-southeast-7', 'ca-central-1', + 'eu-west-2', 'eu-west-3', 'eu-north-1', 'eu-south-1', 'eu-south-2', 'sa-east-1', ]) { expect(AGENTCORE_SUPPORTED_AZ_IDS[region]).toBeDefined(); } }); - it('lists at least two unique physical zone IDs per region', () => { + it('lists at least the HA floor of distinct zone IDs per region', () => { for (const [region, ids] of Object.entries(AGENTCORE_SUPPORTED_AZ_IDS)) { - expect(ids.length).toBeGreaterThanOrEqual(2); + expect(ids.length).toBeGreaterThanOrEqual(MIN_AGENTCORE_AZS); expect(new Set(ids).size).toBe(ids.length); for (const id of ids) { - // Physical zone-ID shape, e.g. use1-az1 (NOT a zone name like us-east-1a). expect(id).toMatch(/^[a-z]+[0-9]+-az[0-9]+$/); } - expect(region).toMatch(/^[a-z]{2}-[a-z]+-\d$/); + expect(region).toMatch(/^[a-z]{2}(-[a-z]+)+-\d$/); } }); + + it('carries a snapshot date so staleness is visible', () => { + expect(AGENTCORE_SUPPORTED_AZ_IDS_SNAPSHOT_DATE).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe('AUTO_PIN_AZ_COUNT', () => { + it('equals the AgentVpc default zone count (topology stays unchanged)', () => { + // The coupling the constants' comments claim, asserted rather than trusted: + // a default AgentVpc must produce exactly AUTO_PIN_AZ_COUNT zones, i.e. + // AUTO_PIN_AZ_COUNT * 2 subnets (one public + one private per zone). + const stack = new Stack(new App(), 'DefaultVpcStack'); + new AgentVpc(stack, 'AgentVpc'); + Template.fromStack(stack).resourceCountIs('AWS::EC2::Subnet', AUTO_PIN_AZ_COUNT * 2); + expect(AUTO_PIN_AZ_COUNT).toBeGreaterThanOrEqual(MIN_AGENTCORE_AZS); + }); }); describe('resolveAgentCoreAzOverride', () => { @@ -79,137 +162,269 @@ describe('resolveAgentCoreAzOverride', () => { it('returns the validated array when provided', () => { const override = ['us-east-1b', 'us-east-1c']; - expect( - resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: override })), - ).toEqual(override); - }); - - it('throws on a bare (non-JSON) string override (typo guard)', () => { - expect(() => - resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' })), - ).toThrow(/must be a JSON array/); + expect(resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: override }))) + .toEqual(override); }); it('parses a JSON-string array (the `-c key=value` CLI form)', () => { - // CDK delivers `-c agentcore:availabilityZones=[...]` as a raw string, not a - // parsed array — the documented mid-rollback recovery path must still work. expect( resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: '["us-east-1b","us-east-1c"]' })), ).toEqual(['us-east-1b', 'us-east-1c']); }); + it('throws on a bare (non-JSON) string override', () => { + expect(() => resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' }))) + .toThrow(/must be a JSON array of availability-zone names/); + }); + it('throws on a JSON string that does not parse to an array', () => { - expect(() => - resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: '"us-east-1b"' })), - ).toThrow(/must be a JSON array/); + expect(() => resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: '"us-east-1b"' }))) + .toThrow(/must be a JSON array of availability-zone names/); }); it('throws on a non-string / empty entry', () => { + expect(() => resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', ''] }))) + .toThrow(/entries must be non-empty availability-zone-name strings/); + }); + + it('throws when fewer than two DISTINCT zones are listed', () => { + // Duplicates previously satisfied the HA check: 4 subnets, 1 real zone. expect(() => - resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', ''] })), - ).toThrow(/non-empty availability-zone-name/); + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1b'] })), + ).toThrow(/at least 2 distinct zones/); + expect(() => resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b'] }))) + .toThrow(/at least 2 distinct zones/); }); - it('throws when fewer than two zones are listed (HA guidance)', () => { + it('rejects zone IDs with a message pointing at the name/ID mix-up', () => { + // `describe-availability-zones` prints ZoneName then ZoneId; copying column 2 + // used to synthesize as AvailabilityZone: "use1-az2". expect(() => - resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b'] })), - ).toThrow(/at least 2 zones/); + resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['use1-az2', 'use1-az4'] })), + ).toThrow(/is a zone \*ID\*, not a zone \*name\*/); + }); + + it('rejects names outside the target region when the region is known', () => { + expect(() => + resolveAgentCoreAzOverride( + nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-west-2a', 'us-west-2b'] }), + 'us-east-1', + ), + ).toThrow(/is not in the target region us-east-1/); + }); + + it('skips the region check when the region is unknown (env-agnostic synth)', () => { + expect(resolveAgentCoreAzOverride(nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-west-2a', 'us-west-2b'] }))) + .toEqual(['us-west-2a', 'us-west-2b']); }); }); describe('selectSupportedAzNames', () => { - it('returns the zone names whose IDs are AgentCore-supported', () => { - expect(selectSupportedAzNames('us-east-1', US_EAST_1_ZONES)).toEqual([ - 'us-east-1a', - 'us-east-1b', - 'us-east-1d', - ]); + it('returns supported zone names in deterministic sorted order', () => { + // Deliberately unsorted input: DescribeAvailabilityZones contracts no order, + // and an unstable pin would replace every subnet on a later synth. + const shuffled = [...US_EAST_1_ZONES].reverse(); + expect(selectSupportedAzNames('us-east-1', shuffled)).toEqual(['us-east-1a', 'us-east-1b', 'us-east-1d']); + expect(selectSupportedAzNames('us-east-1', US_EAST_1_ZONES)) + .toEqual(selectSupportedAzNames('us-east-1', shuffled)); }); it('returns an empty array for a region with no known constraint', () => { - expect(selectSupportedAzNames('eu-north-1', US_EAST_1_ZONES)).toEqual([]); + // A region genuinely absent from the map — NOT eu-north-1, which is supported. + expect(selectSupportedAzNames('me-central-1', US_EAST_1_ZONES)).toEqual([]); }); it('returns an empty array when no account zone matches the supported set', () => { - const zones: AvailabilityZoneInfo[] = [{ zoneName: 'us-east-1c', zoneId: 'use1-az6' }]; - expect(selectSupportedAzNames('us-east-1', zones)).toEqual([]); + expect(selectSupportedAzNames('us-east-1', [{ zoneName: 'us-east-1c', zoneId: 'use1-az6' }])).toEqual([]); + }); +}); + +describe('applyAgentCoreAzDiagnostics', () => { + it('maps error diagnostics to addError and warnings to addWarning', () => { + const stack = new Stack(new App(), 'DiagStack'); + applyAgentCoreAzDiagnostics(stack, { + zones: undefined, + diagnostics: [ + { level: 'error', message: 'boom-error' }, + { level: 'warning', message: 'boom-warning' }, + ], + }); + const errors = stack.node.metadata.filter(m => m.type === 'aws:cdk:error').map(m => m.data); + const warnings = stack.node.metadata.filter(m => m.type === 'aws:cdk:warning').map(m => m.data); + expect(errors).toEqual(['boom-error']); + expect(warnings).toEqual(['boom-warning']); }); }); describe('resolveAgentCoreAzs', () => { - it('returns the validated override without calling the AZ lookup', async () => { - const describeAzs = jest.fn, [string]>(); + it('auto-pins exactly AUTO_PIN_AZ_COUNT supported zones, sorted', async () => { + const describeAzs = jest.fn(okZones); + const result = await resolveConcrete({ describeAzs }); + expect(result.zones).toEqual(['us-east-1a', 'us-east-1b']); + expect(result.zones).toHaveLength(AUTO_PIN_AZ_COUNT); + expect(result.diagnostics).toEqual([]); + expect(describeAzs).toHaveBeenCalledWith('us-east-1'); + }); + + it('returns the override without any AWS lookup', async () => { + const describeAzs = jest.fn(okZones); + const resolveCallerAccount = jest.fn(okAccount); const result = await resolveAgentCoreAzs({ - scope: scopeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1c'] }), - account: '123456789012', - region: 'us-east-1', + node: nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1c'] }), describeAzs, + resolveCallerAccount, }); - expect(result).toEqual(['us-east-1b', 'us-east-1c']); + expect(result.zones).toEqual(['us-east-1b', 'us-east-1c']); + expect(result.diagnostics).toEqual([]); expect(describeAzs).not.toHaveBeenCalled(); + expect(resolveCallerAccount).not.toHaveBeenCalled(); + }); + + it('honors the override BEFORE the env-agnostic guard (the CI/CD path)', async () => { + // Reordering these two checks would silently disable the only pinning + // mechanism the pipeline artifact has. + const result = await resolveAgentCoreAzs({ + node: nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1c'] }), + account: undefined, + region: undefined, + }); + expect(result.zones).toEqual(['us-east-1b', 'us-east-1c']); + expect(result.diagnostics).toEqual([]); }); it('rethrows a malformed override (fails synth loudly)', async () => { - await expect( - resolveAgentCoreAzs({ - scope: scopeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' }), - account: '123456789012', - region: 'us-east-1', - }), - ).rejects.toThrow(/must be a JSON array/); - }); - - it('falls back (undefined) for env-agnostic synth without touching AWS', async () => { - const describeAzs = jest.fn, [string]>(); - expect(await resolveAgentCoreAzs({ scope: scopeWithContext(), account: undefined, region: undefined, describeAzs })) - .toBeUndefined(); - expect(await resolveAgentCoreAzs({ scope: scopeWithContext(), account: '123456789012', region: undefined, describeAzs })) - .toBeUndefined(); - expect(describeAzs).not.toHaveBeenCalled(); + await expect(resolveConcrete({ context: { [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' } })) + .rejects.toThrow(/must be a JSON array of availability-zone names/); }); - it('falls back (undefined) for a region with no known constraint', async () => { - const describeAzs = jest.fn, [string]>(); - const result = await resolveAgentCoreAzs({ - scope: scopeWithContext(), - account: '123456789012', - region: 'eu-north-1', - describeAzs, + it('errors when the override names zones whose IDs are unsupported', async () => { + const result = await resolveConcrete({ + context: { [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1c', 'us-east-1e'] }, + }); + expect(result.zones).toEqual(['us-east-1c', 'us-east-1e']); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('not AgentCore-supported in us-east-1'); + expect(result.diagnostics[0].message).toContain('us-east-1c (use1-az6)'); + }); + + it('errors when the override names zones that do not exist', async () => { + const result = await resolveConcrete({ + context: { [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1a', 'us-east-1z'] }, + }); + expect(result.diagnostics.some(d => d.level === 'error' && d.message.includes('do not exist in us-east-1'))) + .toBe(true); + }); + + it('warns (not errors) when the override cannot be verified', async () => { + const result = await resolveConcrete({ + context: { [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1a', 'us-east-1b'] }, + describeAzs: async () => { + throw new Error('AccessDenied'); + }, }); - expect(result).toBeUndefined(); + // The escape hatch must keep working when the lookup cannot. + expect(result.zones).toEqual(['us-east-1a', 'us-east-1b']); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('warning'); + expect(result.diagnostics[0].message).toContain('Could not verify'); + }); + + it('warns and does not pin for env-agnostic synth, without touching AWS', async () => { + const describeAzs = jest.fn(okZones); + const resolveCallerAccount = jest.fn(okAccount); + for (const env of [{}, { account: ACCOUNT }, { region: 'us-east-1' }]) { + const result = await resolveAgentCoreAzs({ + node: nodeWithContext(), ...env, describeAzs, resolveCallerAccount, + }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('warning'); + expect(result.diagnostics[0].message).toContain('without a bound account/region'); + } expect(describeAzs).not.toHaveBeenCalled(); + expect(resolveCallerAccount).not.toHaveBeenCalled(); }); - it('auto-pins the first two supported zone names (capped) for a concrete env', async () => { - // US_EAST_1_ZONES has three supported zones (az2/az4/az1 -> a/b/d); auto-pin - // caps at two to match AgentVpc's default maxAzs and avoid widening topology. - const describeAzs = jest.fn, [string]>().mockResolvedValue(US_EAST_1_ZONES); + it('treats an unresolved token account as env-agnostic', async () => { + // Defensive guard for a future caller passing stack.account. const result = await resolveAgentCoreAzs({ - scope: scopeWithContext(), - account: '123456789012', + node: nodeWithContext(), + account: '${Token[AWS.AccountId.1]}', region: 'us-east-1', + // Both injected so that dropping the token guard cannot reach AWS from a + // unit test — it fails the assertion below instead. + describeAzs: async () => { + throw new Error('must not be called'); + }, + resolveCallerAccount: async () => { + throw new Error('must not be called'); + }, + }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics[0].message).toContain('without a bound account/region'); + }); + + it('warns and does not pin for a region absent from the map', async () => { + const describeAzs = jest.fn(okZones); + const result = await resolveConcrete({ region: 'me-central-1', describeAzs }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('warning'); + expect(result.diagnostics[0].message).toContain('No supported-zone data for region me-central-1'); + expect(describeAzs).not.toHaveBeenCalled(); + }); + + it('ERRORS when the AZ lookup fails (fail closed, not a silent fallback)', async () => { + const result = await resolveConcrete({ + describeAzs: async () => { + throw new Error('User: arn:aws:iam::123456789012:user/dev is not authorized'); + }, + }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('Could not resolve AgentCore-supported availability zones'); + // Error identity only — the authz message echoes the caller ARN, and this + // string lands in cdk.out, which CI uploads as an artifact. + expect(result.diagnostics[0].message).not.toContain('arn:aws:iam::'); + }); + + it('ERRORS when synth credentials belong to a different account', async () => { + const describeAzs = jest.fn(okZones); + const result = await resolveConcrete({ + resolveCallerAccount: async () => '999999999999', describeAzs, }); - expect(result).toEqual(['us-east-1a', 'us-east-1b']); - expect(describeAzs).toHaveBeenCalledWith('us-east-1'); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('resolve to account 999999999999'); + // Must not read a mapping from the wrong account. + expect(describeAzs).not.toHaveBeenCalled(); }); - it('warns and falls back when fewer than two supported zones are found', async () => { - const scope = scopeWithContext(); - const describeAzs = jest.fn, [string]>().mockResolvedValue([ - { zoneName: 'us-east-1a', zoneId: 'use1-az1' }, - { zoneName: 'us-east-1c', zoneId: 'use1-az6' }, - ]); - const result = await resolveAgentCoreAzs({ scope, account: '123456789012', region: 'us-east-1', describeAzs }); - expect(result).toBeUndefined(); - Annotations.fromStack(scope).hasWarning('*', Match.stringLikeRegexp('AgentCore AZs')); + it('ERRORS when the caller account cannot be confirmed', async () => { + const result = await resolveConcrete({ + resolveCallerAccount: async () => { + throw new Error('ExpiredToken'); + }, + }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('Could not confirm which account'); }); - it('warns and falls back when the AZ lookup fails', async () => { - const scope = scopeWithContext(); - const describeAzs = jest.fn, [string]>().mockRejectedValue(new Error('DescribeAZ boom')); - const result = await resolveAgentCoreAzs({ scope, account: '123456789012', region: 'us-east-1', describeAzs }); - expect(result).toBeUndefined(); - Annotations.fromStack(scope).hasWarning('*', Match.stringLikeRegexp('AgentCore AZs')); + it('ERRORS when fewer than the HA floor of supported zones exist', async () => { + const result = await resolveConcrete({ + describeAzs: async () => [ + { zoneName: 'us-east-1a', zoneId: 'use1-az1' }, + { zoneName: 'us-east-1c', zoneId: 'use1-az6' }, + ], + }); + expect(result.zones).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].level).toBe('error'); + expect(result.diagnostics[0].message).toContain('Found only 1 AgentCore-supported availability zone(s)'); }); }); diff --git a/cdk/test/main.test.ts b/cdk/test/main.test.ts new file mode 100644 index 000000000..81deb6d82 --- /dev/null +++ b/cdk/test/main.test.ts @@ -0,0 +1,155 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; +import { + AGENTCORE_AZS_CONTEXT_KEY, + AvailabilityZoneInfo, + DescribeAzsFn, + ResolveCallerAccountFn, +} from '../src/constructs/agentcore-azs'; +import { BuildAppOptions, buildApp } from '../src/main'; + +/** + * These tests drive the real `main.ts` wiring rather than a re-implementation of + * it, so that deleting the AZ resolution, failing to thread the prop into + * `AgentStack`, or annotating a scope CDK does not collect all fail here. + * + * `Template.fromStack` / `Annotations.fromStack` read the synthesized **stack + * artifact** with validation skipped — which is exactly the seam that matters: + * annotations attached to the `App` node never reach a stack artifact. + */ + +const ACCOUNT = '123456789012'; +const REGION = 'us-east-1'; +const STACK_NAME = 'backgroundagent-dev'; + +const ZONES: AvailabilityZoneInfo[] = [ + { zoneName: 'us-east-1a', zoneId: 'use1-az2' }, // supported + { zoneName: 'us-east-1b', zoneId: 'use1-az4' }, // supported + { zoneName: 'us-east-1c', zoneId: 'use1-az6' }, // unsupported + { zoneName: 'us-east-1d', zoneId: 'use1-az1' }, // supported +]; + +const okZones: DescribeAzsFn = async () => ZONES; +const okAccount: ResolveCallerAccountFn = async () => ACCOUNT; + +function app(options: BuildAppOptions = {}): Promise { + return buildApp({ + account: ACCOUNT, + region: REGION, + describeAzs: okZones, + resolveCallerAccount: okAccount, + ...options, + }); +} + +function stackOf(built: App): Stack { + return built.node.findChild(STACK_NAME) as Stack; +} + +describe('buildApp — AgentCore AZ wiring', () => { + it('threads auto-pinned zones all the way into the VPC subnets', async () => { + const template = Template.fromStack(stackOf(await app())); + + // Sorted supported names capped at 2 => us-east-1a + us-east-1b, one public + // and one private subnet each. + template.resourceCountIs('AWS::EC2::Subnet', 4); + template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1a' }); + template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1b' }); + // The unsupported zone must never appear. + const subnets = template.findResources('AWS::EC2::Subnet'); + const azs = Object.values(subnets).map(s => s.Properties?.AvailabilityZone); + expect(azs).not.toContain('us-east-1c'); + expect(new Set(azs)).toEqual(new Set(['us-east-1a', 'us-east-1b'])); + }); + + it('pins from the context override on the env-agnostic (pipeline) path', async () => { + // The assembly deploy.yml ships is synthesized without credentials, so the + // override is the only mechanism available there. + const built = await buildApp({ + account: undefined, + region: undefined, + appProps: { context: { [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1c'] } }, + }); + const template = Template.fromStack(stackOf(built)); + template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1b' }); + template.hasResourceProperties('AWS::EC2::Subnet', { AvailabilityZone: 'us-east-1c' }); + Annotations.fromStack(stackOf(built)).hasNoError('*', Match.stringLikeRegexp('AgentCore AZs')); + }); + + it('surfaces a lookup failure as a stack-artifact ERROR (regression: annotations were dropped)', async () => { + // Previously the resolver annotated the App node, which CDK never collects + // into a stack artifact — a failed lookup produced a clean, silent synth and + // then the NotStabilized rollback this feature exists to prevent. + const built = await app({ + describeAzs: async () => { + throw new Error('AccessDeniedException'); + }, + }); + Annotations.fromStack(stackOf(built)).hasError( + '*', + Match.stringLikeRegexp('Could not resolve AgentCore-supported availability zones'), + ); + }); + + it('records the failure at error level in the assembly (what the CLI fails on)', async () => { + // In-process `app.synth()` does not throw on error annotations; the CDK CLI + // is what refuses to continue ("Synthesis finished with errors", exit 1) when + // a stack artifact carries an error-level message. Verified against the CLI + // in this tree, so this asserts exactly that condition — every real synth + // path (mise //cdk:synth, cdk deploy, build.yml) goes through the CLI. + const built = await app({ + describeAzs: async () => { + throw new Error('AccessDeniedException'); + }, + }); + const messages = built.synth().getStackByName(STACK_NAME).messages; + const errors = messages.filter(m => m.level === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0].entry.data).toContain('[AgentCore AZs]'); + expect(errors[0].entry.data).toContain('Could not resolve AgentCore-supported availability zones'); + }); + + it('surfaces the unpinned env-agnostic case as a stack-artifact WARNING', async () => { + const built = await buildApp({ account: undefined, region: undefined }); + Annotations.fromStack(stackOf(built)).hasWarning( + '*', + Match.stringLikeRegexp('without a bound account/region'), + ); + }); + + it('leaves the VPC unpinned (Fn::GetAZs) when no zones resolve', async () => { + const built = await buildApp({ account: undefined, region: undefined }); + Template.fromStack(stackOf(built)).hasResourceProperties('AWS::EC2::Subnet', { + AvailabilityZone: { 'Fn::Select': Match.arrayWith([{ 'Fn::GetAZs': '' }]) }, + }); + }); + + it('propagates a malformed override as a synth-time throw', async () => { + await expect( + buildApp({ + account: ACCOUNT, + region: REGION, + appProps: { context: { [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' } }, + }), + ).rejects.toThrow(/must be a JSON array of availability-zone names/); + }); +}); diff --git a/docs/guides/DEPLOYMENT_GUIDE.md b/docs/guides/DEPLOYMENT_GUIDE.md index 511dae6cc..57245e4fb 100644 --- a/docs/guides/DEPLOYMENT_GUIDE.md +++ b/docs/guides/DEPLOYMENT_GUIDE.md @@ -184,28 +184,58 @@ Triggers via `workflow_run` when `build.yml` completes successfully. The pipelin **Symptom:** The `AWS::BedrockAgentCore::Runtime` resource fails to stabilize (`NotStabilized` — "subnets are in unsupported availability zones") and the stack rolls back. -**Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones) table for the per-region set. +**Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-supported-azs) table for the per-region set; the same table is snapshotted as `AGENTCORE_SUPPORTED_AZ_IDS` in `cdk/src/constructs/agentcore-azs.ts`. -**Default behavior (auto-pin) — local deploy only:** When the stack is synthesized with a concrete account and region — a local `cdk deploy` / `mise //cdk:deploy`, which resolves credentials at synth time — it reads your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the first two AZ *names* whose IDs are AgentCore-supported (two matches AgentVpc's default `maxAzs`, so enabling this doesn't widen an already-working topology). No action needed on that path for the regions in the built-in map. +#### Which deploy paths are protected -**The CI/CD-deployed stack is NOT auto-pinned.** Auto-pin needs a bound account at synth time, and the pipeline doesn't have one: `build.yml` synthesizes `cdk.out` credential-less (env-agnostic), and `deploy.yml` deploys that pre-built artifact (`--app cdk/cdk.out`). So a team deploying through the pipeline falls back to CDK's default AZ selection and **must set the `agentcore:availabilityZones` override to pin zones**. The same applies to any region not yet in the built-in supported-AZ map. To set the override: +| Path | Auto-pinned? | What you must do | +|------|--------------|------------------| +| Local `cdk deploy` / `mise //cdk:deploy` (credentials resolve at synth) | Yes — for regions in the built-in map | Nothing, unless synth reports an `[AgentCore AZs]` error | +| CI/CD (`build.yml` → `deploy.yml`) | **No** — the assembly is synthesized credential-less | Set the `AGENTCORE_AVAILABILITY_ZONES` repo/environment variable (below) | +| Any region absent from the built-in map | No | Set the context override (below) | + +**Auto-pin (local deploys).** With a concrete account and region, synth confirms the credentials belong to that account (`sts:GetCallerIdentity`), reads the account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`), and pins the VPC to the first two AZ *names* — sorted, so the pin is stable across synths — whose zone IDs are AgentCore-supported. Two zones matches `AgentVpc`'s default `maxAzs`, so enabling this does not widen an already-working topology. + +If auto-pin is attempted and cannot finish — lookup denied or throttled, credentials pointing at a different account, or fewer than two supported zones — synth **fails** with an `[AgentCore AZs]` error rather than quietly falling back. That is deliberate: a silent fallback produces a template that looks pinned but is not, which is the failure this section exists to prevent. Fix the cause or set the override. + +**CI/CD deploys are not auto-pinned.** Auto-pin needs a bound account at synth time and the pipeline has none: `build.yml` synthesizes `cdk.out` without credentials (env-agnostic) and `deploy.yml` deploys that pre-built assembly (`--app cdk/cdk.out`). Two consequences worth being explicit about: + +- Passing `-c 'agentcore:availabilityZones=...'` to `cdk deploy` **has no effect on the pipeline path** — the template is already synthesized by then. Context only matters at synth. +- `cdk/cdk.context.json` is **not** a durable place to set this: it is gitignored, and `build.yml` regenerates the whole file. + +Instead, set the repo (or environment) variable **`AGENTCORE_AVAILABILITY_ZONES`** to a JSON array of zone names. `build.yml`'s "Generate CDK context" step folds it into the context that the uploaded assembly is synthesized with, and an unset variable simply leaves the stack unpinned (synth logs an `[AgentCore AZs]` warning): + +``` +AGENTCORE_AVAILABILITY_ZONES = ["us-east-1b","us-east-1c"] +``` + +**Choosing the values.** 1. Discover your account's zone name-to-ID mapping: ```bash aws ec2 describe-availability-zones --region \ --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text ``` -2. Pick at least two zone **names** whose **IDs** are in the AgentCore-supported set for the region (high-availability floor). -3. Set the override — either in `cdk/cdk.context.json`: +2. Pick at least two zone **names** (column 1) whose **zone IDs** (column 2) appear in the AgentCore-supported set for that region. +3. Set the value — the pipeline variable above, or for a local synth either `cdk/cdk.json` `context`: ```json - { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } + { "context": { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } } ``` - or on the CLI (note the value is a JSON array): + or the CLI at synth time: ```bash cdk deploy -c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]' ``` -The override is validated at synth time (both forms parse identically): a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. +The override is validated at synth time, and both the JSON-array and `-c` string forms behave identically. Synth fails with a message naming the key when the value is not an array, has an empty/non-string entry, lists fewer than two **distinct** zones, contains zone *IDs* instead of names (`use1-az2` — a common column mix-up), or names zones outside the target region. When the account's mapping is knowable, the override is additionally cross-checked against the supported set, and unsupported or nonexistent zones fail synth. + +**Upgrading an existing stack.** Auto-pin is on by default, so a local `cdk deploy` against a stack created before this change may select different zones than the deployed subnets use. `Subnet.AvailabilityZone` is create-only, so that is a **replacement** of the subnets and the resources bound to them (route tables, NAT gateway/EIP, VPC endpoints). Run `mise //cdk:diff` first. If the diff shows subnet replacement and you would rather keep the current topology, pin the override to the zones already deployed: + +```bash +aws ec2 describe-subnets --filters "Name=vpc-id,Values=" \ + --query 'Subnets[].[SubnetId,AvailabilityZone,AvailabilityZoneId]' --output text +``` + +Be aware that destroying a VPC whose subnets held AgentCore ENIs can take 20–40 minutes while AWS reclaims them (see the `DELETE_FAILED` note in the [quick start](./QUICK_START.mdx) troubleshooting table). ### DNS Query Log Config replacement cascade (upgrading from pre-v0.5) diff --git a/docs/guides/QUICK_START.mdx b/docs/guides/QUICK_START.mdx index ee0d33ca9..f6abf09d6 100644 --- a/docs/guides/QUICK_START.mdx +++ b/docs/guides/QUICK_START.mdx @@ -72,7 +72,9 @@ If `lib/bin/bgagent.js` is missing, run `mise run build` from `cli/` (or repeat ::: -> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build. A local deploy uses that same call to auto-pin the VPC to AgentCore-supported zones; if you deploy from an env-agnostic artifact or hit "unsupported availability zones", set the `agentcore:availabilityZones` override — see [Known deployment issues](./DEPLOYMENT_GUIDE.md#agentcore-unsupported-availability-zones). +> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build — the CDK CLI does not forward `--profile` to the app process, so an unexported profile can leave synth reading a different account. +> +> A local deploy uses that same call to pin the VPC to AgentCore-supported zones, and synth fails with an `[AgentCore AZs]` error if it cannot confirm them. Deploys through the CI/CD pipeline are **not** auto-pinned (the assembly is synthesized without credentials) and need the `AGENTCORE_AVAILABILITY_ZONES` variable instead — see [Known deployment issues](./DEPLOYMENT_GUIDE.md#agentcore-unsupported-availability-zones). ## Step 2 - Prepare a repository @@ -515,6 +517,7 @@ Here is what the platform did after you ran `node lib/bin/bgagent.js submit`: | Deploy rolled back; can't redeploy (`ROLLBACK_COMPLETE`) | A first-create failure leaves the stack un-updatable | `mise //cdk:destroy` (or delete the stack), then deploy again. Do **not** force-delete past stuck VPC resources — it orphans the VPC, and VPCs are quota-capped per Region | | Stack stuck in `DELETE_FAILED` on a security group / subnet | AgentCore's service-managed (Hyperplane) ENIs reclaim asynchronously after the runtime is gone | Wait ~20–40 min for AWS to release the ENIs, then retry `mise //cdk:destroy`. You cannot force-detach an `amazon-aws`-owned ENI | | Runtime rollback: `NotStabilized` / "subnets are in unsupported availability zones" | VPC subnets landed in AZs AgentCore doesn't support (auto-pin skipped for env-agnostic synth, or region not in the built-in map) | Set the `agentcore:availabilityZones` override to supported zone names — see [Known deployment issues](./DEPLOYMENT_GUIDE.md#agentcore-unsupported-availability-zones) | +| Synth fails with `[AgentCore AZs] Could not resolve …` or `… Synth credentials resolve to account …` | Auto-pin could not verify supported zones: missing `ec2:DescribeAvailabilityZones`, expired credentials, or the app process resolving a different account than the deploy target | Grant the permission / refresh credentials / `export AWS_PROFILE=`, or set the `agentcore:availabilityZones` override. Failing closed is intentional — a silent fallback yields a template that looks pinned but isn't | | `put-secret-value` returns double-dot endpoint | `REGION` variable is empty | Set `REGION=us-east-1` (or your actual region) before running the command | | Model / Bedrock errors in logs (`not available on your bedrock`, zero tokens) | Model not entitled for the account or Region, wrong `modelId` shape, or missing Marketplace / FTU steps | Follow **Amazon Bedrock before your first task** above; confirm [model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) and use an [inference profile](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html) ID such as `us.anthropic.claude-sonnet-4-6` where required; keep `grantInvoke` in `agent.ts` aligned with that model | | `REPO_NOT_ONBOARDED` on task submit | Blueprint `repo` does not match what you passed to the CLI | Confirm `BLUEPRINT_REPO`, CDK context `blueprintRepo`, or the `repo` prop on the `Blueprint` in `cdk/src/stacks/agent.ts` resolves to exactly the same `owner/repo` you pass to the CLI | diff --git a/docs/src/content/docs/getting-started/Deployment-guide.md b/docs/src/content/docs/getting-started/Deployment-guide.md index b88f10036..6519e0ecd 100644 --- a/docs/src/content/docs/getting-started/Deployment-guide.md +++ b/docs/src/content/docs/getting-started/Deployment-guide.md @@ -188,28 +188,58 @@ Triggers via `workflow_run` when `build.yml` completes successfully. The pipelin **Symptom:** The `AWS::BedrockAgentCore::Runtime` resource fails to stabilize (`NotStabilized` — "subnets are in unsupported availability zones") and the stack rolls back. -**Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://aws.github.io/bedrock-agentcore-starter-toolkit/user-guide/security/agentcore-vpc/#supported-availability-zones) table for the per-region set. +**Root cause:** AgentCore Runtime only places its network interfaces in a subset of each region's Availability Zones, published as physical **zone IDs** (e.g. `use1-az1`, `use1-az2`, `use1-az4` for `us-east-1`). Zone IDs are stable across accounts, but zone *names* (`us-east-1a`) are aliased per-account — so `us-east-1a` can map to a different physical zone in your account than in another. Left to its default, CDK picks zones by name and can land the Runtime subnets in an unsupported zone. See the AWS [Supported Availability Zones](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-vpc.html#agentcore-supported-azs) table for the per-region set; the same table is snapshotted as `AGENTCORE_SUPPORTED_AZ_IDS` in `cdk/src/constructs/agentcore-azs.ts`. -**Default behavior (auto-pin) — local deploy only:** When the stack is synthesized with a concrete account and region — a local `cdk deploy` / `mise //cdk:deploy`, which resolves credentials at synth time — it reads your account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`) and pins the VPC to the first two AZ *names* whose IDs are AgentCore-supported (two matches AgentVpc's default `maxAzs`, so enabling this doesn't widen an already-working topology). No action needed on that path for the regions in the built-in map. +#### Which deploy paths are protected -**The CI/CD-deployed stack is NOT auto-pinned.** Auto-pin needs a bound account at synth time, and the pipeline doesn't have one: `build.yml` synthesizes `cdk.out` credential-less (env-agnostic), and `deploy.yml` deploys that pre-built artifact (`--app cdk/cdk.out`). So a team deploying through the pipeline falls back to CDK's default AZ selection and **must set the `agentcore:availabilityZones` override to pin zones**. The same applies to any region not yet in the built-in supported-AZ map. To set the override: +| Path | Auto-pinned? | What you must do | +|------|--------------|------------------| +| Local `cdk deploy` / `mise //cdk:deploy` (credentials resolve at synth) | Yes — for regions in the built-in map | Nothing, unless synth reports an `[AgentCore AZs]` error | +| CI/CD (`build.yml` → `deploy.yml`) | **No** — the assembly is synthesized credential-less | Set the `AGENTCORE_AVAILABILITY_ZONES` repo/environment variable (below) | +| Any region absent from the built-in map | No | Set the context override (below) | + +**Auto-pin (local deploys).** With a concrete account and region, synth confirms the credentials belong to that account (`sts:GetCallerIdentity`), reads the account's zone name-to-ID mapping (`ec2:DescribeAvailabilityZones`), and pins the VPC to the first two AZ *names* — sorted, so the pin is stable across synths — whose zone IDs are AgentCore-supported. Two zones matches `AgentVpc`'s default `maxAzs`, so enabling this does not widen an already-working topology. + +If auto-pin is attempted and cannot finish — lookup denied or throttled, credentials pointing at a different account, or fewer than two supported zones — synth **fails** with an `[AgentCore AZs]` error rather than quietly falling back. That is deliberate: a silent fallback produces a template that looks pinned but is not, which is the failure this section exists to prevent. Fix the cause or set the override. + +**CI/CD deploys are not auto-pinned.** Auto-pin needs a bound account at synth time and the pipeline has none: `build.yml` synthesizes `cdk.out` without credentials (env-agnostic) and `deploy.yml` deploys that pre-built assembly (`--app cdk/cdk.out`). Two consequences worth being explicit about: + +- Passing `-c 'agentcore:availabilityZones=...'` to `cdk deploy` **has no effect on the pipeline path** — the template is already synthesized by then. Context only matters at synth. +- `cdk/cdk.context.json` is **not** a durable place to set this: it is gitignored, and `build.yml` regenerates the whole file. + +Instead, set the repo (or environment) variable **`AGENTCORE_AVAILABILITY_ZONES`** to a JSON array of zone names. `build.yml`'s "Generate CDK context" step folds it into the context that the uploaded assembly is synthesized with, and an unset variable simply leaves the stack unpinned (synth logs an `[AgentCore AZs]` warning): + +``` +AGENTCORE_AVAILABILITY_ZONES = ["us-east-1b","us-east-1c"] +``` + +**Choosing the values.** 1. Discover your account's zone name-to-ID mapping: ```bash aws ec2 describe-availability-zones --region \ --query 'AvailabilityZones[].[ZoneName,ZoneId]' --output text ``` -2. Pick at least two zone **names** whose **IDs** are in the AgentCore-supported set for the region (high-availability floor). -3. Set the override — either in `cdk/cdk.context.json`: +2. Pick at least two zone **names** (column 1) whose **zone IDs** (column 2) appear in the AgentCore-supported set for that region. +3. Set the value — the pipeline variable above, or for a local synth either `cdk/cdk.json` `context`: ```json - { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } + { "context": { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } } ``` - or on the CLI (note the value is a JSON array): + or the CLI at synth time: ```bash cdk deploy -c 'agentcore:availabilityZones=["us-east-1b","us-east-1c"]' ``` -The override is validated at synth time (both forms parse identically): a non-array, an empty/non-string entry, or fewer than two zones fails the synth with a message naming the key and expected shape. The supported-AZ map and resolution logic live in `cdk/src/constructs/agentcore-azs.ts`. +The override is validated at synth time, and both the JSON-array and `-c` string forms behave identically. Synth fails with a message naming the key when the value is not an array, has an empty/non-string entry, lists fewer than two **distinct** zones, contains zone *IDs* instead of names (`use1-az2` — a common column mix-up), or names zones outside the target region. When the account's mapping is knowable, the override is additionally cross-checked against the supported set, and unsupported or nonexistent zones fail synth. + +**Upgrading an existing stack.** Auto-pin is on by default, so a local `cdk deploy` against a stack created before this change may select different zones than the deployed subnets use. `Subnet.AvailabilityZone` is create-only, so that is a **replacement** of the subnets and the resources bound to them (route tables, NAT gateway/EIP, VPC endpoints). Run `mise //cdk:diff` first. If the diff shows subnet replacement and you would rather keep the current topology, pin the override to the zones already deployed: + +```bash +aws ec2 describe-subnets --filters "Name=vpc-id,Values=" \ + --query 'Subnets[].[SubnetId,AvailabilityZone,AvailabilityZoneId]' --output text +``` + +Be aware that destroying a VPC whose subnets held AgentCore ENIs can take 20–40 minutes while AWS reclaims them (see the `DELETE_FAILED` note in the [quick start](./QUICK_START.mdx) troubleshooting table). ### DNS Query Log Config replacement cascade (upgrading from pre-v0.5) diff --git a/docs/src/content/docs/getting-started/Quick-start.mdx b/docs/src/content/docs/getting-started/Quick-start.mdx index a1a32ae08..bd14eaf41 100644 --- a/docs/src/content/docs/getting-started/Quick-start.mdx +++ b/docs/src/content/docs/getting-started/Quick-start.mdx @@ -72,7 +72,9 @@ If `lib/bin/bgagent.js` is missing, run `mise run build` from `cli/` (or repeat ::: -> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build. A local deploy uses that same call to auto-pin the VPC to AgentCore-supported zones; if you deploy from an env-agnostic artifact or hit "unsupported availability zones", set the `agentcore:availabilityZones` override — see [Known deployment issues](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide#agentcore-unsupported-availability-zones). +> **Note:** `mise run build` includes CDK synthesis, which queries AWS for availability zones. Your active AWS credentials must have at least `ec2:DescribeAvailabilityZones` permission, or the build will fail. If you use named profiles, make sure `AWS_PROFILE` is set before running the build — the CDK CLI does not forward `--profile` to the app process, so an unexported profile can leave synth reading a different account. +> +> A local deploy uses that same call to pin the VPC to AgentCore-supported zones, and synth fails with an `[AgentCore AZs]` error if it cannot confirm them. Deploys through the CI/CD pipeline are **not** auto-pinned (the assembly is synthesized without credentials) and need the `AGENTCORE_AVAILABILITY_ZONES` variable instead — see [Known deployment issues](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide#agentcore-unsupported-availability-zones). ## Step 2 - Prepare a repository @@ -515,6 +517,7 @@ Here is what the platform did after you ran `node lib/bin/bgagent.js submit`: | Deploy rolled back; can't redeploy (`ROLLBACK_COMPLETE`) | A first-create failure leaves the stack un-updatable | `mise //cdk:destroy` (or delete the stack), then deploy again. Do **not** force-delete past stuck VPC resources — it orphans the VPC, and VPCs are quota-capped per Region | | Stack stuck in `DELETE_FAILED` on a security group / subnet | AgentCore's service-managed (Hyperplane) ENIs reclaim asynchronously after the runtime is gone | Wait ~20–40 min for AWS to release the ENIs, then retry `mise //cdk:destroy`. You cannot force-detach an `amazon-aws`-owned ENI | | Runtime rollback: `NotStabilized` / "subnets are in unsupported availability zones" | VPC subnets landed in AZs AgentCore doesn't support (auto-pin skipped for env-agnostic synth, or region not in the built-in map) | Set the `agentcore:availabilityZones` override to supported zone names — see [Known deployment issues](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide#agentcore-unsupported-availability-zones) | +| Synth fails with `[AgentCore AZs] Could not resolve …` or `… Synth credentials resolve to account …` | Auto-pin could not verify supported zones: missing `ec2:DescribeAvailabilityZones`, expired credentials, or the app process resolving a different account than the deploy target | Grant the permission / refresh credentials / `export AWS_PROFILE=`, or set the `agentcore:availabilityZones` override. Failing closed is intentional — a silent fallback yields a template that looks pinned but isn't | | `put-secret-value` returns double-dot endpoint | `REGION` variable is empty | Set `REGION=us-east-1` (or your actual region) before running the command | | Model / Bedrock errors in logs (`not available on your bedrock`, zero tokens) | Model not entitled for the account or Region, wrong `modelId` shape, or missing Marketplace / FTU steps | Follow **Amazon Bedrock before your first task** above; confirm [model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) and use an [inference profile](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html) ID such as `us.anthropic.claude-sonnet-4-6` where required; keep `grantInvoke` in `agent.ts` aligned with that model | | `REPO_NOT_ONBOARDED` on task submit | Blueprint `repo` does not match what you passed to the CLI | Confirm `BLUEPRINT_REPO`, CDK context `blueprintRepo`, or the `repo` prop on the `Blueprint` in `cdk/src/stacks/agent.ts` resolves to exactly the same `owner/repo` you pass to the CLI | diff --git a/yarn.lock b/yarn.lock index 387c165ee..3da21e0eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -594,6 +594,21 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/client-sts@^3.1078.0": + version "3.1119.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.1119.0.tgz#2b080c257fc5f4549f2cb743546cbb5ae9309034" + integrity sha512-pz9DKEWoXFmvSpGH5Bdo10hYYIIDs2sZMQf1HZbFG9mlVdMOVanCIVUsQqU34nIvKEvdqYdjY67DoE1ashzf8g== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/credential-provider-node" "^3.972.81" + "@aws-sdk/signature-v4-multi-region" "^3.996.46" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/fetch-http-handler" "^5.7.2" + "@smithy/node-http-handler" "^4.11.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + "@aws-sdk/core@^3.974.26", "@aws-sdk/core@^3.974.29": version "3.974.29" resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.29.tgz#6a22ddd63d5995ff5a300f5911bd9fbb5d837deb"