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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
649
docs/plans/2026-02-14-dual-stack-vpc-implementation.md
Normal file
649
docs/plans/2026-02-14-dual-stack-vpc-implementation.md
Normal file
@@ -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.
|
||||||
@@ -24,6 +24,24 @@ export interface SpicyVpcTags {
|
|||||||
build?: string;
|
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
|
* Properties for the SpicyVpc construct
|
||||||
*/
|
*/
|
||||||
@@ -104,6 +122,22 @@ export interface SpicyVpcProps {
|
|||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
readonly createVpcEndpoints?: boolean;
|
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 */
|
/** S3 Gateway Endpoint */
|
||||||
public readonly s3Endpoint?: ec2.GatewayVpcEndpoint;
|
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) {
|
constructor(scope: Construct, id: string, props: SpicyVpcProps) {
|
||||||
super(scope, id);
|
super(scope, id);
|
||||||
|
|
||||||
@@ -224,6 +261,29 @@ export class SpicyVpc extends Construct {
|
|||||||
'VPCDHCPOptionsAssociation'
|
'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)
|
// Public Route Table (shared across all public subnets)
|
||||||
const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', {
|
const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', {
|
||||||
vpcId: cfnVpc.ref,
|
vpcId: cfnVpc.ref,
|
||||||
@@ -243,6 +303,18 @@ export class SpicyVpc extends Construct {
|
|||||||
});
|
});
|
||||||
((publicRoute.node.defaultChild as cdk.CfnResource) ?? publicRoute).overrideLogicalId('PublicSubnetRoute');
|
((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
|
// Get AZ letters based on numberOfAzs
|
||||||
const azLetters = ['a', 'b', 'c', 'd'].slice(0, 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}`);
|
((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(
|
const publicSubnetRtAssoc = new ec2.CfnSubnetRouteTableAssociation(
|
||||||
this,
|
this,
|
||||||
`PublicSubnet${azUpper}RouteTableAssociation`,
|
`PublicSubnet${azUpper}RouteTableAssociation`,
|
||||||
@@ -369,6 +452,27 @@ export class SpicyVpc extends Construct {
|
|||||||
`PrivateSubnet${azUpper}1RouteTableAssociation`
|
`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(
|
this.privateSubnets1.set(
|
||||||
azLetter,
|
azLetter,
|
||||||
ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, {
|
ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, {
|
||||||
@@ -435,6 +539,27 @@ export class SpicyVpc extends Construct {
|
|||||||
`PrivateSubnet${azUpper}2RouteTableAssociation`
|
`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
|
// Network ACL for Private Subnet 2
|
||||||
const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, {
|
const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, {
|
||||||
vpcId: cfnVpc.ref,
|
vpcId: cfnVpc.ref,
|
||||||
@@ -471,6 +596,41 @@ export class SpicyVpc extends Construct {
|
|||||||
`PrivateSubnet${azUpper}2NetworkACLEntryOutbound`
|
`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(
|
const naclAssociation = new ec2.CfnSubnetNetworkAclAssociation(
|
||||||
this,
|
this,
|
||||||
`PrivateSubnet${azUpper}2NetworkACLAssociation`,
|
`PrivateSubnet${azUpper}2NetworkACLAssociation`,
|
||||||
@@ -528,6 +688,97 @@ export class SpicyVpc extends Construct {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
((s3Endpoint.node.defaultChild as cdk.CfnResource) ?? s3Endpoint).overrideLogicalId('S3VPCEndpoint');
|
((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
|
// Add CloudFormation Outputs matching the original template
|
||||||
@@ -537,7 +788,8 @@ export class SpicyVpc extends Construct {
|
|||||||
publicRouteTable,
|
publicRouteTable,
|
||||||
privateRouteTables,
|
privateRouteTables,
|
||||||
createPrivateSubnets,
|
createPrivateSubnets,
|
||||||
createAdditionalPrivateSubnets
|
createAdditionalPrivateSubnets,
|
||||||
|
enableIpv6
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +814,8 @@ export class SpicyVpc extends Construct {
|
|||||||
publicRouteTable: ec2.CfnRouteTable,
|
publicRouteTable: ec2.CfnRouteTable,
|
||||||
privateRouteTables: ec2.CfnRouteTable[],
|
privateRouteTables: ec2.CfnRouteTable[],
|
||||||
createPrivateSubnets: boolean,
|
createPrivateSubnets: boolean,
|
||||||
createAdditionalPrivateSubnets: boolean
|
createAdditionalPrivateSubnets: boolean,
|
||||||
|
enableIpv6: boolean
|
||||||
): void {
|
): void {
|
||||||
const stack = cdk.Stack.of(this);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ export class SpicyVpcStack extends cdk.Stack {
|
|||||||
const publicSubnetTag = app.node.tryGetContext('publicSubnetTag') as string | undefined;
|
const publicSubnetTag = app.node.tryGetContext('publicSubnetTag') as string | undefined;
|
||||||
const privateSubnetATag = app.node.tryGetContext('privateSubnetATag') as string | undefined;
|
const privateSubnetATag = app.node.tryGetContext('privateSubnetATag') as string | undefined;
|
||||||
const privateSubnetBTag = app.node.tryGetContext('privateSubnetBTag') 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
|
// Parse subnet CIDRs from context
|
||||||
const subnetCidrs = SpicyVpcStack.parseSubnetCidrsFromContext(app);
|
const subnetCidrs = SpicyVpcStack.parseSubnetCidrsFromContext(app);
|
||||||
@@ -102,6 +111,8 @@ export class SpicyVpcStack extends cdk.Stack {
|
|||||||
privateSubnetATag?: string;
|
privateSubnetATag?: string;
|
||||||
privateSubnetBTag?: string;
|
privateSubnetBTag?: string;
|
||||||
subnetCidrs?: SpicyVpcProps['subnetCidrs'];
|
subnetCidrs?: SpicyVpcProps['subnetCidrs'];
|
||||||
|
enableIpv6?: boolean;
|
||||||
|
interfaceEndpoints?: SpicyVpcProps['interfaceEndpoints'];
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (vpcCidr) vpcConfigBuilder.vpcCidr = vpcCidr;
|
if (vpcCidr) vpcConfigBuilder.vpcCidr = vpcCidr;
|
||||||
@@ -122,6 +133,28 @@ export class SpicyVpcStack extends cdk.Stack {
|
|||||||
if (subnetCidrs && Object.keys(subnetCidrs).length > 0) {
|
if (subnetCidrs && Object.keys(subnetCidrs).length > 0) {
|
||||||
vpcConfigBuilder.subnetCidrs = subnetCidrs;
|
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, {
|
return new SpicyVpcStack(scope, id, {
|
||||||
...stackProps,
|
...stackProps,
|
||||||
|
|||||||
Reference in New Issue
Block a user