Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as cdk from 'aws-cdk-lib';
import { ExpectedResult, IntegTest } from '@aws-cdk/integ-tests-alpha';
import { CfnIPAM, CfnIPAMPool, IpAddresses, Subnet, Vpc } from 'aws-cdk-lib/aws-ec2';
import { EC2_RESTRICT_DEFAULT_SECURITY_GROUP } from 'aws-cdk-lib/cx-api';

/*
* Stack verification steps:
* * The subnet is created without a CidrBlock property; IPAM allocates its CIDR at deploy time
* * The assertion checks that the allocated CIDR is the first /24 of the pool's provisioned range
*
* ### MANUAL CLEAN UP REQUIRED ###
*
* As in integ.vpc-ipam.ts, the IPAM and the pool are retained after the test run and must be
* deleted manually.
*/

Comment thread
ozelalisen marked this conversation as resolved.
Outdated
const app = new cdk.App();
const stack = new cdk.Stack(app, 'aws-cdk-ec2-ipam-subnet');
stack.node.setContext(EC2_RESTRICT_DEFAULT_SECURITY_GROUP, false);

const ipam = new CfnIPAM(stack, 'IPAM', {
operatingRegions: [
{ regionName: stack.region },
],
tags: [{
key: 'stack',
value: stack.stackId,
}],
});
ipam.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);

// A VPC with a concrete CIDR and no subnets of its own
const vpc = new Vpc(stack, 'Vpc', {
ipAddresses: IpAddresses.cidr('10.0.0.0/16'),
subnetConfiguration: [],
});

// A pool that plans the VPC's address space for subnets: it provisions the VPC CIDR
const pool = new CfnIPAMPool(stack, 'Pool', {
description: 'Subnet pool for the VPC',
addressFamily: 'ipv4',
autoImport: false,
locale: stack.region,
ipamScopeId: ipam.attrPrivateDefaultScopeId,
provisionedCidrs: [{
cidr: '10.0.0.0/16',
}],
});
pool.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For subnets, IPAM allocates from a resource planning pool: a pool whose sourceResource points at the VPC (tutorial, SourceResource). Without it, the subnet is expected to fail at deploy:

const pool = new CfnIPAMPool(stack, 'Pool', {
  description: 'Resource planning pool for the VPC',
  addressFamily: 'ipv4',
  autoImport: false,
  locale: stack.region,
  ipamScopeId: ipam.attrPrivateDefaultScopeId,
  sourceResource: {
    resourceId: vpc.vpcId,
    resourceOwner: stack.account,
    resourceRegion: stack.region,
    resourceType: 'vpc',
  },
  provisionedCidrs: [{ cidr: '10.0.0.0/16' }],
});

The provisioned CIDR has to match the VPC's CIDR, and 10.0.0.0/16 already does.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the pool in the integ test is now a resource planning pool for the VPC (sourceResource with resourceType: 'vpc' and the VPC's id, account and Region). I also set tier: 'advanced' on the CfnIPAM, since the pool is in the private scope.

const subnet = new Subnet(stack, 'IpamSubnet', {
vpcId: vpc.vpcId,
availabilityZone: vpc.availabilityZones[0],
ipv4IpamAllocation: {
ipamPool: pool,
netmaskLength: 24,
},
});

const integ = new IntegTest(app, 'SubnetIpam', {
testCases: [stack],
allowDestroy: ['EC2::IPAM'],
});

// The first allocation from a fresh pool is the lowest /24 of the provisioned range
integ.assertions.awsApiCall('EC2', 'describeSubnets', {
SubnetIds: [subnet.subnetId],
}).expect(ExpectedResult.objectLike({
Subnets: [
{
CidrBlock: '10.0.0.0/24',
},
],
}));
Comment on lines +1 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Recommended — This integration test's source is added, but no committed *.snapshot/ directory ships with it (the change contains only the four source and doc files). An integration test's core value is running cdk synth and diffing against the stored Cloud Assembly snapshot (INTEGRATION_TESTS.md); without a snapshot the test does no regression detection and the change is not verified to deploy. That matters especially here because the feature exercises previously-unused CFN properties (Ipv4IpamPoolId/Ipv4NetmaskLength on AWS::EC2::Subnet) and the assertion hard-codes CidrBlock: '10.0.0.0/24', an outcome only a real deploy can confirm.

Suggested change: Deploy and record the snapshot rather than granting a no-snapshot exemption — a maintainer can run yarn integ integ.subnet-ipam.js --update-on-failed to deploy the stack and commit the resulting *.snapshot/ directory, which also confirms the hard-coded 10.0.0.0/24 allocation assumption holds.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not able to run the real deployment for this one: the test needs an IPAM in the Advanced Tier, and I don't have an account where I can run that. Following CONTRIBUTING.md ("What if you cannot run integration tests"), could a maintainer run yarn integ integ.subnet-ipam.js --update-on-failed for this PR? I haven't used --dry-run or written the snapshot by hand.

The IPAM and the pool are retained, so the cleanup is aws ec2 delete-ipam --ipam-id <ipam-id> --cascade afterwards (also in the test's header comment).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's right. The changes look good. I'll run the deploy and push the snapshot here.

Comment on lines +123 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Optional — The deploy-time assertion checks only CidrBlock: '10.0.0.0/24'. That confirms the subnet received a /24, but it relies on an IPAM implementation detail (that the first allocation from a fresh pool is the lowest /24) and never confirms the subnet is actually bound to the IPAM pool — a subnet with a hand-set 10.0.0.0/24 would pass identically. The test would be a stronger behavioral guarantee if it also proved the CIDR came from IPAM rather than coincidentally matching (INTEGRATION_TESTS.md).

Suggested change: Keep the CidrBlock check and additionally assert the IPAM association, e.g. an awsApiCall('EC2', 'getIpamResourceCidrs', ...) or describeIpamPools/pool-allocations call confirming the pool allocated the block, so the test proves the CIDR came from IPAM.

22 changes: 22 additions & 0 deletions packages/aws-cdk-lib/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,28 @@ new ec2.Vpc(this, 'TheVPC', {

With this method of IP address management, no attempt is made to guess at subnet group sizes or to exhaustively allocate the IP range. All subnet groups must have an explicit `cidrMask` set as part of their subnet configuration, or `defaultSubnetIpv4NetmaskLength` must be set for a default size. If not, synthesis will fail and you must provide one or the other.

#### Allocating a subnet CIDR from AWS IPAM

A standalone `Subnet` (or `PublicSubnet`/`PrivateSubnet`) can also have its IPv4 CIDR block allocated from an IPAM pool at deploy time instead of taking a concrete `cidrBlock`. Pass the pool and the netmask length of the block to allocate in `ipv4IpamAllocation`; exactly one of `cidrBlock` and `ipv4IpamAllocation` must be set:

```ts
declare const vpc: ec2.Vpc;
declare const pool: ec2.CfnIPAMPool;

const subnet = new ec2.Subnet(this, 'IpamSubnet', {
vpcId: vpc.vpcId,
availabilityZone: vpc.availabilityZones[0],
ipv4IpamAllocation: {
ipamPool: pool,
netmaskLength: 24,
},
});
```

The CIDR block allocated from the pool must lie within the CIDR of the VPC. To use a pool that is not defined in your CDK app (for example one shared with your account through AWS RAM), reference it by ID with `ec2.CfnIPAMPool.fromIpamPoolId(this, 'Pool', 'ipam-pool-0123456789abcdef0')`. If the pool's address space is provisioned through separate `CfnIPAMPoolCidr` resources, add a dependency from the subnet on them so the pool has space to allocate from when the subnet is created.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CIDR block allocated from the pool must lie within the CIDR of the VPC" is true, but users will trip on the pool type. It has to be a resource planning pool for this VPC, and private-scope pools need the IPAM Advanced Tier. Showing the pool in the example makes it copyable:

declare const vpc: ec2.Vpc;
declare const ipam: ec2.CfnIPAM;

const pool = new ec2.CfnIPAMPool(this, 'SubnetPool', {
  addressFamily: 'ipv4',
  ipamScopeId: ipam.attrPrivateDefaultScopeId,
  locale: this.region,
  sourceResource: {
    resourceId: vpc.vpcId,
    resourceOwner: this.account,
    resourceRegion: this.region,
    resourceType: 'vpc',
  },
  provisionedCidrs: [{ cidr: vpc.vpcCidrBlock }],
});

new ec2.Subnet(this, 'IpamSubnet', {
  vpcId: vpc.vpcId,
  availabilityZone: vpc.availabilityZones[0],
  ipv4IpamAllocation: { ipamPool: pool, netmaskLength: 24 },
});

The ipamPool JSDoc in lib/vpc.ts has the same sentence and could get the same note.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the README example now creates the resource planning pool from the VPC, and the text after it says the pool must be a resource planning pool for the subnet's VPC and that private-scope pools require the IPAM Advanced Tier. I added the same note to the ipamPool docstring in SubnetIpamAllocation.


Because the CIDR block is only known at deploy time, `subnet.ipv4CidrBlock` is a CloudFormation attribute reference rather than a concrete string. Subnet filters that parse the CIDR (`SubnetFilter.byCidrMask()`, `SubnetFilter.byCidrRanges()` and `SubnetFilter.containsIpAddresses()`) therefore cannot be used with IPAM-allocated subnets. Subnets created by `Vpc` from `subnetConfiguration` are not affected by this option; they keep getting their CIDRs from the VPC's `IpAddresses` provider.

### Dual Stack configuration

To allocate both IPv4 and IPv6 addresses in your VPC, you can configure your VPC to have a dual stack protocol.
Expand Down
61 changes: 59 additions & 2 deletions packages/aws-cdk-lib/aws-ec2/lib/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ClientVpnEndpointOptions } from './client-vpn-endpoint';
import { ClientVpnEndpoint } from './client-vpn-endpoint';
import type {
CfnVPCCidrBlock,
IIPAMPoolRef,
ISubnetRef,
IVPCRef, SubnetReference, VPCReference,
} from './ec2.generated';
Expand Down Expand Up @@ -2042,6 +2043,26 @@ function subnetTypeTagValue(type: SubnetType) {
}
}

/**
* Allocation of a subnet's IPv4 CIDR block from an Amazon VPC IP Address Manager (IPAM) pool
*/
export interface SubnetIpamAllocation {
/**
* The IPAM pool from which the CIDR block is allocated at deploy time
*
* The allocated CIDR block must lie within the CIDR of the VPC the subnet belongs to.
* To reference a pool that is not defined in this app, use `CfnIPAMPool.fromIpamPoolId()`.
*/
readonly ipamPool: IIPAMPoolRef;

/**
* The netmask length of the CIDR block to allocate from the pool
*
* Must be between 16 and 28 (inclusive), the sizes allowed for an IPv4 subnet.
*/
Comment on lines +2103 to +2107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Optional — SubnetIpamAllocation.netmaskLength is a required field, but the underlying service property Ipv4NetmaskLength is optional because an IPAM pool can carry a default allocation netmask length, so allocating from a pool without specifying a per-subnet netmask is a valid service use case this struct cannot express (aws-resource-ec2-subnet). The current required shape (matching the pre-existing AwsIpamProps.ipv4NetmaskLength) is defensible and the field is easy to widen later, so this does not block — but it is worth a deliberate decision since the prop is hard to change once released.

Suggested change: If covering the pool-default case is desired, make it readonly netmaskLength?: number; with a @default - the pool's default netmask length is used doc, letting ipv4NetmaskLength fall through as undefined.

readonly netmaskLength: number;
}

/**
* Specify configuration parameters for a VPC subnet
*/
Expand All @@ -2059,8 +2080,25 @@ export interface SubnetProps {

/**
* The CIDR notation for this subnet
*
* Exactly one of `cidrBlock` and `ipv4IpamAllocation` must be specified.
*
* @default - the CIDR block is allocated from the IPAM pool given in `ipv4IpamAllocation`
*/
readonly cidrBlock: string;
readonly cidrBlock?: string;

/**
* Allocate the IPv4 CIDR block of this subnet from an IPAM pool at deploy time
*
* When set, `ipv4CidrBlock` resolves to a deploy-time attribute of the subnet instead of a
* concrete string, so `SubnetFilter.byCidrMask()`, `SubnetFilter.byCidrRanges()` and
* `SubnetFilter.containsIpAddresses()` cannot be used with this subnet.
*
* Exactly one of `cidrBlock` and `ipv4IpamAllocation` must be specified.
*
* @default - the subnet uses the concrete `cidrBlock`
*/
readonly ipv4IpamAllocation?: SubnetIpamAllocation;

/**
* Controls if a public IP is associated to an instance at launch
Expand Down Expand Up @@ -2123,6 +2161,11 @@ export class Subnet extends Resource implements ISubnet {
public readonly availabilityZone: string;

/**
* The IPv4 CIDR block for this subnet
*
* If the subnet is allocated from an IPAM pool (`ipv4IpamAllocation`), this is a
* deploy-time attribute of the subnet rather than a concrete string.
*
* @attribute
*/
public readonly ipv4CidrBlock: string;
Expand Down Expand Up @@ -2181,20 +2224,34 @@ export class Subnet extends Resource implements ISubnet {
// Enhanced CDK Analytics Telemetry
addConstructMetadata(this, props);

if (props.cidrBlock !== undefined && props.ipv4IpamAllocation !== undefined) {
throw new ValidationError(lit`CannotSpecifyBothCidrBlockAndIpv4IpamAllocation`, 'Cannot specify both \'cidrBlock\' and \'ipv4IpamAllocation\'; supply exactly one of them', this);
}
if (props.cidrBlock === undefined && props.ipv4IpamAllocation === undefined) {
throw new ValidationError(lit`MissingCidrBlockOrIpv4IpamAllocation`, 'Either \'cidrBlock\' or \'ipv4IpamAllocation\' must be specified', this);
}
const netmaskLength = props.ipv4IpamAllocation?.netmaskLength;
if (netmaskLength !== undefined && !Token.isUnresolved(netmaskLength) && (netmaskLength < 16 || netmaskLength > 28)) {
throw new ValidationError(lit`InvalidIpv4IpamAllocationNetmaskLength`, `'ipv4IpamAllocation.netmaskLength' must be between 16 and 28, got ${netmaskLength}`, this);
}

Object.defineProperty(this, VPC_SUBNET_SYMBOL, { value: true });

Tags.of(this).add(NAME_TAG, this.node.path);

this.availabilityZone = props.availabilityZone;
this.ipv4CidrBlock = props.cidrBlock;
const subnet = new CfnSubnet(this, 'Subnet', {
vpcId: props.vpcId,
cidrBlock: props.cidrBlock,
ipv4IpamPoolId: props.ipv4IpamAllocation?.ipamPool.ipamPoolRef.ipamPoolId,
ipv4NetmaskLength: netmaskLength,
availabilityZone: props.availabilityZone,
mapPublicIpOnLaunch: props.mapPublicIpOnLaunch,
ipv6CidrBlock: props.ipv6CidrBlock,
assignIpv6AddressOnCreation: props.assignIpv6AddressOnCreation,
});
// With IPAM the CIDR block is only known at deploy time
this.ipv4CidrBlock = props.cidrBlock ?? subnet.attrCidrBlock;
this.subnetId = subnet.ref;
this.subnetVpcId = subnet.attrVpcId;
this.subnetAvailabilityZone = subnet.attrAvailabilityZone;
Expand Down
Loading
Loading