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 d98f3123d..e901429c0 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -20,11 +20,13 @@ "@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-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 d8d5546e4..91e06d6f2 100644 --- a/cdk/src/constructs/agent-vpc.ts +++ b/cdk/src/constructs/agent-vpc.ts @@ -26,16 +26,63 @@ 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. */ 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. The supported zone-ID set differs per region and + * can change over time — see the AWS + * {@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 `AgentStackProps.agentCoreAvailabilityZones`. + * + * 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[]; + /** * Number of NAT gateways to provision. * @default 1 @@ -66,13 +113,35 @@ 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; // --- 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, + ...(pinnedAzs?.length + ? { availabilityZones: pinnedAzs } + : { maxAzs }), natGateways, restrictDefaultSecurityGroup: true, subnetConfiguration: [ diff --git a/cdk/src/constructs/agentcore-azs.ts b/cdk/src/constructs/agentcore-azs.ts new file mode 100644 index 000000000..7665136f6 --- /dev/null +++ b/cdk/src/constructs/agentcore-azs.ts @@ -0,0 +1,572 @@ +/** + * 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'; +import { makeClient } from '../handlers/shared/ua'; + +/** + * 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. 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. + * + * ## 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'], + '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. + * + * 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'; + +/** + * 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 { + /** 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; + +/** 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 { + /** 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`); 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 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. + */ +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.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 { + 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 ` + + `(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 (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).`, + ); + } + } + 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} distinct 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`. + * + * 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. + */ +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) + .sort((a, b) => a.localeCompare(b)); +} + +/** + * Live `DescribeAvailabilityZones` lookup (default {@link DescribeAzsFn}). + * + * `@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 = makeClient(EC2Client, { + region, + maxAttempts: LOOKUP_MAX_ATTEMPTS, + requestHandler: { requestTimeout: LOOKUP_TIMEOUT_MS, connectionTimeout: LOOKUP_TIMEOUT_MS }, + }); + const response = await client.send( + new DescribeAvailabilityZonesCommand({ + 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[] = []; + for (const zone of response.AvailabilityZones ?? []) { + if (zone.ZoneName && zone.ZoneId) { + zones.push({ zoneName: zone.ZoneName, zoneId: zone.ZoneId }); + } + } + return zones; +} + +/** + * Live `sts:GetCallerIdentity` (default {@link ResolveCallerAccountFn}). + * + * Needed because the CDK CLI passes only `CDK_DEFAULT_ACCOUNT` / + * `CDK_DEFAULT_REGION` to the app process — **not** credentials and not + * `--profile`. So `cdk deploy --profile prod` can leave this process resolving + * the *default* profile's credentials while the deploy targets `prod`. Zone + * names are region-uniform, so every other guard would pass while the mapping + * came from the wrong account, yielding a confident pin into physically + * unsupported zones. `GetCallerIdentity` requires no IAM permission. + */ +async function defaultResolveCallerAccount(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, 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 { 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(node, concreteRegion); + if (override) { + if (concreteRegion && AGENTCORE_SUPPORTED_AZ_IDS[concreteRegion]) { + diagnostics.push(...await verifyOverride(options, concreteRegion, override)); + } + return { zones: override, diagnostics }; + } + + // 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, 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; + + // Confirm ambient credentials belong to the target account before trusting + // any name -> zone-ID mapping they return. + let callerAccount: string | undefined; + try { + callerAccount = await resolveCallerAccount(region); + } catch (err) { + 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 { zones: names.slice(0, AUTO_PIN_AZ_COUNT), diagnostics }; +} diff --git a/cdk/src/constructs/bedrock-models.ts b/cdk/src/constructs/bedrock-models.ts index 8e539e8a3..c8c56c7af 100644 --- a/cdk/src/constructs/bedrock-models.ts +++ b/cdk/src/constructs/bedrock-models.ts @@ -206,10 +206,22 @@ export function haikuInferenceProfileId(geoRegion: CrossRegionInferenceProfileRe * 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/src/main.ts b/cdk/src/main.ts index 98266bc98..ac442c8dd 100644 --- a/cdk/src/main.ts +++ b/cdk/src/main.ts @@ -17,8 +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 { + applyAgentCoreAzDiagnostics, + DescribeAzsFn, + ResolveCallerAccountFn, + resolveAgentCoreAzs, +} from './constructs/agentcore-azs'; import { buildAppId, SolutionUaAspect } from './constructs/solution-ua-aspect'; import { AgentStack } from './stacks/agent'; @@ -28,73 +34,140 @@ const devEnv = { region: process.env.CDK_DEFAULT_REGION, }; -const app = new App(); - -Aspects.of(app).add(new AwsSolutionsChecks()); - -const stackName = app.node.tryGetContext('stackName') ?? 'backgroundagent-dev'; - -const stack = new AgentStack( - app, - stackName, - { - env: devEnv, - description: 'ABCA Development Stack (uksb-wt64nei4u6)', - }, -); - -// 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 -// `-c sdkUaAppId=...`; `-c sdkUaAppId=''` opts out (no app/ segment anywhere). -const sdkUaAppIdOverride = app.node.tryGetContext('sdkUaAppId') as string | undefined; -// MUTATING priority so the env var is set before cdk-nag (priority 500) -// inspects the synthesized functions — matches the agent stack's aspects. -Aspects.of(stack).add(new SolutionUaAspect(buildAppId(stackName, sdkUaAppIdOverride)), { - priority: AspectPriority.MUTATING, -}); - -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', -]; - -// TODO(#645): with three backends this single-valued tag is no longer an honest -// statement of what a stack runs — a `--context compute_type=lambda-microvm` -// deploy still provisions the AgentCore runtime, so every resource gets tagged -// `compute_type=lambda-microvm` including the AgentCore ones. ADR-021 -// sub-decision 4 flags revisiting the semantics (e.g. a `compute_types` list). -// Deliberately NOT changed here: retagging every resource in the stack is a -// replacement-risk change of its own, and MicroVM spend is already attributable -// through the per-resource `abca:compute-backend` tags the -// LambdaMicrovmCompute construct applies. -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; - -for (const key of githubTagKeys) { - const value = app.node.tryGetContext(`github:${key}`); - Tags.of(stack).add(`github:${key}`, value || 'none', { excludeResourceTypes }); +/** 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; } -app.synth(); +/** + * 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. + */ +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). `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, + 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 + // `-c sdkUaAppId=...`; `-c sdkUaAppId=''` opts out (no app/ segment anywhere). + const sdkUaAppIdOverride = app.node.tryGetContext('sdkUaAppId') as string | undefined; + // MUTATING priority so the env var is set before cdk-nag (priority 500) + // inspects the synthesized functions — matches the agent stack's aspects. + Aspects.of(stack).add(new SolutionUaAspect(buildAppId(stackName, sdkUaAppIdOverride)), { + priority: AspectPriority.MUTATING, + }); + + 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', + ]; + + // TODO(#645): with three backends this single-valued tag is no longer an honest + // statement of what a stack runs — a `--context compute_type=lambda-microvm` + // deploy still provisions the AgentCore runtime, so every resource gets tagged + // `compute_type=lambda-microvm` including the AgentCore ones. ADR-021 + // sub-decision 4 flags revisiting the semantics (e.g. a `compute_types` list). + // Deliberately NOT changed here: retagging every resource in the stack is a + // replacement-risk change of its own, and MicroVM spend is already attributable + // through the per-resource `abca:compute-backend` tags the + // LambdaMicrovmCompute construct applies. + 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; + + for (const key of githubTagKeys) { + const value = app.node.tryGetContext(`github:${key}`); + Tags.of(stack).add(`github:${key}`, value || 'none', { excludeResourceTypes }); + } + + return app; +} + +/** Builds and synthesizes — the CDK app entrypoint (`cdk.json` `app`). */ +export async function main(): Promise { + (await buildApp()).synth(); +} + +// 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 86525bcd9..e749ef06f 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -89,8 +89,27 @@ 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, 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 agentCoreAvailabilityZones?: string[]; +} + export class AgentStack extends Stack { - constructor(scope: Construct, id: string, props: StackProps = {}) { + constructor(scope: Construct, id: string, props: AgentStackProps = {}) { super(scope, id, props); const enableAgentRegistry = this.node.tryGetContext('enableAgentRegistry'); @@ -330,8 +349,20 @@ export class AgentStack extends Stack { }, }); - // Network isolation — VPC with restricted egress - const agentVpc = new AgentVpc(this, 'AgentVpc'); + // Network isolation — VPC with restricted egress. + // 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.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.agentCoreAvailabilityZones?.length + ? { availabilityZones: props.agentCoreAvailabilityZones } + : {}), + }); // 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..6b287bf9a 100644 --- a/cdk/test/constructs/agent-vpc.test.ts +++ b/cdk/test/constructs/agent-vpc.test.ts @@ -149,4 +149,74 @@ describe('AgentVpc with custom props', () => { template.resourceCountIs('AWS::EC2::NatGateway', 2); }); + + 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'], + }); + const template = Template.fromStack(stack); + + // 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('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 + // 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', () => { + 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); + }); }); 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 new file mode 100644 index 000000000..782d34e2c --- /dev/null +++ b/cdk/test/constructs/agentcore-azs.test.ts @@ -0,0 +1,430 @@ +/** + * 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 { 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; +} + +/** + * 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. */ +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 +]; + +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('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 [ + '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 the HA floor of distinct zone IDs per region', () => { + for (const [region, ids] of Object.entries(AGENTCORE_SUPPORTED_AZ_IDS)) { + expect(ids.length).toBeGreaterThanOrEqual(MIN_AGENTCORE_AZS); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) { + expect(id).toMatch(/^[a-z]+[0-9]+-az[0-9]+$/); + } + 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', () => { + 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('parses a JSON-string array (the `-c key=value` CLI form)', () => { + 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 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', '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('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]: ['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 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', () => { + // 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', () => { + 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('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({ + node: nodeWithContext({ [AGENTCORE_AZS_CONTEXT_KEY]: ['us-east-1b', 'us-east-1c'] }), + describeAzs, + resolveCallerAccount, + }); + 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(resolveConcrete({ context: { [AGENTCORE_AZS_CONTEXT_KEY]: 'us-east-1b' } })) + .rejects.toThrow(/must be a JSON array of availability-zone names/); + }); + + 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'); + }, + }); + // 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('treats an unresolved token account as env-agnostic', async () => { + // Defensive guard for a future caller passing stack.account. + const result = await resolveAgentCoreAzs({ + 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.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('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('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/constructs/bedrock-models.test.ts b/cdk/test/constructs/bedrock-models.test.ts index 477fbca8d..ed64852e6 100644 --- a/cdk/test/constructs/bedrock-models.test.ts +++ b/cdk/test/constructs/bedrock-models.test.ts @@ -48,6 +48,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' })), 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 fe9441a40..d26104868 100644 --- a/docs/guides/DEPLOYMENT_GUIDE.md +++ b/docs/guides/DEPLOYMENT_GUIDE.md @@ -217,6 +217,65 @@ 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://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`. + +#### Which deploy paths are protected + +| 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** (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 + { "context": { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } } + ``` + 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, 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) **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..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. +> **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 @@ -514,6 +516,8 @@ 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) | +| 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 50a9ef667..8d9db0a23 100644 --- a/docs/src/content/docs/getting-started/Deployment-guide.md +++ b/docs/src/content/docs/getting-started/Deployment-guide.md @@ -221,6 +221,65 @@ 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://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`. + +#### Which deploy paths are protected + +| 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** (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 + { "context": { "agentcore:availabilityZones": ["us-east-1b", "us-east-1c"] } } + ``` + 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, 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) **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..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. +> **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 @@ -514,6 +516,8 @@ 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) | +| 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 b9ac5a299..3da21e0eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -492,6 +492,21 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/client-ec2@^3.1078.0": + version "3.1119.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-ec2/-/client-ec2-3.1119.0.tgz#533bcf17e518a7b52bb050dfc64be97f74755c46" + integrity sha512-bBoitEwXRSkWk+tNEyoVed0GCBhhT+2njZjqTKoKepgb+J0xLvW6V65YvPW7EDxCiok4Zx5fsx1Y+I1HHyf00A== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/credential-provider-node" "^3.972.81" + "@aws-sdk/middleware-sdk-ec2" "^3.972.58" + "@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/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" @@ -579,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" @@ -1016,6 +1046,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/middleware-sdk-ec2@^3.972.58": + version "3.972.58" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.58.tgz#de650ecad01741f8e9bffcd6e0d23be778fe9230" + integrity sha512-k1yW3bEUcy/e9oxIH0zT81sbPCqgERdNf0bOsBZg/tXP75tyEKp8kp8BG9xag64+u9fF8064fLDUdIItWgQxWw== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.17.2" + 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"