Skip to content

Commit

Permalink
Permit aws.ec2.Vpc to use IPAM-allocated cidrBlock ranges (#1352)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t0yv0 authored Aug 7, 2024
1 parent 44d871d commit b805061
Show file tree
Hide file tree
Showing 22 changed files with 813 additions and 183 deletions.
3 changes: 2 additions & 1 deletion awsx/ec2/subnetDistributorLegacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

import fc from "fast-check";
import { SubnetSpecInputs, SubnetTypeInputs } from "../schema-types";
import { getSubnetSpecsLegacy, SubnetSpec, validateRanges } from "./subnetDistributorLegacy";
import { getSubnetSpecsLegacy, validateRanges } from "./subnetDistributorLegacy";
import { SubnetSpec } from "./subnetSpecs";
import { knownWorkingSubnets } from "./knownWorkingSubnets";
import { extractSubnetSpecInputFromLegacyLayout } from "./vpc";
import { getSubnetSpecs } from "./subnetDistributorNew";
Expand Down
11 changes: 1 addition & 10 deletions awsx/ec2/subnetDistributorLegacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,7 @@ import * as pulumi from "@pulumi/pulumi";
import { SubnetSpecInputs, SubnetTypeInputs } from "../schema-types";
import * as ipAddress from "ip-address";
import { BigInteger } from "jsbn";

export interface SubnetSpec {
cidrBlock: string;
type: SubnetTypeInputs;
azName: string;
subnetName: string;
tags?: pulumi.Input<{
[key: string]: pulumi.Input<string>;
}>;
}
import { SubnetSpec } from "./subnetSpecs";

export function getSubnetSpecsLegacy(
vpcName: string,
Expand Down
84 changes: 44 additions & 40 deletions awsx/ec2/subnetDistributorNew.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { Netmask } from "netmask";
import { getOverlappingSubnets, validateNoGaps, validateSubnets } from "./vpc";
import { getSubnetSpecsLegacy } from "./subnetDistributorLegacy";
import { validatePartialSubnetSpecs } from "./subnetSpecs";

function cidrMask(args?: { min?: number; max?: number }): fc.Arbitrary<number> {
return fc.integer({ min: args?.min ?? 16, max: args?.max ?? 27 });
Expand Down Expand Up @@ -54,24 +55,24 @@ describe("default subnet layout", () => {
it.each([16, 17, 18, 19, 20, 21, 22, 23, 24])(
"/%i AZ creates single private & public with staggered sizes",
(azCidrMask) => {
expect(getDefaultSubnetSizes(azCidrMask)).toMatchObject([
{
type: "Private",
cidrMask: azCidrMask + 1,
},
{
type: "Public",
cidrMask: azCidrMask + 2,
},
]);
const vpcCidr = `10.0.0.0/${azCidrMask}`;
const result = getSubnetSpecs("vpcName", vpcCidr, ["us-east-1a"], undefined);

validatePartialSubnetSpecs(result, (ss) => {
const x = ss.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
expect(x).toMatchObject([
{
type: "Private",
cidrMask: azCidrMask + 1,
},
{
type: "Public",
cidrMask: azCidrMask + 2,
},
]);
});
},
);

function getDefaultSubnetSizes(azSize: number) {
const vpcCidr = `10.0.0.0/${azSize}`;
const result = getSubnetSpecs("vpcName", vpcCidr, ["us-east-1a"], undefined);
return result.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
}
});

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

const result = getSubnetSpecs("vpcName", vpcCidr, azs, subnetSpecs);

for (const subnet of result) {
const subnetMask = getCidrMask(subnet.cidrBlock);
// Larger mask means smaller subnet
expect(subnetMask).toBeGreaterThan(vpcCidrMask);
}
const specs = getSubnetSpecs("vpcName", vpcCidr, azs, subnetSpecs);
validatePartialSubnetSpecs(specs, (result) => {
for (const subnet of result) {
const subnetMask = getCidrMask(subnet.cidrBlock);
// Larger mask means smaller subnet
expect(subnetMask).toBeGreaterThan(vpcCidrMask);
}
});
},
),
);
Expand Down Expand Up @@ -127,21 +129,23 @@ describe("default subnet layout", () => {
["us-east-1a"],
[{ type: "Private" }, { type: "Public" }, { type: "Isolated" }],
);
const masks = result.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
expect(masks).toMatchObject([
{
type: "Private",
cidrMask: azCidrMask + 2,
},
{
type: "Public",
cidrMask: azCidrMask + 2,
},
{
type: "Isolated",
cidrMask: azCidrMask + 2,
},
]);
validatePartialSubnetSpecs(result, (ss) => {
const masks = ss.map((s) => ({ type: s.type, cidrMask: getCidrMask(s.cidrBlock) }));
expect(masks).toMatchObject([
{
type: "Private",
cidrMask: azCidrMask + 2,
},
{
type: "Public",
cidrMask: azCidrMask + 2,
},
{
type: "Isolated",
cidrMask: azCidrMask + 2,
},
]);
});
},
);
});
Expand Down Expand Up @@ -170,7 +174,7 @@ describe("default subnet layout", () => {

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

validateSubnets(result, getOverlappingSubnets);
validatePartialSubnetSpecs(result, (ss) => validateSubnets(ss, getOverlappingSubnets));
},
),
);
Expand Down Expand Up @@ -246,7 +250,7 @@ describe("validating exact layouts", () => {
[{ type: "Public" }, { type: "Private" }, { type: "Isolated" }],
);
expect(() => {
validateNoGaps(vpcCidr, result);
validatePartialSubnetSpecs(result, (ss) => validateNoGaps(vpcCidr, ss));
}).toThrowError(
"Please fix the following gaps: vpcName-isolated-1 (ending 10.0.191.254) ends before VPC ends (at 10.0.255.254})",
);
Expand Down
113 changes: 82 additions & 31 deletions awsx/ec2/subnetDistributorNew.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2016-2022, Pulumi Corporation.
// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -16,26 +16,65 @@
// and used in accordance with MPL v2.0 license

import * as pulumi from "@pulumi/pulumi";
import { SubnetSpecInputs, SubnetTypeInputs } from "../schema-types";
import { SubnetSpecInputs } from "../schema-types";
import { Netmask } from "netmask";
import { SubnetSpec, SubnetSpecPartial } from "./subnetSpecs";

export interface SubnetSpec {
cidrBlock: string;
type: SubnetTypeInputs;
azName: string;
subnetName: string;
tags?: pulumi.Input<{
[key: string]: pulumi.Input<string>;
}>;
export function getSubnetSpecs(
vpcName: string,
vpcCidr: pulumi.Input<string>,
azNames: string[],
subnetInputs: SubnetSpecInputs[] | undefined,
azCidrMask?: number,
): SubnetSpecPartial[] {
const allocatedCidrBlocks = inputApply(
vpcCidr,
(vpcCidr) => allocateSubnetCidrBlocks(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask),
(x) => x,
(x) => x,
);
const subnetSpecs = subnetInputs ?? defaultSubnetInputsBare();
return azNames.flatMap((azName, azIndex) => {
const azNum = azIndex + 1;
return subnetSpecs.map((subnetSpec, subnetIndex) => {
const subnetAllocID = subnetAllocationID(vpcName, subnetSpec, azNum, subnetIndex);
const cidrBlock = inputApply(
allocatedCidrBlocks,
(t) => t[subnetAllocID].cidrBlock,
(x) => x,
(x) => x,
);
return {
cidrBlock,
type: subnetSpec.type,
azName,
subnetName: subnetName(vpcName, subnetSpec, azNum),
tags: subnetSpec.tags,
};
});
});
}

export function getSubnetSpecs(
type SubnetAllocationID = string;

function subnetAllocationID(
vpcName: string,
subnetSpec: SubnetSpecInputs,
azNum: number,
subnetSpecIndex: number,
): SubnetAllocationID {
const name = subnetName(vpcName, subnetSpec, azNum);
return `${name}#${subnetSpecIndex}`;
}

function allocateSubnetCidrBlocks(
vpcName: string,
vpcCidr: string,
azNames: string[],
subnetInputs: SubnetSpecInputs[] | undefined,
azCidrMask?: number,
): SubnetSpec[] {
): Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }> {
const allocation: Record<string, { cidrBlock: pulumi.Input<string> }> = {};
const vpcNetmask = new Netmask(vpcCidr);
const azBitmask = azCidrMask ?? vpcNetmask.bitmask + newBits(azNames.length);

Expand All @@ -60,11 +99,11 @@ export function getSubnetSpecs(
}

let currentAzNetmask = new Netmask(`${vpcNetmask.base}/${azBitmask}`);
const subnets: SubnetSpec[] = [];

for (let azIndex = 0; azIndex < azNames.length; azIndex++) {
const azName = azNames[azIndex];
const azNum = azIndex + 1;
let currentSubnetNetmask: Netmask | undefined;
let subnetIndex = 0;
for (const subnetSpec of subnetSpecs) {
if (currentSubnetNetmask === undefined) {
currentSubnetNetmask = new Netmask(
Expand All @@ -78,19 +117,22 @@ export function getSubnetSpecs(
);
}
const subnetCidr = currentSubnetNetmask.toString();
subnets.push({
const subnetAllocID = subnetAllocationID(vpcName, subnetSpec, azNum, subnetIndex);
allocation[subnetAllocID] = {
cidrBlock: subnetCidr,
type: subnetSpec.type,
azName,
subnetName: subnetName(vpcName, subnetSpec, azNum),
tags: subnetSpec.tags,
});
};

subnetIndex++;
}

currentAzNetmask = currentAzNetmask.next();
}

return subnets;
return allocation;
}

function defaultSubnetInputsBare(): SubnetSpecInputs[] {
return [{ type: "Private" }, { type: "Public" }];
}

export function defaultSubnetInputs(azBitmask: number): SubnetSpecInputs[] {
Expand All @@ -110,16 +152,10 @@ export function defaultSubnetInputs(azBitmask: number): SubnetSpecInputs[] {
// Even if we've got more than /16, only use the first /16 for the default subnets.
// Leave the rest for the user to add later if needed.
const maxBitmask = Math.max(azBitmask, 16);
return [
{
type: "Private",
cidrMask: maxBitmask + 1,
},
{
type: "Public",
cidrMask: maxBitmask + 2,
},
];
return defaultSubnetInputsBare().map((t, i) => ({
type: t.type,
cidrMask: maxBitmask + i + 1,
}));
}

export function nextNetmask(previous: Netmask, nextBitmask: number): Netmask {
Expand Down Expand Up @@ -285,3 +321,18 @@ export const validSubnetSizes: readonly number[] = [
8388608, 4194304, 2097152, 1048576, 524288, 262144, 131072, 65536, 32768, 16384, 8192, 4096, 2048,
1024, 512, 256, 128, 64, 32, 16, 8, 4,
];

// This utility function is like pulumi.output(x).apply(fn) but tries to stay in the Input layer so that prompt
// validations and test cases are not disturbed. wrap* functions are usually identity. Ideally something like this could
// be handled in the core Pulumi Node SDK.
function inputApply<T, U>(
x: pulumi.Input<T>,
fn: (value: T) => pulumi.Input<U>,
wrapT: (value: pulumi.Unwrap<T>) => T,
wrapU: (value: pulumi.Unwrap<U>) => U,
): pulumi.Input<U> {
if (x instanceof Promise || pulumi.Output.isInstance(x)) {
return pulumi.output(x).apply((x) => pulumi.output(fn(wrapT(x))).apply(wrapU));
}
return fn(x);
}
61 changes: 61 additions & 0 deletions awsx/ec2/subnetSpecs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as pulumi from "@pulumi/pulumi";

import { SubnetTypeInputs } from "../schema-types";

export interface SubnetSpec {
cidrBlock: string;
type: SubnetTypeInputs;
azName: string;
subnetName: string;
tags?: pulumi.Input<{
[key: string]: pulumi.Input<string>;
}>;
}

// Like SubnetSpec, but cidrBlock may not be fully known yet. This type supports scenarios where the cidrBlock is
// allocated by IPAM and is only known after the underlying VPC provisions.
export type SubnetSpecPartial = Omit<SubnetSpec, "cidrBlock"> & { cidrBlock: pulumi.Input<string> };

// Runs check(specs) immediately if all specs are fully known, otherwise defers validation into the apply layer and
// makes sure that validation is resolved before cidrBlock fields resolve.
export function validatePartialSubnetSpecs(
specs: SubnetSpecPartial[],
check: (specs: SubnetSpec[]) => void,
): SubnetSpecPartial[] {
const promptSpecs = detectPromptSubnetSpecs(specs);
if (promptSpecs) {
check(promptSpecs);
return specs;
}

const checked: pulumi.Output<SubnetSpec[]> = pulumi.output(specs).apply((xs) => {
check(xs);
return xs;
});
return specs.map((s, index) => ({ ...s, cidrBlock: checked.apply((cs) => cs[index].cidrBlock) }));
}

function detectPromptSubnetSpecs(specs: SubnetSpecPartial[]): SubnetSpec[] | undefined {
if (specs.every((s) => typeof s.cidrBlock === "string")) {
return specs.map((s) => {
const cidrBlock: string = typeof s.cidrBlock === "string" ? s.cidrBlock : "";
return { ...s, cidrBlock };
});
} else {
return undefined;
}
}
Loading

0 comments on commit b805061

Please sign in to comment.