From 2e02b02023d30506b30eb4b2f23188984fa95c06 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Sat, 14 Feb 2026 17:01:13 -0800 Subject: [PATCH] Add dual-stack IPv6 VPC with egress optimization and VPC endpoints - Add Amazon-provided IPv6 /56 CIDR block with auto-carved /64 per subnet - Add Egress-Only Internet Gateway for free IPv6 outbound from private subnets - Add IPv6 routes: public subnets via IGW, private subnets via EOIGW - Add IPv6 NACL entries for subnet tier 2 - Add DynamoDB gateway endpoint (free, alongside existing S3) - Add 6 interface endpoints: ECR, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM with shared security group - Add enableIpv6 prop (default true) and interfaceEndpoints config - Update VPC stack with context params for all new features - Include design doc and implementation plan Co-Authored-By: Claude Opus 4.6 --- ...al-stack-vpc-egress-optimization-design.md | 95 +++ ...026-02-14-dual-stack-vpc-implementation.md | 649 ++++++++++++++++++ resources/lib/constructs/spicy-vpc.ts | 267 ++++++- resources/lib/stacks/spicy-vpc-stack.ts | 33 + 4 files changed, 1042 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-02-14-dual-stack-vpc-egress-optimization-design.md create mode 100644 docs/plans/2026-02-14-dual-stack-vpc-implementation.md diff --git a/docs/plans/2026-02-14-dual-stack-vpc-egress-optimization-design.md b/docs/plans/2026-02-14-dual-stack-vpc-egress-optimization-design.md new file mode 100644 index 0000000..8bdfebd --- /dev/null +++ b/docs/plans/2026-02-14-dual-stack-vpc-egress-optimization-design.md @@ -0,0 +1,95 @@ +# Dual-Stack VPC with Egress Optimization + +**Date:** 2026-02-14 +**Status:** Approved + +## Problem + +The VPC is IPv4-only. All private subnet egress routes through NAT Gateways, incurring: +- $0.045/hr per gateway (~$128/mo for 4 AZs) +- $0.045/GB data processing for all traffic through NAT + +Most of this traffic is AWS-to-AWS (ECR pulls, CloudWatch logs, S3) and could bypass NAT entirely. + +## Solution + +Add IPv6 dual-stack support and VPC endpoints to the `SpicyVpc` construct. + +### Traffic flow after changes + +``` +Private subnet egress paths (in priority order): +1. VPC Endpoints → AWS services (S3, ECR, DynamoDB, etc.) — free or ~$0.01/hr +2. EOIGW (IPv6) → IPv6-capable internet destinations — free +3. NAT Gateway → IPv4-only internet destinations — $0.045/GB (fallback only) + +Inbound to private subnets: +- ALB (public subnet) → private subnet via target group (unchanged) +- No direct internet access (private IPs + EOIGW is outbound-only) +``` + +### Security model + +Private subnets remain private. The Egress-Only Internet Gateway is a stateful firewall: +- Allows outbound IPv6 connections and their return traffic +- Drops all unsolicited inbound IPv6 traffic +- No address translation (unlike NAT) — instances use globally routable IPv6 but are not reachable from the internet + +The ALB in public subnets remains the only ingress path to private services. + +## Changes to `spicy-vpc.ts` + +### New props + +```typescript +/** Enable IPv6 dual-stack (adds Amazon-provided IPv6 CIDR, EOIGW, IPv6 routes) @default true */ +readonly enableIpv6?: boolean; + +/** VPC Interface Endpoints to create. @default full ECS set when createVpcEndpoints is true */ +readonly vpcInterfaceEndpoints?: VpcInterfaceEndpointConfig; +``` + +### Infrastructure additions + +1. **VPC IPv6 CIDR** — `CfnVPCCidrBlock` with `amazonProvidedIpv6CidrBlock: true` (assigns a /56) +2. **Subnet IPv6 CIDRs** — Each subnet gets a /64 carved from the VPC's /56, auto-calculated by AZ index +3. **Egress-Only Internet Gateway** — `CfnEgressOnlyInternetGateway` attached to the VPC +4. **Public subnet IPv6 route** — `::/0` via IGW (already exists, just needs the route) +5. **Private subnet IPv6 routes** — `::/0` via EOIGW on all private route tables +6. **DynamoDB Gateway Endpoint** — Free, added alongside existing S3 endpoint +7. **Interface Endpoints** — ECR API, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM + - Each gets a security group allowing HTTPS (443) from the VPC CIDR + - Deployed into private subnet 1 across all AZs + - Private DNS enabled (transparent to applications) + +### What does NOT change + +- NAT Gateways (kept as IPv4 fallback, unchanged config) +- Subnet IPv4 CIDRs +- All existing CloudFormation logical IDs +- ALB, ECS cluster, and ECS service constructs +- Cross-stack export names and values + +### New outputs + +- `EgressOnlyInternetGatewayId` — for cross-stack reference if needed +- `VpcIpv6CidrBlock` — the assigned /56 + +## Cost impact (4 AZ deployment) + +| Component | Before | After | +|-----------|--------|-------| +| NAT Gateway hourly | $128/mo | $128/mo (unchanged) | +| NAT data processing | $0.045/GB (all traffic) | Near zero (IPv4-only fallback) | +| S3 endpoint | Free | Free | +| DynamoDB endpoint | N/A | Free | +| Interface endpoints (6) | N/A | ~$43/mo | +| IPv6 egress | N/A | Free | + +**Net savings** depend on traffic volume. At 1 TB/mo through NAT, that's ~$45 in processing fees eliminated, roughly breaking even on endpoint costs. Above 1 TB/mo, savings scale linearly. The IPv6 egress savings are pure upside. + +## Decisions + +- **IPv6 on by default** — new stacks get dual-stack automatically; existing stacks can set `enableIpv6: false` +- **Keep NAT Gateways** — managed HA, zero ops; IPv6 + endpoints dramatically reduce their data processing costs +- **Full ECS endpoint set** — ECR, CloudWatch Logs, STS, Secrets Manager, SSM cover the common ECS workload needs diff --git a/docs/plans/2026-02-14-dual-stack-vpc-implementation.md b/docs/plans/2026-02-14-dual-stack-vpc-implementation.md new file mode 100644 index 0000000..9e730cc --- /dev/null +++ b/docs/plans/2026-02-14-dual-stack-vpc-implementation.md @@ -0,0 +1,649 @@ +# Dual-Stack VPC with Egress Optimization — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add IPv6 dual-stack support and VPC endpoints to the SpicyVpc construct to eliminate most NAT Gateway egress costs. + +**Architecture:** The VPC gets an Amazon-provided IPv6 /56 CIDR. All subnets become dual-stack (/64 each). Public subnets route IPv6 via the existing IGW. Private subnets route IPv6 via a new Egress-Only IGW (free, outbound-only). Interface VPC endpoints handle AWS service traffic without NAT. NAT Gateways remain as IPv4 fallback. + +**Tech Stack:** AWS CDK (TypeScript), L1 CfnResources (to match existing construct pattern), L2 InterfaceVpcEndpoint for interface endpoints. + +**Design doc:** `docs/plans/2026-02-14-dual-stack-vpc-egress-optimization-design.md` + +--- + +### Task 1: Add new props and interface endpoint config type + +**Files:** +- Modify: `resources/lib/constructs/spicy-vpc.ts:30-107` (SpicyVpcProps interface) + +**Step 1: Add the VpcInterfaceEndpointConfig interface and new props** + +Add after the `SpicyVpcTags` interface (line 25) and before `SpicyVpcProps`: + +```typescript +/** + * Configuration for VPC Interface Endpoints + */ +export interface VpcInterfaceEndpointConfig { + /** ECR API endpoint (for image metadata) @default true */ + readonly ecr?: boolean; + /** ECR Docker endpoint (for image layer pulls) @default true */ + readonly ecrDocker?: boolean; + /** CloudWatch Logs endpoint @default true */ + readonly cloudwatchLogs?: boolean; + /** STS endpoint (for IAM role assumption) @default true */ + readonly sts?: boolean; + /** Secrets Manager endpoint @default true */ + readonly secretsManager?: boolean; + /** SSM endpoint (for Parameter Store, Session Manager) @default true */ + readonly ssm?: boolean; +} +``` + +Add to `SpicyVpcProps` interface before the closing brace: + +```typescript + /** + * Enable IPv6 dual-stack with Egress-Only Internet Gateway. + * Adds Amazon-provided IPv6 CIDR, assigns /64 to each subnet, + * and routes private subnet IPv6 egress through EOIGW (free, outbound-only). + * @default true + */ + readonly enableIpv6?: boolean; + + /** + * Configuration for VPC Interface Endpoints. + * Only applies when createVpcEndpoints is true. + * Each endpoint costs ~$0.01/hr but eliminates NAT data processing costs for that service. + * @default All endpoints enabled (ECR, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM) + */ + readonly interfaceEndpoints?: VpcInterfaceEndpointConfig; +``` + +**Step 2: Verify TypeScript compiles** + +Run: `cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit` +Expected: Clean compilation (new props are optional, no consumers break) + +**Step 3: Commit** + +```bash +git add resources/lib/constructs/spicy-vpc.ts +git commit -m "feat(vpc): add IPv6 and interface endpoint props to SpicyVpcProps" +``` + +--- + +### Task 2: Add IPv6 CIDR block and Egress-Only Internet Gateway + +**Files:** +- Modify: `resources/lib/constructs/spicy-vpc.ts` — constructor body, after DHCP options (after line 225) + +**Step 1: Add IPv6 CIDR block and EOIGW after DHCP association** + +Insert after the `VPCDHCPOptionsAssociation` block (after line 225), before the public route table section: + +```typescript + // IPv6 Dual-Stack Configuration + const enableIpv6 = props.enableIpv6 ?? true; + let ipv6CidrBlock: ec2.CfnVPCCidrBlock | undefined; + let egressOnlyIgw: ec2.CfnEgressOnlyInternetGateway | undefined; + + if (enableIpv6) { + // Add Amazon-provided IPv6 /56 CIDR block to the VPC + ipv6CidrBlock = new ec2.CfnVPCCidrBlock(this, 'IPv6CidrBlock', { + vpcId: cfnVpc.ref, + amazonProvidedIpv6CidrBlock: true, + }); + ((ipv6CidrBlock.node.defaultChild as cdk.CfnResource) ?? ipv6CidrBlock).overrideLogicalId('IPv6CidrBlock'); + + // Egress-Only Internet Gateway (outbound IPv6 only, drops unsolicited inbound) + egressOnlyIgw = new ec2.CfnEgressOnlyInternetGateway(this, 'EgressOnlyInternetGateway', { + vpcId: cfnVpc.ref, + }); + ((egressOnlyIgw.node.defaultChild as cdk.CfnResource) ?? egressOnlyIgw).overrideLogicalId( + 'EgressOnlyInternetGateway' + ); + } +``` + +**Step 2: Verify TypeScript compiles** + +Run: `cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit` +Expected: Clean (variables declared but not yet used — that's fine, used in Task 3) + +**Step 3: Commit** + +```bash +git add resources/lib/constructs/spicy-vpc.ts +git commit -m "feat(vpc): add IPv6 CIDR block and Egress-Only Internet Gateway" +``` + +--- + +### Task 3: Add IPv6 CIDRs to subnets and IPv6 routes + +**Files:** +- Modify: `resources/lib/constructs/spicy-vpc.ts` — inside the `azLetters.forEach` loop + +This is the most complex task. We need to: +1. Add IPv6 CIDR to each subnet (public, private1, private2) +2. Add `::/0` route to public route table via IGW +3. Add `::/0` routes to private route tables via EOIGW + +**Step 1: Add IPv6 route on public route table** + +After the existing `PublicSubnetRoute` (line 244), add: + +```typescript + // Public subnet IPv6 route via IGW + if (enableIpv6) { + const publicIpv6Route = new ec2.CfnRoute(this, 'PublicSubnetIpv6Route', { + routeTableId: publicRouteTable.ref, + destinationIpv6CidrBlock: '::/0', + gatewayId: igw.ref, + }); + ((publicIpv6Route.node.defaultChild as cdk.CfnResource) ?? publicIpv6Route).overrideLogicalId( + 'PublicSubnetIpv6Route' + ); + } +``` + +**Step 2: Add IPv6 CIDR to public subnets** + +Inside the `azLetters.forEach` loop, after the public subnet `CfnSubnet` is created (after line 274 — the `overrideLogicalId` call), add: + +```typescript + // Assign IPv6 /64 to public subnet + if (enableIpv6 && ipv6CidrBlock) { + const publicIpv6Cidr = cdk.Fn.select( + index, + cdk.Fn.cidr( + cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), + 256, + '64' + ) + ); + publicSubnet.ipv6CidrBlock = publicIpv6Cidr; + publicSubnet.assignIpv6AddressOnCreation = true; + publicSubnet.addDependency(ipv6CidrBlock); + } +``` + +**Step 3: Add IPv6 CIDR to private subnet 1 and EOIGW route** + +Inside the `if (createPrivateSubnets)` block, after private subnet 1 is created and its route table association (after line 370), add: + +```typescript + // Assign IPv6 /64 to private subnet 1 (offset by numberOfAzs to avoid public subnet range) + if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) { + const private1Ipv6Cidr = cdk.Fn.select( + numberOfAzs + index, + cdk.Fn.cidr( + cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), + 256, + '64' + ) + ); + privateSubnet1.ipv6CidrBlock = private1Ipv6Cidr; + privateSubnet1.assignIpv6AddressOnCreation = true; + privateSubnet1.addDependency(ipv6CidrBlock); + + // IPv6 egress route via Egress-Only IGW + const private1Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Ipv6Route`, { + routeTableId: privateRouteTable1.ref, + destinationIpv6CidrBlock: '::/0', + egressOnlyInternetGatewayId: egressOnlyIgw.ref, + }); + ((private1Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private1Ipv6Route).overrideLogicalId( + `PrivateSubnet${azUpper}1Ipv6Route` + ); + } +``` + +**Step 4: Add IPv6 CIDR to private subnet 2 and EOIGW route** + +Inside the `if (createAdditionalPrivateSubnets && cidrs.private2Cidr)` block, after private subnet 2 route table association (after line 436), add (before the NACL section): + +```typescript + // Assign IPv6 /64 to private subnet 2 (offset by 2*numberOfAzs) + if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) { + const private2Ipv6Cidr = cdk.Fn.select( + 2 * numberOfAzs + index, + cdk.Fn.cidr( + cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), + 256, + '64' + ) + ); + privateSubnet2.ipv6CidrBlock = private2Ipv6Cidr; + privateSubnet2.assignIpv6AddressOnCreation = true; + privateSubnet2.addDependency(ipv6CidrBlock); + + // IPv6 egress route via Egress-Only IGW + const private2Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Ipv6Route`, { + routeTableId: privateRouteTable2.ref, + destinationIpv6CidrBlock: '::/0', + egressOnlyInternetGatewayId: egressOnlyIgw.ref, + }); + ((private2Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private2Ipv6Route).overrideLogicalId( + `PrivateSubnet${azUpper}2Ipv6Route` + ); + } +``` + +**Step 5: Update NACL rules to include IPv6** + +In the NACL section for private subnet 2, after the existing outbound rule (after line 472), add IPv6 NACL entries: + +```typescript + // Allow all inbound IPv6 + if (enableIpv6) { + const naclInboundIpv6 = new ec2.CfnNetworkAclEntry( + this, + `PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6`, + { + networkAclId: nacl.ref, + ruleNumber: 101, + protocol: -1, + ruleAction: 'allow', + egress: false, + ipv6CidrBlock: '::/0', + } + ); + ((naclInboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclInboundIpv6).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6` + ); + + // Allow all outbound IPv6 + const naclOutboundIpv6 = new ec2.CfnNetworkAclEntry( + this, + `PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6`, + { + networkAclId: nacl.ref, + ruleNumber: 101, + protocol: -1, + ruleAction: 'allow', + egress: true, + ipv6CidrBlock: '::/0', + } + ); + ((naclOutboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclOutboundIpv6).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6` + ); + } +``` + +**Step 6: Verify TypeScript compiles and synth** + +Run: +```bash +cd /Volumes/Engineering/Kodeniks/spicy-automation/resources +pnpm exec tsc --noEmit +pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep -E '(IPv6|EgressOnly|Ipv6)' +``` +Expected: Clean compilation. Synth output should show IPv6CidrBlock, EgressOnlyInternetGateway, and Ipv6Route resources. + +**Step 7: Commit** + +```bash +git add resources/lib/constructs/spicy-vpc.ts +git commit -m "feat(vpc): add IPv6 CIDRs to all subnets and EOIGW routes for private subnets" +``` + +--- + +### Task 4: Add DynamoDB Gateway Endpoint + +**Files:** +- Modify: `resources/lib/constructs/spicy-vpc.ts` — VPC endpoints section (after line 513) + +**Step 1: Add DynamoDB gateway endpoint** + +After the existing S3 endpoint block (after line 531), add: + +```typescript + // DynamoDB VPC Endpoint (Gateway type - free) + const dynamoDbEndpoint = new ec2.CfnVPCEndpoint(this, 'DynamoDBVPCEndpoint', { + vpcId: cfnVpc.ref, + serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.dynamodb`, + routeTableIds: privateRouteTables.map((rt) => rt.ref), + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: '*', + Effect: 'Allow', + Resource: '*', + Principal: '*', + }, + ], + }, + }); + ((dynamoDbEndpoint.node.defaultChild as cdk.CfnResource) ?? dynamoDbEndpoint).overrideLogicalId( + 'DynamoDBVPCEndpoint' + ); +``` + +**Step 2: Verify TypeScript compiles** + +Run: `cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit` + +**Step 3: Commit** + +```bash +git add resources/lib/constructs/spicy-vpc.ts +git commit -m "feat(vpc): add DynamoDB gateway VPC endpoint" +``` + +--- + +### Task 5: Add Interface VPC Endpoints + +**Files:** +- Modify: `resources/lib/constructs/spicy-vpc.ts` — after gateway endpoints section + +Interface endpoints need: +- A security group allowing HTTPS from the VPC CIDR +- Private DNS enabled +- Subnets (one per AZ in private subnet 1) + +Since we have an imported `this.vpc` (L2) and the private subnets as `ISubnet` references, we can use the L2 `InterfaceVpcEndpoint` construct here. + +**Step 1: Add interface endpoints section** + +After the DynamoDB endpoint (end of gateway endpoints section), add: + +```typescript + // Interface VPC Endpoints (cost ~$0.01/hr each, but eliminate NAT data processing) + const endpointConfig = props.interfaceEndpoints ?? {}; + + // Security group for all interface endpoints + const endpointSecurityGroup = new ec2.CfnSecurityGroup(this, 'VpcEndpointSecurityGroup', { + vpcId: cfnVpc.ref, + groupDescription: 'Security group for VPC Interface Endpoints', + securityGroupIngress: [ + { + ipProtocol: 'tcp', + fromPort: 443, + toPort: 443, + cidrIp: vpcCidr, + description: 'Allow HTTPS from VPC IPv4', + }, + ...(enableIpv6 + ? [ + { + ipProtocol: 'tcp', + fromPort: 443, + toPort: 443, + cidrIpv6: '::/0', + description: 'Allow HTTPS from VPC IPv6', + }, + ] + : []), + ], + }); + ((endpointSecurityGroup.node.defaultChild as cdk.CfnResource) ?? endpointSecurityGroup).overrideLogicalId( + 'VpcEndpointSecurityGroup' + ); + + const privateSubnetIds = Array.from(this.privateSubnets1.values()).map((s) => s.subnetId); + const region = cdk.Stack.of(this).region; + + const interfaceEndpointServices: { id: string; service: string; enabled: boolean }[] = [ + { id: 'ECRApiEndpoint', service: `com.amazonaws.${region}.ecr.api`, enabled: endpointConfig.ecr ?? true }, + { + id: 'ECRDockerEndpoint', + service: `com.amazonaws.${region}.ecr.dkr`, + enabled: endpointConfig.ecrDocker ?? true, + }, + { + id: 'CloudWatchLogsEndpoint', + service: `com.amazonaws.${region}.logs`, + enabled: endpointConfig.cloudwatchLogs ?? true, + }, + { id: 'STSEndpoint', service: `com.amazonaws.${region}.sts`, enabled: endpointConfig.sts ?? true }, + { + id: 'SecretsManagerEndpoint', + service: `com.amazonaws.${region}.secretsmanager`, + enabled: endpointConfig.secretsManager ?? true, + }, + { id: 'SSMEndpoint', service: `com.amazonaws.${region}.ssm`, enabled: endpointConfig.ssm ?? true }, + ]; + + for (const ep of interfaceEndpointServices) { + if (!ep.enabled) continue; + + const endpoint = new ec2.CfnVPCEndpoint(this, ep.id, { + vpcId: cfnVpc.ref, + serviceName: ep.service, + vpcEndpointType: 'Interface', + privateDnsEnabled: true, + subnetIds: privateSubnetIds, + securityGroupIds: [endpointSecurityGroup.ref], + }); + ((endpoint.node.defaultChild as cdk.CfnResource) ?? endpoint).overrideLogicalId(ep.id); + } +``` + +**Step 2: Verify TypeScript compiles and synth** + +Run: +```bash +cd /Volumes/Engineering/Kodeniks/spicy-automation/resources +pnpm exec tsc --noEmit +pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep -c 'VPCEndpoint' +``` +Expected: Clean compilation. Grep should show 8 VPCEndpoint resources (S3 + DynamoDB gateways + 6 interface). + +**Step 3: Commit** + +```bash +git add resources/lib/constructs/spicy-vpc.ts +git commit -m "feat(vpc): add interface VPC endpoints for ECR, CloudWatch, STS, SecretsManager, SSM" +``` + +--- + +### Task 6: Add new CloudFormation outputs and expose EOIGW + +**Files:** +- Modify: `resources/lib/constructs/spicy-vpc.ts` — class properties (line ~159) and `addOutputs` method + +**Step 1: Add new public properties** + +After the `s3Endpoint` property (line 159), add: + +```typescript + /** Egress-Only Internet Gateway (IPv6 outbound from private subnets) */ + public readonly egressOnlyInternetGateway?: ec2.CfnEgressOnlyInternetGateway; +``` + +**Step 2: Assign EOIGW to the property** + +In the constructor, where `egressOnlyIgw` is created (Task 2), after creating the EOIGW, assign it: + +```typescript + this.egressOnlyInternetGateway = egressOnlyIgw; +``` + +**Step 3: Update addOutputs signature and add IPv6 outputs** + +Update the `addOutputs` method to accept the new parameters. Add `enableIpv6` and `cfnVpc` to the method signature: + +Change the method signature to: +```typescript + private addOutputs( + cfnVpc: ec2.CfnVPC, + azLetters: string[], + publicRouteTable: ec2.CfnRouteTable, + privateRouteTables: ec2.CfnRouteTable[], + createPrivateSubnets: boolean, + createAdditionalPrivateSubnets: boolean, + enableIpv6: boolean + ): void { +``` + +At the end of the `addOutputs` method, before the closing brace, add: + +```typescript + // IPv6 outputs + if (enableIpv6 && this.egressOnlyInternetGateway) { + const eoigwOutput = new cdk.CfnOutput(this, 'EgressOnlyInternetGatewayId', { + value: this.egressOnlyInternetGateway.ref, + description: 'Egress-Only Internet Gateway ID', + exportName: `${stack.stackName}-EgressOnlyIGW`, + }); + eoigwOutput.overrideLogicalId('EgressOnlyInternetGatewayId'); + } +``` + +**Step 4: Update the addOutputs call site** + +Update the `addOutputs` call in the constructor to pass the new parameter: + +```typescript + this.addOutputs( + cfnVpc, + azLetters, + publicRouteTable, + privateRouteTables, + createPrivateSubnets, + createAdditionalPrivateSubnets, + enableIpv6 + ); +``` + +**Step 5: Verify TypeScript compiles and synth** + +Run: +```bash +cd /Volumes/Engineering/Kodeniks/spicy-automation/resources +pnpm exec tsc --noEmit +pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep 'EgressOnly' +``` +Expected: Clean compilation. Output should show the EOIGW output. + +**Step 6: Commit** + +```bash +git add resources/lib/constructs/spicy-vpc.ts +git commit -m "feat(vpc): add EOIGW property and IPv6 CloudFormation outputs" +``` + +--- + +### Task 7: Update VPC stack to pass new context params + +**Files:** +- Modify: `resources/lib/stacks/spicy-vpc-stack.ts` — `fromContext` method + +**Step 1: Add context parsing for new props** + +In the `fromContext` method, after the existing context parsing (around line 88), add: + +```typescript + const enableIpv6 = app.node.tryGetContext('enableIpv6') as string | undefined; + + // Interface endpoint overrides + const disableEcrEndpoint = app.node.tryGetContext('disableEcrEndpoint') as string | undefined; + const disableEcrDockerEndpoint = app.node.tryGetContext('disableEcrDockerEndpoint') as string | undefined; + const disableCloudwatchLogsEndpoint = app.node.tryGetContext('disableCloudwatchLogsEndpoint') as string | undefined; + const disableStsEndpoint = app.node.tryGetContext('disableStsEndpoint') as string | undefined; + const disableSecretsManagerEndpoint = app.node.tryGetContext('disableSecretsManagerEndpoint') as string | undefined; + const disableSsmEndpoint = app.node.tryGetContext('disableSsmEndpoint') as string | undefined; +``` + +In the `vpcConfigBuilder` type, add: + +```typescript + enableIpv6?: boolean; + interfaceEndpoints?: SpicyVpcProps['interfaceEndpoints']; +``` + +After the existing config assignments (around line 124), add: + +```typescript + if (enableIpv6 !== undefined) { + vpcConfigBuilder.enableIpv6 = enableIpv6 !== 'false'; + } + + // Build interface endpoint config if any overrides provided + if ( + disableEcrEndpoint || + disableEcrDockerEndpoint || + disableCloudwatchLogsEndpoint || + disableStsEndpoint || + disableSecretsManagerEndpoint || + disableSsmEndpoint + ) { + vpcConfigBuilder.interfaceEndpoints = { + ecr: disableEcrEndpoint !== 'true', + ecrDocker: disableEcrDockerEndpoint !== 'true', + cloudwatchLogs: disableCloudwatchLogsEndpoint !== 'true', + sts: disableStsEndpoint !== 'true', + secretsManager: disableSecretsManagerEndpoint !== 'true', + ssm: disableSsmEndpoint !== 'true', + }; + } +``` + +**Step 2: Add the SpicyVpcProps import if not already imported** + +The file already imports `SpicyVpc` and `SpicyVpcProps` on line 3 — verify this covers the new `VpcInterfaceEndpointConfig` type (it doesn't need to be imported separately since it's referenced via `SpicyVpcProps['interfaceEndpoints']`). + +**Step 3: Verify TypeScript compiles** + +Run: `cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit` + +**Step 4: Commit** + +```bash +git add resources/lib/stacks/spicy-vpc-stack.ts +git commit -m "feat(vpc): add IPv6 and endpoint context params to VPC stack" +``` + +--- + +### Task 8: Full synth verification and final commit + +**Step 1: Run full type check** + +Run: `cd /Volumes/Engineering/Kodeniks/spicy-automation/resources && pnpm exec tsc --noEmit` +Expected: Clean + +**Step 2: Synth VPC stack and verify all resources** + +Run: +```bash +cd /Volumes/Engineering/Kodeniks/spicy-automation/resources +pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c 2>&1 | grep -E 'Type.*AWS::' +``` +Expected: Should include: +- `AWS::EC2::VPCCidrBlock` (IPv6CidrBlock) +- `AWS::EC2::EgressOnlyInternetGateway` +- `AWS::EC2::Route` entries for IPv6 (`::/0`) +- `AWS::EC2::VPCEndpoint` (8 total: S3, DynamoDB, ECR, ECR Docker, Logs, STS, SecretsManager, SSM) +- `AWS::EC2::SecurityGroup` for endpoints + +**Step 3: Synth VPC with IPv6 disabled (regression check)** + +Run: +```bash +pnpm exec cdk synth -c stackType=vpc -c stackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c -c enableIpv6=false 2>&1 | grep -c 'IPv6\|EgressOnly' +``` +Expected: 0 matches (no IPv6 resources when disabled) + +**Step 4: Synth ECS cluster stack to verify no regressions** + +Run: +```bash +pnpm exec cdk synth -c stackType=ecs-cluster -c stackName=test -c vpcStackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c -c environment=d -c numberOfAzs=2 2>&1 | tail -5 +``` +Expected: Clean synth, no errors + +**Step 5: Verify all changes are clean** + +Run: `git diff --stat` +Review that only `spicy-vpc.ts` and `spicy-vpc-stack.ts` were modified. diff --git a/resources/lib/constructs/spicy-vpc.ts b/resources/lib/constructs/spicy-vpc.ts index 4dc2360..1be4810 100644 --- a/resources/lib/constructs/spicy-vpc.ts +++ b/resources/lib/constructs/spicy-vpc.ts @@ -24,6 +24,24 @@ export interface SpicyVpcTags { build?: string; } +/** + * Configuration for VPC Interface Endpoints + */ +export interface VpcInterfaceEndpointConfig { + /** ECR API endpoint (for image metadata) @default true */ + readonly ecr?: boolean; + /** ECR Docker endpoint (for image layer pulls) @default true */ + readonly ecrDocker?: boolean; + /** CloudWatch Logs endpoint @default true */ + readonly cloudwatchLogs?: boolean; + /** STS endpoint (for IAM role assumption) @default true */ + readonly sts?: boolean; + /** Secrets Manager endpoint @default true */ + readonly secretsManager?: boolean; + /** SSM endpoint (for Parameter Store, Session Manager) @default true */ + readonly ssm?: boolean; +} + /** * Properties for the SpicyVpc construct */ @@ -104,6 +122,22 @@ export interface SpicyVpcProps { * @default true */ readonly createVpcEndpoints?: boolean; + + /** + * Enable IPv6 dual-stack with Egress-Only Internet Gateway. + * Adds Amazon-provided IPv6 CIDR, assigns /64 to each subnet, + * and routes private subnet IPv6 egress through EOIGW (free, outbound-only). + * @default true + */ + readonly enableIpv6?: boolean; + + /** + * Configuration for VPC Interface Endpoints. + * Only applies when createVpcEndpoints is true. + * Each endpoint costs ~$0.01/hr but eliminates NAT data processing costs for that service. + * @default All endpoints enabled (ECR, ECR Docker, CloudWatch Logs, STS, Secrets Manager, SSM) + */ + readonly interfaceEndpoints?: VpcInterfaceEndpointConfig; } /** @@ -158,6 +192,9 @@ export class SpicyVpc extends Construct { /** S3 Gateway Endpoint */ public readonly s3Endpoint?: ec2.GatewayVpcEndpoint; + /** Egress-Only Internet Gateway (IPv6 outbound from private subnets) */ + public readonly egressOnlyInternetGateway?: ec2.CfnEgressOnlyInternetGateway; + constructor(scope: Construct, id: string, props: SpicyVpcProps) { super(scope, id); @@ -224,6 +261,29 @@ export class SpicyVpc extends Construct { 'VPCDHCPOptionsAssociation' ); + // IPv6 Dual-Stack Configuration + const enableIpv6 = props.enableIpv6 ?? true; + let ipv6CidrBlock: ec2.CfnVPCCidrBlock | undefined; + let egressOnlyIgw: ec2.CfnEgressOnlyInternetGateway | undefined; + + if (enableIpv6) { + // Add Amazon-provided IPv6 /56 CIDR block to the VPC + ipv6CidrBlock = new ec2.CfnVPCCidrBlock(this, 'IPv6CidrBlock', { + vpcId: cfnVpc.ref, + amazonProvidedIpv6CidrBlock: true, + }); + ((ipv6CidrBlock.node.defaultChild as cdk.CfnResource) ?? ipv6CidrBlock).overrideLogicalId('IPv6CidrBlock'); + + // Egress-Only Internet Gateway (outbound IPv6 only, drops unsolicited inbound) + egressOnlyIgw = new ec2.CfnEgressOnlyInternetGateway(this, 'EgressOnlyInternetGateway', { + vpcId: cfnVpc.ref, + }); + ((egressOnlyIgw.node.defaultChild as cdk.CfnResource) ?? egressOnlyIgw).overrideLogicalId( + 'EgressOnlyInternetGateway' + ); + this.egressOnlyInternetGateway = egressOnlyIgw; + } + // Public Route Table (shared across all public subnets) const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', { vpcId: cfnVpc.ref, @@ -243,6 +303,18 @@ export class SpicyVpc extends Construct { }); ((publicRoute.node.defaultChild as cdk.CfnResource) ?? publicRoute).overrideLogicalId('PublicSubnetRoute'); + // Public subnet IPv6 route via IGW + if (enableIpv6) { + const publicIpv6Route = new ec2.CfnRoute(this, 'PublicSubnetIpv6Route', { + routeTableId: publicRouteTable.ref, + destinationIpv6CidrBlock: '::/0', + gatewayId: igw.ref, + }); + ((publicIpv6Route.node.defaultChild as cdk.CfnResource) ?? publicIpv6Route).overrideLogicalId( + 'PublicSubnetIpv6Route' + ); + } + // Get AZ letters based on numberOfAzs const azLetters = ['a', 'b', 'c', 'd'].slice(0, numberOfAzs); @@ -274,6 +346,17 @@ export class SpicyVpc extends Construct { }); ((publicSubnet.node.defaultChild as cdk.CfnResource) ?? publicSubnet).overrideLogicalId(`PublicSubnet${azUpper}`); + // Assign IPv6 /64 to public subnet + if (enableIpv6 && ipv6CidrBlock) { + const publicIpv6Cidr = cdk.Fn.select( + index, + cdk.Fn.cidr(cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), 256, '64') + ); + publicSubnet.ipv6CidrBlock = publicIpv6Cidr; + publicSubnet.assignIpv6AddressOnCreation = true; + publicSubnet.addDependency(ipv6CidrBlock); + } + const publicSubnetRtAssoc = new ec2.CfnSubnetRouteTableAssociation( this, `PublicSubnet${azUpper}RouteTableAssociation`, @@ -369,6 +452,27 @@ export class SpicyVpc extends Construct { `PrivateSubnet${azUpper}1RouteTableAssociation` ); + // Assign IPv6 /64 to private subnet 1 (offset by numberOfAzs to avoid public subnet range) + if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) { + const private1Ipv6Cidr = cdk.Fn.select( + numberOfAzs + index, + cdk.Fn.cidr(cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), 256, '64') + ); + privateSubnet1.ipv6CidrBlock = private1Ipv6Cidr; + privateSubnet1.assignIpv6AddressOnCreation = true; + privateSubnet1.addDependency(ipv6CidrBlock); + + // IPv6 egress route via Egress-Only IGW + const private1Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Ipv6Route`, { + routeTableId: privateRouteTable1.ref, + destinationIpv6CidrBlock: '::/0', + egressOnlyInternetGatewayId: egressOnlyIgw.ref, + }); + ((private1Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private1Ipv6Route).overrideLogicalId( + `PrivateSubnet${azUpper}1Ipv6Route` + ); + } + this.privateSubnets1.set( azLetter, ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, { @@ -435,6 +539,27 @@ export class SpicyVpc extends Construct { `PrivateSubnet${azUpper}2RouteTableAssociation` ); + // Assign IPv6 /64 to private subnet 2 (offset by 2*numberOfAzs) + if (enableIpv6 && ipv6CidrBlock && egressOnlyIgw) { + const private2Ipv6Cidr = cdk.Fn.select( + 2 * numberOfAzs + index, + cdk.Fn.cidr(cdk.Fn.select(0, cfnVpc.attrIpv6CidrBlocks), 256, '64') + ); + privateSubnet2.ipv6CidrBlock = private2Ipv6Cidr; + privateSubnet2.assignIpv6AddressOnCreation = true; + privateSubnet2.addDependency(ipv6CidrBlock); + + // IPv6 egress route via Egress-Only IGW + const private2Ipv6Route = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Ipv6Route`, { + routeTableId: privateRouteTable2.ref, + destinationIpv6CidrBlock: '::/0', + egressOnlyInternetGatewayId: egressOnlyIgw.ref, + }); + ((private2Ipv6Route.node.defaultChild as cdk.CfnResource) ?? private2Ipv6Route).overrideLogicalId( + `PrivateSubnet${azUpper}2Ipv6Route` + ); + } + // Network ACL for Private Subnet 2 const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, { vpcId: cfnVpc.ref, @@ -471,6 +596,41 @@ export class SpicyVpc extends Construct { `PrivateSubnet${azUpper}2NetworkACLEntryOutbound` ); + // IPv6 NACL entries + if (enableIpv6) { + const naclInboundIpv6 = new ec2.CfnNetworkAclEntry( + this, + `PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6`, + { + networkAclId: nacl.ref, + ruleNumber: 101, + protocol: -1, + ruleAction: 'allow', + egress: false, + ipv6CidrBlock: '::/0', + } + ); + ((naclInboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclInboundIpv6).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLEntryInboundIpv6` + ); + + const naclOutboundIpv6 = new ec2.CfnNetworkAclEntry( + this, + `PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6`, + { + networkAclId: nacl.ref, + ruleNumber: 101, + protocol: -1, + ruleAction: 'allow', + egress: true, + ipv6CidrBlock: '::/0', + } + ); + ((naclOutboundIpv6.node.defaultChild as cdk.CfnResource) ?? naclOutboundIpv6).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLEntryOutboundIpv6` + ); + } + const naclAssociation = new ec2.CfnSubnetNetworkAclAssociation( this, `PrivateSubnet${azUpper}2NetworkACLAssociation`, @@ -528,6 +688,97 @@ export class SpicyVpc extends Construct { }, }); ((s3Endpoint.node.defaultChild as cdk.CfnResource) ?? s3Endpoint).overrideLogicalId('S3VPCEndpoint'); + + // DynamoDB VPC Endpoint (Gateway type - free) + const dynamoDbEndpoint = new ec2.CfnVPCEndpoint(this, 'DynamoDBVPCEndpoint', { + vpcId: cfnVpc.ref, + serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.dynamodb`, + routeTableIds: privateRouteTables.map((rt) => rt.ref), + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: '*', + Effect: 'Allow', + Resource: '*', + Principal: '*', + }, + ], + }, + }); + ((dynamoDbEndpoint.node.defaultChild as cdk.CfnResource) ?? dynamoDbEndpoint).overrideLogicalId( + 'DynamoDBVPCEndpoint' + ); + + // Interface VPC Endpoints (cost ~$0.01/hr each, but eliminate NAT data processing) + const endpointConfig = props.interfaceEndpoints ?? {}; + + // Security group for all interface endpoints + const endpointSecurityGroup = new ec2.CfnSecurityGroup(this, 'VpcEndpointSecurityGroup', { + vpcId: cfnVpc.ref, + groupDescription: 'Security group for VPC Interface Endpoints', + securityGroupIngress: [ + { + ipProtocol: 'tcp', + fromPort: 443, + toPort: 443, + cidrIp: vpcCidr, + description: 'Allow HTTPS from VPC IPv4', + }, + ...(enableIpv6 + ? [ + { + ipProtocol: 'tcp', + fromPort: 443, + toPort: 443, + cidrIpv6: '::/0', + description: 'Allow HTTPS from VPC IPv6', + }, + ] + : []), + ], + }); + ((endpointSecurityGroup.node.defaultChild as cdk.CfnResource) ?? endpointSecurityGroup).overrideLogicalId( + 'VpcEndpointSecurityGroup' + ); + + const privateSubnetIds = Array.from(this.privateSubnets1.values()).map((s) => s.subnetId); + const region = cdk.Stack.of(this).region; + + const interfaceEndpointServices: { id: string; service: string; enabled: boolean }[] = [ + { id: 'ECRApiEndpoint', service: `com.amazonaws.${region}.ecr.api`, enabled: endpointConfig.ecr ?? true }, + { + id: 'ECRDockerEndpoint', + service: `com.amazonaws.${region}.ecr.dkr`, + enabled: endpointConfig.ecrDocker ?? true, + }, + { + id: 'CloudWatchLogsEndpoint', + service: `com.amazonaws.${region}.logs`, + enabled: endpointConfig.cloudwatchLogs ?? true, + }, + { id: 'STSEndpoint', service: `com.amazonaws.${region}.sts`, enabled: endpointConfig.sts ?? true }, + { + id: 'SecretsManagerEndpoint', + service: `com.amazonaws.${region}.secretsmanager`, + enabled: endpointConfig.secretsManager ?? true, + }, + { id: 'SSMEndpoint', service: `com.amazonaws.${region}.ssm`, enabled: endpointConfig.ssm ?? true }, + ]; + + for (const ep of interfaceEndpointServices) { + if (!ep.enabled) continue; + + const endpoint = new ec2.CfnVPCEndpoint(this, ep.id, { + vpcId: cfnVpc.ref, + serviceName: ep.service, + vpcEndpointType: 'Interface', + privateDnsEnabled: true, + subnetIds: privateSubnetIds, + securityGroupIds: [endpointSecurityGroup.ref], + }); + ((endpoint.node.defaultChild as cdk.CfnResource) ?? endpoint).overrideLogicalId(ep.id); + } } // Add CloudFormation Outputs matching the original template @@ -537,7 +788,8 @@ export class SpicyVpc extends Construct { publicRouteTable, privateRouteTables, createPrivateSubnets, - createAdditionalPrivateSubnets + createAdditionalPrivateSubnets, + enableIpv6 ); } @@ -562,7 +814,8 @@ export class SpicyVpc extends Construct { publicRouteTable: ec2.CfnRouteTable, privateRouteTables: ec2.CfnRouteTable[], createPrivateSubnets: boolean, - createAdditionalPrivateSubnets: boolean + createAdditionalPrivateSubnets: boolean, + enableIpv6: boolean ): void { const stack = cdk.Stack.of(this); @@ -673,5 +926,15 @@ export class SpicyVpc extends Construct { } }); } + + // IPv6 outputs + if (enableIpv6 && this.egressOnlyInternetGateway) { + const eoigwOutput = new cdk.CfnOutput(this, 'EgressOnlyInternetGatewayId', { + value: this.egressOnlyInternetGateway.ref, + description: 'Egress-Only Internet Gateway ID', + exportName: `${stack.stackName}-EgressOnlyIGW`, + }); + eoigwOutput.overrideLogicalId('EgressOnlyInternetGatewayId'); + } } } diff --git a/resources/lib/stacks/spicy-vpc-stack.ts b/resources/lib/stacks/spicy-vpc-stack.ts index 85f2cb5..11e4dca 100644 --- a/resources/lib/stacks/spicy-vpc-stack.ts +++ b/resources/lib/stacks/spicy-vpc-stack.ts @@ -86,6 +86,15 @@ export class SpicyVpcStack extends cdk.Stack { const publicSubnetTag = app.node.tryGetContext('publicSubnetTag') as string | undefined; const privateSubnetATag = app.node.tryGetContext('privateSubnetATag') as string | undefined; const privateSubnetBTag = app.node.tryGetContext('privateSubnetBTag') as string | undefined; + const enableIpv6 = app.node.tryGetContext('enableIpv6') as string | undefined; + + // Interface endpoint overrides + const disableEcrEndpoint = app.node.tryGetContext('disableEcrEndpoint') as string | undefined; + const disableEcrDockerEndpoint = app.node.tryGetContext('disableEcrDockerEndpoint') as string | undefined; + const disableCloudwatchLogsEndpoint = app.node.tryGetContext('disableCloudwatchLogsEndpoint') as string | undefined; + const disableStsEndpoint = app.node.tryGetContext('disableStsEndpoint') as string | undefined; + const disableSecretsManagerEndpoint = app.node.tryGetContext('disableSecretsManagerEndpoint') as string | undefined; + const disableSsmEndpoint = app.node.tryGetContext('disableSsmEndpoint') as string | undefined; // Parse subnet CIDRs from context const subnetCidrs = SpicyVpcStack.parseSubnetCidrsFromContext(app); @@ -102,6 +111,8 @@ export class SpicyVpcStack extends cdk.Stack { privateSubnetATag?: string; privateSubnetBTag?: string; subnetCidrs?: SpicyVpcProps['subnetCidrs']; + enableIpv6?: boolean; + interfaceEndpoints?: SpicyVpcProps['interfaceEndpoints']; } = {}; if (vpcCidr) vpcConfigBuilder.vpcCidr = vpcCidr; @@ -122,6 +133,28 @@ export class SpicyVpcStack extends cdk.Stack { if (subnetCidrs && Object.keys(subnetCidrs).length > 0) { vpcConfigBuilder.subnetCidrs = subnetCidrs; } + if (enableIpv6 !== undefined) { + vpcConfigBuilder.enableIpv6 = enableIpv6 !== 'false'; + } + + // Build interface endpoint config if any overrides provided + if ( + disableEcrEndpoint || + disableEcrDockerEndpoint || + disableCloudwatchLogsEndpoint || + disableStsEndpoint || + disableSecretsManagerEndpoint || + disableSsmEndpoint + ) { + vpcConfigBuilder.interfaceEndpoints = { + ecr: disableEcrEndpoint !== 'true', + ecrDocker: disableEcrDockerEndpoint !== 'true', + cloudwatchLogs: disableCloudwatchLogsEndpoint !== 'true', + sts: disableStsEndpoint !== 'true', + secretsManager: disableSecretsManagerEndpoint !== 'true', + ssm: disableSsmEndpoint !== 'true', + }; + } return new SpicyVpcStack(scope, id, { ...stackProps,