diff --git a/cdk/src/constructs/agent-vpc.ts b/cdk/src/constructs/agent-vpc.ts index d8d5546e..72ecf421 100644 --- a/cdk/src/constructs/agent-vpc.ts +++ b/cdk/src/constructs/agent-vpc.ts @@ -30,6 +30,12 @@ const HTTPS_PORT = 443; * Properties for the AgentVpc construct. */ export interface AgentVpcProps { + /** + * Availability zones to use. Zone names must be resolved for the target + * account because AZ name-to-ID mappings differ between accounts. + */ + readonly availabilityZones?: string[]; + /** * Maximum number of availability zones to use. * @default 2 @@ -69,10 +75,13 @@ export class AgentVpc extends Construct { const maxAzs = props.maxAzs ?? 2; const natGateways = props.natGateways ?? 1; const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + const availabilityZoneSelection = props.availabilityZones + ? { availabilityZones: props.availabilityZones } + : { maxAzs }; // --- VPC --- this.vpc = new ec2.Vpc(this, 'Vpc', { - maxAzs, + ...availabilityZoneSelection, natGateways, restrictDefaultSecurityGroup: true, subnetConfiguration: [ diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index d1b9a816..6cc3f5fc 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -317,7 +317,18 @@ export class AgentStack extends Stack { }); // Network isolation — VPC with restricted egress - const agentVpc = new AgentVpc(this, 'AgentVpc'); + const agentcoreAvailabilityZonesContext = this.node.tryGetContext( + 'agentcoreAvailabilityZones', + ) as string | undefined; + const agentcoreAvailabilityZones = agentcoreAvailabilityZonesContext + ?.split(',') + .map(zone => zone.trim()) + .filter(Boolean); + const agentVpc = new AgentVpc(this, 'AgentVpc', { + availabilityZones: agentcoreAvailabilityZones?.length + ? agentcoreAvailabilityZones + : undefined, + }); // 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 49614d8d..7da3cf34 100644 --- a/cdk/test/constructs/agent-vpc.test.ts +++ b/cdk/test/constructs/agent-vpc.test.ts @@ -127,6 +127,25 @@ describe('AgentVpc', () => { }); describe('AgentVpc with custom props', () => { + test('accepts account-local availability zone names', () => { + 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-1d'], + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::EC2::Subnet', { + AvailabilityZone: 'us-east-1b', + }); + template.hasResourceProperties('AWS::EC2::Subnet', { + AvailabilityZone: 'us-east-1d', + }); + template.resourceCountIs('AWS::EC2::Subnet', 4); + }); + test('accepts custom maxAzs', () => { const app = new App(); const stack = new Stack(app, 'TestStack', { diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 2803d387..04e37eb7 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -1368,3 +1368,26 @@ describe('AgentStack registry gate', () => { expect(renderedPolicies).not.toContain('bedrock-agentcore:ListRegistryRecords'); }); }); + +describe('AgentStack AgentCore availability zones', () => { + test('uses account-local zones supplied by workshop context', () => { + const app = new App({ + context: { + agentcoreAvailabilityZones: 'us-east-1b,us-east-1d', + enableAgentRegistry: 'false', + }, + }); + const stack = new AgentStack(app, 'WorkshopAzStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + const template = Template.fromStack(stack); + const subnets = Object.values(template.findResources('AWS::EC2::Subnet')) as Array<{ + Properties: { AvailabilityZone?: string }; + }>; + + expect(subnets).toHaveLength(4); + expect(new Set(subnets.map(subnet => subnet.Properties.AvailabilityZone))).toEqual( + new Set(['us-east-1b', 'us-east-1d']), + ); + }); +}); diff --git a/manage-workshop-stack.sh b/manage-workshop-stack.sh index ecf48642..02da85f8 100755 --- a/manage-workshop-stack.sh +++ b/manage-workshop-stack.sh @@ -14,6 +14,11 @@ BLUEPRINT_REPO="${BLUEPRINT_REPO:-aws-samples/sample-abca-playground}" PARTICIPANT_USERNAME="${PARTICIPANT_USERNAME:-participant@workshop.local}" PARTICIPANT_SECRET_NAME="${PARTICIPANT_SECRET_NAME:-abca-workshop/participant-credentials}" ENABLE_AGENT_REGISTRY="${ENABLE_AGENT_REGISTRY:-false}" +AGENTCORE_SUPPORTED_ZONE_IDS="${AGENTCORE_SUPPORTED_ZONE_IDS:-}" + +if [[ -z "$AGENTCORE_SUPPORTED_ZONE_IDS" && "$REGION" == "us-east-1" ]]; then + AGENTCORE_SUPPORTED_ZONE_IDS="use1-az1,use1-az2" +fi export AWS_REGION="$REGION" export AWS_DEFAULT_REGION="$REGION" @@ -41,6 +46,43 @@ prepare_arm64_builder() { fi } +bind_cdk_environment() { + CDK_DEFAULT_ACCOUNT=$(aws sts get-caller-identity --query Account --output text) + CDK_DEFAULT_REGION="$REGION" + export CDK_DEFAULT_ACCOUNT + export CDK_DEFAULT_REGION +} + +resolve_agentcore_availability_zones() { + if [[ -z "$AGENTCORE_SUPPORTED_ZONE_IDS" ]]; then + return + fi + + local zone_id zone_name + local -a zone_ids zone_names + IFS=',' read -r -a zone_ids <<<"$AGENTCORE_SUPPORTED_ZONE_IDS" + + for zone_id in "${zone_ids[@]}"; do + zone_id="${zone_id//[[:space:]]/}" + zone_name=$(aws ec2 describe-availability-zones \ + --region "$REGION" \ + --filters \ + "Name=zone-id,Values=${zone_id}" \ + "Name=state,Values=available" \ + --query 'AvailabilityZones[0].ZoneName' \ + --output text) + + if [[ -z "$zone_name" || "$zone_name" == "None" ]]; then + echo "AgentCore availability zone ${zone_id} is unavailable in ${REGION}" >&2 + return 1 + fi + zone_names+=("$zone_name") + done + + local IFS=',' + printf '%s\n' "${zone_names[*]}" +} + stack_output() { local output_key="$1" aws cloudformation describe-stacks \ @@ -171,14 +213,25 @@ ensure_participant_user() { deploy_stack() { install_toolchain prepare_arm64_builder + bind_cdk_environment + + local agentcore_availability_zones + agentcore_availability_zones=$(resolve_agentcore_availability_zones) pushd cdk >/dev/null - npx cdk bootstrap \ - "aws://$(aws sts get-caller-identity --query Account --output text)/${REGION}" - npx cdk deploy "$STACK_NAME" \ - --require-approval never \ - --context "blueprintRepo=${BLUEPRINT_REPO}" \ + npx cdk bootstrap "aws://${CDK_DEFAULT_ACCOUNT}/${CDK_DEFAULT_REGION}" + + local -a deploy_args=( + "$STACK_NAME" + --require-approval never + --context "blueprintRepo=${BLUEPRINT_REPO}" --context "enableAgentRegistry=${ENABLE_AGENT_REGISTRY}" + ) + if [[ -n "$agentcore_availability_zones" ]]; then + deploy_args+=(--context "agentcoreAvailabilityZones=${agentcore_availability_zones}") + fi + + npx cdk deploy "${deploy_args[@]}" popd >/dev/null seed_github_token @@ -237,6 +290,7 @@ delete_bootstrap_stack() { destroy_stack() { install_toolchain + bind_cdk_environment if aws cloudformation describe-stacks \ --stack-name "$STACK_NAME" \