Skip to content

Commit b805061

Browse files
authored
Permit aws.ec2.Vpc to use IPAM-allocated cidrBlock ranges (#1352)
Fixes issues with supporting the ipv4IpamPoolId parameter that ties a VPC to an IPAM pool. You should now be able to write the following to allows IPAM to allocate and manage a cidrBlock range. The VPC component now uses that dynamically allocated block to automatically configure subnets. ```typescript new awsx.ec2.Vpc("myVpc", { ipv4IpamPoolId: myVpcIpamPool.id, ipv4NetmaskLength: 24, subnetStrategy: "Auto", }); ``` It is also possible to constrain the allocated subnets with subnetSpecs, while still using IPAM to manage the overall cidrBlock range: ```typescript new awsx.ec2.Vpc("myVpc", { numberOfAvailabilityZones: 3, subnetStrategy: "Auto", ipv4IpamPoolId: myVpcIpamPool.id, ipv4NetmaskLength: 22, subnetSpecs: [ { type: "Private", name: "private", cidrMask: 25, }, { type: "Public", name: "public", cidrMask: 27, }, ], tags: tags, }); ``` Fixes #872 Note that `subnetStrategy: "Auto"` is required with this functionality, and "Legacy" strategy is not supported.
1 parent 44d871d commit b805061

22 files changed

+813
-183
lines changed

awsx/ec2/subnetDistributorLegacy.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515
import fc from "fast-check";
1616
import { SubnetSpecInputs, SubnetTypeInputs } from "../schema-types";
17-
import { getSubnetSpecsLegacy, SubnetSpec, validateRanges } from "./subnetDistributorLegacy";
17+
import { getSubnetSpecsLegacy, validateRanges } from "./subnetDistributorLegacy";
18+
import { SubnetSpec } from "./subnetSpecs";
1819
import { knownWorkingSubnets } from "./knownWorkingSubnets";
1920
import { extractSubnetSpecInputFromLegacyLayout } from "./vpc";
2021
import { getSubnetSpecs } from "./subnetDistributorNew";

awsx/ec2/subnetDistributorLegacy.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,7 @@ import * as pulumi from "@pulumi/pulumi";
1919
import { SubnetSpecInputs, SubnetTypeInputs } from "../schema-types";
2020
import * as ipAddress from "ip-address";
2121
import { BigInteger } from "jsbn";
22-
23-
export interface SubnetSpec {
24-
cidrBlock: string;
25-
type: SubnetTypeInputs;
26-
azName: string;
27-
subnetName: string;
28-
tags?: pulumi.Input<{
29-
[key: string]: pulumi.Input<string>;
30-
}>;
31-
}
22+
import { SubnetSpec } from "./subnetSpecs";
3223

3324
export function getSubnetSpecsLegacy(
3425
vpcName: string,

awsx/ec2/subnetDistributorNew.test.ts

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { Netmask } from "netmask";
2626
import { getOverlappingSubnets, validateNoGaps, validateSubnets } from "./vpc";
2727
import { getSubnetSpecsLegacy } from "./subnetDistributorLegacy";
28+
import { validatePartialSubnetSpecs } from "./subnetSpecs";
2829

2930
function cidrMask(args?: { min?: number; max?: number }): fc.Arbitrary<number> {
3031
return fc.integer({ min: args?.min ?? 16, max: args?.max ?? 27 });
@@ -54,24 +55,24 @@ describe("default subnet layout", () => {
5455
it.each([16, 17, 18, 19, 20, 21, 22, 23, 24])(
5556
"/%i AZ creates single private & public with staggered sizes",
5657
(azCidrMask) => {
57-
expect(getDefaultSubnetSizes(azCidrMask)).toMatchObject([
58-
{
59-
type: "Private",
60-
cidrMask: azCidrMask + 1,
61-
},
62-
{
63-
type: "Public",
64-
cidrMask: azCidrMask + 2,
65-
},
66-
]);
58+
const vpcCidr = `10.0.0.0/${azCidrMask}`;
59+
const result = getSubnetSpecs("vpcName", vpcCidr, ["us-east-1a"], undefined);
60+
61+
validatePartialSubnetSpecs(result, (ss) => {
62+
const x = ss.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
63+
expect(x).toMatchObject([
64+
{
65+
type: "Private",
66+
cidrMask: azCidrMask + 1,
67+
},
68+
{
69+
type: "Public",
70+
cidrMask: azCidrMask + 2,
71+
},
72+
]);
73+
});
6774
},
6875
);
69-
70-
function getDefaultSubnetSizes(azSize: number) {
71-
const vpcCidr = `10.0.0.0/${azSize}`;
72-
const result = getSubnetSpecs("vpcName", vpcCidr, ["us-east-1a"], undefined);
73-
return result.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
74-
}
7576
});
7677

7778
it("should have smaller subnets than the vpc", () => {
@@ -85,13 +86,14 @@ describe("default subnet layout", () => {
8586
({ vpcCidrMask, azs, subnetSpecs }) => {
8687
const vpcCidr = `10.0.0.0/${vpcCidrMask}`;
8788

88-
const result = getSubnetSpecs("vpcName", vpcCidr, azs, subnetSpecs);
89-
90-
for (const subnet of result) {
91-
const subnetMask = getCidrMask(subnet.cidrBlock);
92-
// Larger mask means smaller subnet
93-
expect(subnetMask).toBeGreaterThan(vpcCidrMask);
94-
}
89+
const specs = getSubnetSpecs("vpcName", vpcCidr, azs, subnetSpecs);
90+
validatePartialSubnetSpecs(specs, (result) => {
91+
for (const subnet of result) {
92+
const subnetMask = getCidrMask(subnet.cidrBlock);
93+
// Larger mask means smaller subnet
94+
expect(subnetMask).toBeGreaterThan(vpcCidrMask);
95+
}
96+
});
9597
},
9698
),
9799
);
@@ -127,21 +129,23 @@ describe("default subnet layout", () => {
127129
["us-east-1a"],
128130
[{ type: "Private" }, { type: "Public" }, { type: "Isolated" }],
129131
);
130-
const masks = result.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
131-
expect(masks).toMatchObject([
132-
{
133-
type: "Private",
134-
cidrMask: azCidrMask + 2,
135-
},
136-
{
137-
type: "Public",
138-
cidrMask: azCidrMask + 2,
139-
},
140-
{
141-
type: "Isolated",
142-
cidrMask: azCidrMask + 2,
143-
},
144-
]);
132+
validatePartialSubnetSpecs(result, (ss) => {
133+
const masks = ss.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
134+
expect(masks).toMatchObject([
135+
{
136+
type: "Private",
137+
cidrMask: azCidrMask + 2,
138+
},
139+
{
140+
type: "Public",
141+
cidrMask: azCidrMask + 2,
142+
},
143+
{
144+
type: "Isolated",
145+
cidrMask: azCidrMask + 2,
146+
},
147+
]);
148+
});
145149
},
146150
);
147151
});
@@ -170,7 +174,7 @@ describe("default subnet layout", () => {
170174

171175
const result = getSubnetSpecs("vpcName", vpcCidr, ["us-east-1a"], subnetSpecs);
172176

173-
validateSubnets(result, getOverlappingSubnets);
177+
validatePartialSubnetSpecs(result, (ss) => validateSubnets(ss, getOverlappingSubnets));
174178
},
175179
),
176180
);
@@ -246,7 +250,7 @@ describe("validating exact layouts", () => {
246250
[{ type: "Public" }, { type: "Private" }, { type: "Isolated" }],
247251
);
248252
expect(() => {
249-
validateNoGaps(vpcCidr, result);
253+
validatePartialSubnetSpecs(result, (ss) => validateNoGaps(vpcCidr, ss));
250254
}).toThrowError(
251255
"Please fix the following gaps: vpcName-isolated-1 (ending 10.0.191.254) ends before VPC ends (at 10.0.255.254})",
252256
);

awsx/ec2/subnetDistributorNew.ts

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2016-2022, Pulumi Corporation.
1+
// Copyright 2016-2024, Pulumi Corporation.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -16,26 +16,65 @@
1616
// and used in accordance with MPL v2.0 license
1717

1818
import * as pulumi from "@pulumi/pulumi";
19-
import { SubnetSpecInputs, SubnetTypeInputs } from "../schema-types";
19+
import { SubnetSpecInputs } from "../schema-types";
2020
import { Netmask } from "netmask";
21+
import { SubnetSpec, SubnetSpecPartial } from "./subnetSpecs";
2122

22-
export interface SubnetSpec {
23-
cidrBlock: string;
24-
type: SubnetTypeInputs;
25-
azName: string;
26-
subnetName: string;
27-
tags?: pulumi.Input<{
28-
[key: string]: pulumi.Input<string>;
29-
}>;
23+
export function getSubnetSpecs(
24+
vpcName: string,
25+
vpcCidr: pulumi.Input<string>,
26+
azNames: string[],
27+
subnetInputs: SubnetSpecInputs[] | undefined,
28+
azCidrMask?: number,
29+
): SubnetSpecPartial[] {
30+
const allocatedCidrBlocks = inputApply(
31+
vpcCidr,
32+
(vpcCidr) => allocateSubnetCidrBlocks(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask),
33+
(x) => x,
34+
(x) => x,
35+
);
36+
const subnetSpecs = subnetInputs ?? defaultSubnetInputsBare();
37+
return azNames.flatMap((azName, azIndex) => {
38+
const azNum = azIndex + 1;
39+
return subnetSpecs.map((subnetSpec, subnetIndex) => {
40+
const subnetAllocID = subnetAllocationID(vpcName, subnetSpec, azNum, subnetIndex);
41+
const cidrBlock = inputApply(
42+
allocatedCidrBlocks,
43+
(t) => t[subnetAllocID].cidrBlock,
44+
(x) => x,
45+
(x) => x,
46+
);
47+
return {
48+
cidrBlock,
49+
type: subnetSpec.type,
50+
azName,
51+
subnetName: subnetName(vpcName, subnetSpec, azNum),
52+
tags: subnetSpec.tags,
53+
};
54+
});
55+
});
3056
}
3157

32-
export function getSubnetSpecs(
58+
type SubnetAllocationID = string;
59+
60+
function subnetAllocationID(
61+
vpcName: string,
62+
subnetSpec: SubnetSpecInputs,
63+
azNum: number,
64+
subnetSpecIndex: number,
65+
): SubnetAllocationID {
66+
const name = subnetName(vpcName, subnetSpec, azNum);
67+
return `${name}#${subnetSpecIndex}`;
68+
}
69+
70+
function allocateSubnetCidrBlocks(
3371
vpcName: string,
3472
vpcCidr: string,
3573
azNames: string[],
3674
subnetInputs: SubnetSpecInputs[] | undefined,
3775
azCidrMask?: number,
38-
): SubnetSpec[] {
76+
): Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }> {
77+
const allocation: Record<string, { cidrBlock: pulumi.Input<string> }> = {};
3978
const vpcNetmask = new Netmask(vpcCidr);
4079
const azBitmask = azCidrMask ?? vpcNetmask.bitmask + newBits(azNames.length);
4180

@@ -60,11 +99,11 @@ export function getSubnetSpecs(
6099
}
61100

62101
let currentAzNetmask = new Netmask(`${vpcNetmask.base}/${azBitmask}`);
63-
const subnets: SubnetSpec[] = [];
102+
64103
for (let azIndex = 0; azIndex < azNames.length; azIndex++) {
65-
const azName = azNames[azIndex];
66104
const azNum = azIndex + 1;
67105
let currentSubnetNetmask: Netmask | undefined;
106+
let subnetIndex = 0;
68107
for (const subnetSpec of subnetSpecs) {
69108
if (currentSubnetNetmask === undefined) {
70109
currentSubnetNetmask = new Netmask(
@@ -78,19 +117,22 @@ export function getSubnetSpecs(
78117
);
79118
}
80119
const subnetCidr = currentSubnetNetmask.toString();
81-
subnets.push({
120+
const subnetAllocID = subnetAllocationID(vpcName, subnetSpec, azNum, subnetIndex);
121+
allocation[subnetAllocID] = {
82122
cidrBlock: subnetCidr,
83-
type: subnetSpec.type,
84-
azName,
85-
subnetName: subnetName(vpcName, subnetSpec, azNum),
86-
tags: subnetSpec.tags,
87-
});
123+
};
124+
125+
subnetIndex++;
88126
}
89127

90128
currentAzNetmask = currentAzNetmask.next();
91129
}
92130

93-
return subnets;
131+
return allocation;
132+
}
133+
134+
function defaultSubnetInputsBare(): SubnetSpecInputs[] {
135+
return [{ type: "Private" }, { type: "Public" }];
94136
}
95137

96138
export function defaultSubnetInputs(azBitmask: number): SubnetSpecInputs[] {
@@ -110,16 +152,10 @@ export function defaultSubnetInputs(azBitmask: number): SubnetSpecInputs[] {
110152
// Even if we've got more than /16, only use the first /16 for the default subnets.
111153
// Leave the rest for the user to add later if needed.
112154
const maxBitmask = Math.max(azBitmask, 16);
113-
return [
114-
{
115-
type: "Private",
116-
cidrMask: maxBitmask + 1,
117-
},
118-
{
119-
type: "Public",
120-
cidrMask: maxBitmask + 2,
121-
},
122-
];
155+
return defaultSubnetInputsBare().map((t, i) => ({
156+
type: t.type,
157+
cidrMask: maxBitmask + i + 1,
158+
}));
123159
}
124160

125161
export function nextNetmask(previous: Netmask, nextBitmask: number): Netmask {
@@ -285,3 +321,18 @@ export const validSubnetSizes: readonly number[] = [
285321
8388608, 4194304, 2097152, 1048576, 524288, 262144, 131072, 65536, 32768, 16384, 8192, 4096, 2048,
286322
1024, 512, 256, 128, 64, 32, 16, 8, 4,
287323
];
324+
325+
// This utility function is like pulumi.output(x).apply(fn) but tries to stay in the Input layer so that prompt
326+
// validations and test cases are not disturbed. wrap* functions are usually identity. Ideally something like this could
327+
// be handled in the core Pulumi Node SDK.
328+
function inputApply<T, U>(
329+
x: pulumi.Input<T>,
330+
fn: (value: T) => pulumi.Input<U>,
331+
wrapT: (value: pulumi.Unwrap<T>) => T,
332+
wrapU: (value: pulumi.Unwrap<U>) => U,
333+
): pulumi.Input<U> {
334+
if (x instanceof Promise || pulumi.Output.isInstance(x)) {
335+
return pulumi.output(x).apply((x) => pulumi.output(fn(wrapT(x))).apply(wrapU));
336+
}
337+
return fn(x);
338+
}

awsx/ec2/subnetSpecs.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2016-2024, Pulumi Corporation.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import * as pulumi from "@pulumi/pulumi";
16+
17+
import { SubnetTypeInputs } from "../schema-types";
18+
19+
export interface SubnetSpec {
20+
cidrBlock: string;
21+
type: SubnetTypeInputs;
22+
azName: string;
23+
subnetName: string;
24+
tags?: pulumi.Input<{
25+
[key: string]: pulumi.Input<string>;
26+
}>;
27+
}
28+
29+
// Like SubnetSpec, but cidrBlock may not be fully known yet. This type supports scenarios where the cidrBlock is
30+
// allocated by IPAM and is only known after the underlying VPC provisions.
31+
export type SubnetSpecPartial = Omit<SubnetSpec, "cidrBlock"> & { cidrBlock: pulumi.Input<string> };
32+
33+
// Runs check(specs) immediately if all specs are fully known, otherwise defers validation into the apply layer and
34+
// makes sure that validation is resolved before cidrBlock fields resolve.
35+
export function validatePartialSubnetSpecs(
36+
specs: SubnetSpecPartial[],
37+
check: (specs: SubnetSpec[]) => void,
38+
): SubnetSpecPartial[] {
39+
const promptSpecs = detectPromptSubnetSpecs(specs);
40+
if (promptSpecs) {
41+
check(promptSpecs);
42+
return specs;
43+
}
44+
45+
const checked: pulumi.Output<SubnetSpec[]> = pulumi.output(specs).apply((xs) => {
46+
check(xs);
47+
return xs;
48+
});
49+
return specs.map((s, index) => ({ ...s, cidrBlock: checked.apply((cs) => cs[index].cidrBlock) }));
50+
}
51+
52+
function detectPromptSubnetSpecs(specs: SubnetSpecPartial[]): SubnetSpec[] | undefined {
53+
if (specs.every((s) => typeof s.cidrBlock === "string")) {
54+
return specs.map((s) => {
55+
const cidrBlock: string = typeof s.cidrBlock === "string" ? s.cidrBlock : "";
56+
return { ...s, cidrBlock };
57+
});
58+
} else {
59+
return undefined;
60+
}
61+
}

0 commit comments

Comments
 (0)