diff --git a/awsx/ec2/subnetDistributorLegacy.test.ts b/awsx/ec2/subnetDistributorLegacy.test.ts index 25f188199..a1e74dc6a 100644 --- a/awsx/ec2/subnetDistributorLegacy.test.ts +++ b/awsx/ec2/subnetDistributorLegacy.test.ts @@ -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"; diff --git a/awsx/ec2/subnetDistributorLegacy.ts b/awsx/ec2/subnetDistributorLegacy.ts index 5b396bc15..0006c2ebb 100644 --- a/awsx/ec2/subnetDistributorLegacy.ts +++ b/awsx/ec2/subnetDistributorLegacy.ts @@ -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; - }>; -} +import { SubnetSpec } from "./subnetSpecs"; export function getSubnetSpecsLegacy( vpcName: string, diff --git a/awsx/ec2/subnetDistributorNew.test.ts b/awsx/ec2/subnetDistributorNew.test.ts index 64497cf52..a926712d4 100644 --- a/awsx/ec2/subnetDistributorNew.test.ts +++ b/awsx/ec2/subnetDistributorNew.test.ts @@ -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 { return fc.integer({ min: args?.min ?? 16, max: args?.max ?? 27 }); @@ -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", () => { @@ -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); + } + }); }, ), ); @@ -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, + }, + ]); + }); }, ); }); @@ -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)); }, ), ); @@ -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})", ); diff --git a/awsx/ec2/subnetDistributorNew.ts b/awsx/ec2/subnetDistributorNew.ts index 8c55fbec4..09d995ab0 100644 --- a/awsx/ec2/subnetDistributorNew.ts +++ b/awsx/ec2/subnetDistributorNew.ts @@ -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. @@ -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; - }>; +export function getSubnetSpecs( + vpcName: string, + vpcCidr: pulumi.Input, + 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 }> { + const allocation: Record }> = {}; const vpcNetmask = new Netmask(vpcCidr); const azBitmask = azCidrMask ?? vpcNetmask.bitmask + newBits(azNames.length); @@ -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( @@ -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[] { @@ -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 { @@ -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( + x: pulumi.Input, + fn: (value: T) => pulumi.Input, + wrapT: (value: pulumi.Unwrap) => T, + wrapU: (value: pulumi.Unwrap) => U, +): pulumi.Input { + if (x instanceof Promise || pulumi.Output.isInstance(x)) { + return pulumi.output(x).apply((x) => pulumi.output(fn(wrapT(x))).apply(wrapU)); + } + return fn(x); +} diff --git a/awsx/ec2/subnetSpecs.ts b/awsx/ec2/subnetSpecs.ts new file mode 100644 index 000000000..8066d2d95 --- /dev/null +++ b/awsx/ec2/subnetSpecs.ts @@ -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; + }>; +} + +// 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 & { cidrBlock: pulumi.Input }; + +// 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 = 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; + } +} diff --git a/awsx/ec2/vpc.test.ts b/awsx/ec2/vpc.test.ts index 7bf0418d5..ecc52f02b 100644 --- a/awsx/ec2/vpc.test.ts +++ b/awsx/ec2/vpc.test.ts @@ -342,3 +342,24 @@ describe("picking subnet allocator", () => { expect(f("Exact")).toBe("NewAllocator"); // this case is a bit suspect }); }); + +describe("validating vpc args", () => { + it("permits ipv4IpamPoolId with cidrBlock", () => { + Vpc.validateVpcArgs({ ipv4IpamPoolId: "pool", cidrBlock: "10.0.0.0/16" }); + }); + it("permits ipv4IpamPoolId with mask", () => { + Vpc.validateVpcArgs({ ipv4IpamPoolId: "pool", ipv4NetmaskLength: 24 }); + }); + it("rejects ipv4IpamPoolId without mask or cidrBlock", () => { + expect(() => Vpc.validateVpcArgs({ ipv4IpamPoolId: "pool" })).toThrowError(); + }); + it("rejects ipv4IpamPoolId with both mask and cidrBlock", () => { + expect(() => + Vpc.validateVpcArgs({ + ipv4IpamPoolId: "pool", + cidrBlock: "10.0.0.0/24", + ipv4NetmaskLength: 24, + }), + ).toThrowError(); + }); +}); diff --git a/awsx/ec2/vpc.ts b/awsx/ec2/vpc.ts index b60698eff..903a7c63b 100644 --- a/awsx/ec2/vpc.ts +++ b/awsx/ec2/vpc.ts @@ -15,7 +15,9 @@ import * as aws from "@pulumi/aws"; import * as pulumi from "@pulumi/pulumi"; import * as schema from "../schema-types"; -import { getSubnetSpecsLegacy, SubnetSpec } from "./subnetDistributorLegacy"; +import { getSubnetSpecsLegacy } from "./subnetDistributorLegacy"; +import * as vpcConverters from "./vpcConverters"; +import { SubnetSpec, SubnetSpecPartial, validatePartialSubnetSpecs } from "./subnetSpecs"; import { getSubnetSpecs, getSubnetSpecsExplicit, @@ -35,7 +37,7 @@ interface VpcData { igw: aws.ec2.InternetGateway; natGateways: aws.ec2.NatGateway[]; eips: aws.ec2.Eip[]; - subnetLayout: schema.ResolvedSubnetSpecInputs[]; + subnetLayout: pulumi.Output; publicSubnetIds: pulumi.Output[]; privateSubnetIds: pulumi.Output[]; isolatedSubnetIds: pulumi.Output[]; @@ -56,7 +58,8 @@ export class Vpc extends schema.Vpc { this.internetGateway = data.igw; this.natGateways = data.natGateways; this.eips = data.eips; - this.subnetLayout = data.subnetLayout; + + this.subnetLayout = data.subnetLayout.apply(vpcConverters.toResolvedSubnetSpecOutputs); this.privateSubnetIds = data.privateSubnetIds; this.publicSubnetIds = data.publicSubnetIds; @@ -71,7 +74,9 @@ export class Vpc extends schema.Vpc { name: string; args: schema.VpcArgs; opts: pulumi.ComponentResourceOptions; - }) { + }): Promise { + Vpc.validateVpcArgs(props.args); + const { name, args } = props; if (args.availabilityZoneNames && args.numberOfAvailabilityZones) { throw new Error( @@ -87,79 +92,24 @@ export class Vpc extends schema.Vpc { const allocationIds = args.natGateways?.elasticIpAllocationIds ?? []; validateEips(natGatewayStrategy, allocationIds, availabilityZones); - const cidrBlock = args.cidrBlock ?? "10.0.0.0/16"; - - const parsedSpecs: NormalizedSubnetInputs = validateAndNormalizeSubnetInputs( - args.subnetSpecs, - availabilityZones.length, - ); - const subnetStrategy = args.subnetStrategy ?? "Legacy"; - const subnetSpecs = (() => { - const a = Vpc.pickSubnetAllocator(parsedSpecs, subnetStrategy); - switch (a.allocator) { - case "LegacyAllocator": - const legacySubnetSpecs = getSubnetSpecsLegacy( - name, - cidrBlock, - availabilityZones, - parsedSpecs?.normalizedSpecs, - ); - return legacySubnetSpecs; - case "ExplicitAllocator": - return getSubnetSpecsExplicit(name, availabilityZones, a.specs); - case "NewAllocator": - default: - return getSubnetSpecs( - name, - cidrBlock, - availabilityZones, - parsedSpecs?.normalizedSpecs, - args.availabilityZoneCidrMask, - ); - } - })(); - - let subnetLayout = parsedSpecs?.normalizedSpecs; - if (subnetStrategy === "Legacy" || subnetLayout === undefined) { - subnetLayout = extractSubnetSpecInputFromLegacyLayout(subnetSpecs, name, availabilityZones); - } - // Only warn if they're using a custom, non-explicit layout and haven't specified a strategy. - if ( - args.subnetStrategy === undefined && - parsedSpecs !== undefined && - parsedSpecs.isExplicitLayout === false - ) { - pulumi.log.warn( - `The default subnetStrategy will change from "Legacy" to "Auto" in the next major version. Please specify the subnetStrategy explicitly. The current subnet layout can be specified via "Auto" as:\n\n${JSON.stringify( - subnetLayout, - undefined, - 2, - )}`, - this, - ); - } - - validateSubnets(subnetSpecs, getOverlappingSubnets); - - if (subnetStrategy === "Exact") { - validateNoGaps(cidrBlock, subnetSpecs); - } - - validateNatGatewayStrategy(natGatewayStrategy, subnetSpecs); const sharedTags = { Name: name, ...args.tags }; - const vpc = new aws.ec2.Vpc( + const cidrBlock = Vpc.decideCidrBlockVpcInput(args); + + const { + vpc, + subnets: { subnetSpecs, subnetLayout }, + } = this.createInnerVpc( name, - { - ...args, - cidrBlock, - tags: sharedTags, - }, - { parent: this }, + subnetStrategy, + availabilityZones, + natGatewayStrategy, + args, + cidrBlock, + sharedTags, ); - const vpcId = vpc.id; // We unconditionally create the IGW (even if it's not needed because we // only have isolated subnets) because AWS does not charge for it, and @@ -347,14 +297,217 @@ export class Vpc extends schema.Vpc { routes, natGateways, eips, - subnetLayout: subnetLayout, + subnetLayout: pulumi.output(subnetLayout).apply(vpcConverters.toResolvedSubnetSpecOutputs), privateSubnetIds, publicSubnetIds, isolatedSubnetIds, - vpcId, + vpcId: vpc.id, }; } + // Internal. Exported for testing. + public static validateVpcArgs(args: schema.VpcArgs) { + if (args.ipv4IpamPoolId !== undefined) { + if (args.cidrBlock !== undefined && args.ipv4NetmaskLength !== undefined) { + throw new Error("Only one of 'cidrBlock', 'ipv4NetmaskLength' is allowed."); + } + if (args.ipv4NetmaskLength === undefined && args.cidrBlock === undefined) { + throw new Error( + "If 'ipv4IpamPoolId' is specified, 'ipv4NetmaskLength' or 'cidrBlock' must also be specified.", + ); + } + } + } + + // Decide the cidrBlock input parameter for the underlying aws.ec2.Vpc resource. + private static decideCidrBlockVpcInput(args: schema.VpcArgs): string | undefined { + // Respect the user-provided value, if any. + if (args.cidrBlock !== undefined) { + return args.cidrBlock; + } + + // If the user wants to use an IPAM pool without specifying a cidrBlock, they must also define ipv4netMaskLength + // that instructs how the IPAM pool should allocate the cidrBlock. In this case the should not assume any defaults. + if (args.ipv4IpamPoolId !== undefined) { + return undefined; + } + + // Historically this default was used when left unspecified. + return "10.0.0.0/16"; + } + + private createInnerVpc( + name: string, + subnetStrategy: schema.SubnetAllocationStrategyInputs, + availabilityZones: string[], + natGatewayStrategy: schema.NatGatewayStrategyInputs, + args: schema.VpcArgs, + cidrBlock: string | undefined, + sharedTags: pulumi.Input>>, + ): { + vpc: aws.ec2.Vpc; + subnets: { + subnetSpecs: SubnetSpecPartial[]; + subnetLayout: pulumi.Output; + }; + } { + if (cidrBlock) { + // If cidrBlock is known because the user provided it, validations should run as early as possible. Failing + // validations will short-circuit trying to create the inner aws.ec2.Vpc resource. + const subnets = this.decideAndValidateSubnetSpecs( + name, + subnetStrategy, + availabilityZones, + natGatewayStrategy, + args, + cidrBlock, + ); + const vpc = new aws.ec2.Vpc( + name, + { + ...args, + cidrBlock, + tags: sharedTags, + }, + { parent: this }, + ); + return { vpc, subnets }; + } else { + // If cidrBlock is not yet known, validations will run after the creation of the aws.ec2.Vpc resource and will be + // based on the dynamically decided cidrBlock value. If these validations fail, subnet specs and layout will have + // failing outputs which will short-circuit creating the subnets. + const vpc = new aws.ec2.Vpc( + name, + { + ...args, + cidrBlock, + tags: sharedTags, + }, + { parent: this }, + ); + const subnets = this.decideAndValidateSubnetSpecs( + name, + subnetStrategy, + availabilityZones, + natGatewayStrategy, + args, + vpc.cidrBlock, + ); + return { vpc, subnets }; + } + } + + private decideAndValidateSubnetSpecs( + name: string, + subnetStrategy: schema.SubnetAllocationStrategyInputs, + availabilityZones: string[], + natGatewayStrategy: schema.NatGatewayStrategyInputs, + args: schema.VpcArgs, + actualCidrBlock: pulumi.Input, + ): { + subnetSpecs: SubnetSpecPartial[]; + subnetLayout: pulumi.Output; + } { + const { subnetSpecs: decidedSpecs, subnetLayout } = this.decideSubnetSpecs( + name, + actualCidrBlock, + subnetStrategy, + availabilityZones, + args, + ); + + const subnetSpecs = validatePartialSubnetSpecs(decidedSpecs, (subnetSpecs) => { + validateSubnets(subnetSpecs, getOverlappingSubnets); + // Only prompt cidrBlock is validated; probably OK as non-prompt cidrBlock implies that ipv4NetmaskLength was set + // to allocate the cidrBlock via IPAM. + if (subnetStrategy === "Exact" && typeof actualCidrBlock === "string") { + validateNoGaps(actualCidrBlock, subnetSpecs); + } + validateNatGatewayStrategy(natGatewayStrategy, subnetSpecs); + }); + + return { subnetSpecs, subnetLayout }; + } + + private decideSubnetSpecs( + name: string, + cidrBlock: pulumi.Input, + subnetStrategy: schema.SubnetAllocationStrategyInputs, + availabilityZones: string[], + args: { + readonly subnetSpecs?: schema.SubnetSpecInputs[]; + readonly subnetStrategy?: schema.SubnetAllocationStrategyInputs; + readonly availabilityZoneCidrMask?: number; + }, + ): { + subnetSpecs: SubnetSpecPartial[]; + subnetLayout: pulumi.Output; + } { + const parsedSpecs: NormalizedSubnetInputs = validateAndNormalizeSubnetInputs( + args.subnetSpecs, + availabilityZones.length, + ); + + const subnetSpecs = (() => { + const a = Vpc.pickSubnetAllocator(parsedSpecs, subnetStrategy); + switch (a.allocator) { + case "LegacyAllocator": + if (typeof cidrBlock !== "string") { + throw new Error( + `Dynamically allocated cidrBlock ranges are not supported with subnetStrategy="Legacy". ` + + `"Try using subnetStrategy="Auto"`, + ); + } + const legacySubnetSpecs = getSubnetSpecsLegacy( + name, + cidrBlock, + availabilityZones, + parsedSpecs?.normalizedSpecs, + ); + return legacySubnetSpecs; + case "ExplicitAllocator": + return getSubnetSpecsExplicit(name, availabilityZones, a.specs); + case "NewAllocator": + default: + return getSubnetSpecs( + name, + cidrBlock, + availabilityZones, + parsedSpecs?.normalizedSpecs, + args.availabilityZoneCidrMask, + ); + } + })(); + + const subnetLayout: pulumi.Output = + subnetStrategy === "Legacy" || parsedSpecs?.normalizedSpecs === undefined + ? pulumi + .output(subnetSpecs) + .apply((ss) => extractSubnetSpecInputFromLegacyLayout(ss, name, availabilityZones)) + .apply(vpcConverters.toResolvedSubnetSpecOutputs) + : pulumi + .output(parsedSpecs?.normalizedSpecs) + .apply(vpcConverters.toResolvedSubnetSpecOutputs); + + // Only warn if they're using a custom, non-explicit layout and haven't specified a strategy. + if ( + args.subnetStrategy === undefined && + parsedSpecs !== undefined && + parsedSpecs.isExplicitLayout === false + ) { + pulumi.log.warn( + `The default subnetStrategy will change from "Legacy" to "Auto" in the next major version. Please specify the subnetStrategy explicitly. The current subnet layout can be specified via "Auto" as:\n\n${JSON.stringify( + subnetLayout, + undefined, + 2, + )}`, + this, + ); + } + + return { subnetLayout, subnetSpecs }; + } + async getDefaultAzs(azCount?: number): Promise { const desiredCount = azCount ?? 3; const result = await aws.getAvailabilityZones(undefined, { parent: this }); @@ -392,7 +545,7 @@ export function extractSubnetSpecInputFromLegacyLayout( subnetSpecs: SubnetSpec[], vpcName: string, availabilityZones: string[], -) { +): schema.SubnetSpecInputs[] { const singleAzLength = subnetSpecs.length / availabilityZones.length; function extractName(subnetName: string, type: schema.SubnetTypeInputs) { const withoutVpcPrefix = subnetName.replace(`${vpcName}-`, ""); @@ -533,7 +686,10 @@ export function shouldCreateNatGateway( } } -export function compareSubnetSpecs(spec1: SubnetSpec, spec2: SubnetSpec): number { +export function compareSubnetSpecs( + spec1: Omit, + spec2: Omit, +): number { if (spec1.type === spec2.type) { return 0; } diff --git a/awsx/ec2/vpcConverters.ts b/awsx/ec2/vpcConverters.ts new file mode 100644 index 000000000..14d5420bc --- /dev/null +++ b/awsx/ec2/vpcConverters.ts @@ -0,0 +1,38 @@ +// 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. + +// Functions in module do not attempt any semantic processing and are simply helping the VPC resource translate +// information between similar but distinct Input/Output forms. There might be an opportunity to use output tricks to +// simplify these. + +import * as pulumi from "@pulumi/pulumi"; +import * as schema from "../schema-types"; + +export function toResolvedSubnetSpecOutputs( + s: schema.SubnetSpecInputs[], +): schema.ResolvedSubnetSpecOutputs[] { + return s.map(convSubnetSpecInputsToResolvedSubnetSpecOutputs); +} + +function convSubnetSpecInputsToResolvedSubnetSpecOutputs( + s: schema.SubnetSpecInputs, +): schema.ResolvedSubnetSpecOutputs { + return { + name: s.name ? pulumi.output(s.name) : undefined, + cidrBlocks: s.cidrBlocks ? pulumi.output(s.cidrBlocks) : undefined, + cidrMask: s.cidrMask ? pulumi.output(s.cidrMask) : undefined, + size: s.size ? pulumi.output(s.size) : undefined, + type: pulumi.output(s.type), + }; +} diff --git a/awsx/schema-types.ts b/awsx/schema-types.ts index eea296e32..99f45e691 100644 --- a/awsx/schema-types.ts +++ b/awsx/schema-types.ts @@ -587,18 +587,18 @@ export interface NatGatewayConfigurationOutputs { export type NatGatewayStrategyInputs = "None" | "Single" | "OnePerAz"; export type NatGatewayStrategyOutputs = "None" | "Single" | "OnePerAz"; export interface ResolvedSubnetSpecInputs { - readonly cidrBlocks?: string[]; - readonly cidrMask?: number; - readonly name?: string; - readonly size?: number; - readonly type: SubnetTypeInputs; + readonly cidrBlocks?: pulumi.Input[]>; + readonly cidrMask?: pulumi.Input; + readonly name?: pulumi.Input; + readonly size?: pulumi.Input; + readonly type: pulumi.Input; } export interface ResolvedSubnetSpecOutputs { - readonly cidrBlocks?: string[]; - readonly cidrMask?: number; - readonly name?: string; - readonly size?: number; - readonly type: SubnetTypeOutputs; + readonly cidrBlocks?: pulumi.Output; + readonly cidrMask?: pulumi.Output; + readonly name?: pulumi.Output; + readonly size?: pulumi.Output; + readonly type: pulumi.Output; } export type SubnetAllocationStrategyInputs = "Legacy" | "Auto" | "Exact"; export type SubnetAllocationStrategyOutputs = "Legacy" | "Auto" | "Exact"; diff --git a/examples/examples_nodejs_test.go b/examples/examples_nodejs_test.go index ba5680dee..bbe15d59d 100644 --- a/examples/examples_nodejs_test.go +++ b/examples/examples_nodejs_test.go @@ -17,6 +17,7 @@ package examples import ( + "fmt" "path/filepath" "testing" "time" @@ -191,7 +192,127 @@ func TestVpcSpecificSubnetSpecArgs(t *testing.T) { integration.ProgramTest(t, &test) } -func TestVpcMultipleSimilarSubnetSpecArgs(t *testing.T) { +func TestVpcIpam(t *testing.T) { + t.Run("vpc-ipam-ipv4-auto-cidrblock", func(t *testing.T) { + dir := filepath.Join(getCwd(t), "vpc", "nodejs", "vpc-ipam-ipv4-auto-cidrblock") + validate := func(t *testing.T, stack integration.RuntimeValidationStackInfo) { + regionName := stack.Outputs["regionName"].(string) + assert.Equal(t, []interface{}{ + map[string]interface{}{ + "cidrMask": float64(27), + "type": "Private", + }, + map[string]interface{}{ + "cidrMask": float64(28), + "type": "Public", + }, + }, stack.Outputs["subnetLayout"]) + expectedSubnets := []interface{}{ + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sa", regionName), + "cidrBlock": "172.20.0.32/28", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sa", regionName), + "cidrBlock": "172.20.0.0/27", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sb", regionName), + "cidrBlock": "172.20.0.96/28", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sb", regionName), + "cidrBlock": "172.20.0.64/27", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sc", regionName), + "cidrBlock": "172.20.0.160/28", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sc", regionName), + "cidrBlock": "172.20.0.128/27", + }, + } + actualSubnets := stack.Outputs["subnets"].([]any) + assert.Equal(t, len(expectedSubnets), len(actualSubnets)) + for _, expsub := range expectedSubnets { + assert.Contains(t, actualSubnets, expsub) + } + } + test := getNodeJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: dir, + RetryFailedSteps: true, + NoParallel: true, // test account has N=1 limit for IPAM + Quick: true, + ExtraRuntimeValidation: validate, + }) + + integration.ProgramTest(t, &test) + }) + t.Run("vpc-ipam-ipv4-auto-cidrblock-with-specs", func(t *testing.T) { + dir := filepath.Join(getCwd(t), "vpc", "nodejs", "vpc-ipam-ipv4-auto-cidrblock-with-specs") + validate := func(t *testing.T, stack integration.RuntimeValidationStackInfo) { + regionName := stack.Outputs["regionName"].(string) + assert.Equal(t, []interface{}{ + map[string]interface{}{ + "cidrMask": float64(25), + "size": float64(128), + "name": "private", + "type": "Private", + }, + map[string]interface{}{ + "cidrMask": float64(27), + "size": float64(32), + "name": "public", + "type": "Public", + }, + }, stack.Outputs["subnetLayout"]) + expectedSubnets := []interface{}{ + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sa", regionName), + "cidrBlock": "172.20.0.128/27", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sa", regionName), + "cidrBlock": "172.20.0.0/25", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sb", regionName), + "cidrBlock": "172.20.1.128/27", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sb", regionName), + "cidrBlock": "172.20.1.0/25", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sc", regionName), + "cidrBlock": "172.20.2.128/27", + }, + map[string]interface{}{ + "availabilityZone": fmt.Sprintf("%sc", regionName), + "cidrBlock": "172.20.2.0/25", + }, + } + actualSubnets := stack.Outputs["subnets"].([]any) + assert.Equal(t, len(expectedSubnets), len(actualSubnets)) + for _, expsub := range expectedSubnets { + assert.Contains(t, actualSubnets, expsub) + } + } + test := getNodeJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: dir, + RetryFailedSteps: true, + NoParallel: true, // test account has N=1 limit for IPAM + Quick: true, + ExtraRuntimeValidation: validate, + }) + integration.ProgramTest(t, &test) + }) +} + +func TestVpc(t *testing.T) { test := getNodeJSBaseOptions(t). With(integration.ProgramTestOptions{ RunUpdateTest: false, diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/.gitignore b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/.gitignore new file mode 100644 index 000000000..c6958891d --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/node_modules/ diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/Pulumi.yaml b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/Pulumi.yaml new file mode 100644 index 000000000..1922aa4c4 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/Pulumi.yaml @@ -0,0 +1,8 @@ +name: vpc-ipam-ipv4-auto-cidrblock-with-specs +runtime: + name: nodejs +description: Test inheriting the cidrBlock from an IPv4 IPAM pool and constraining the subnet specs +config: + pulumi:tags: + value: + pulumi:template: aws-typescript diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/index.ts b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/index.ts new file mode 100644 index 000000000..5952cb2fa --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/index.ts @@ -0,0 +1,62 @@ +import * as awsx from "@pulumi/awsx"; +import * as aws from "@pulumi/aws"; +import { SubnetAllocationStrategy } from "@pulumi/awsx/ec2"; + +const repository = "pulumi/pulumi-awsx"; +const testcase = "vpc-ipam-ipv4-auto-cidrblock-with-specs"; + +const tags = { + "repository": repository, + "testcase": testcase, +}; + +const currentRegion = aws.getRegionOutput({}); + +const myVpcIpam = new aws.ec2.VpcIpam("myVpcIpam", { + operatingRegions: [{ + regionName: currentRegion.name, + }], + description: `IPAM for ${repository} example ${testcase}`, + tags: tags, +}); + +const myVpcIpamPool = new aws.ec2.VpcIpamPool("myVpcIpamPool", { + addressFamily: "ipv4", + ipamScopeId: myVpcIpam.privateDefaultScopeId, + locale: currentRegion.name, + tags: tags, +}); + +const myVpcIpamPoolCidr = new aws.ec2.VpcIpamPoolCidr("myVpcIpamPoolCidr", { + ipamPoolId: myVpcIpamPool.id, + cidr: "172.20.0.0/16", +}); + +const myVpc = new awsx.ec2.Vpc("myVpc", { + numberOfAvailabilityZones: 3, + subnetStrategy: SubnetAllocationStrategy.Auto, + ipv4IpamPoolId: myVpcIpamPool.id, + ipv4NetmaskLength: 22, + subnetSpecs: [ + { + type: "Private", + name: "private", + cidrMask: 25, + }, + { + type: "Public", + name: "public", + cidrMask: 27, + }, + ], + tags: tags, +}, { + dependsOn: [myVpcIpamPoolCidr], +}); + +export const regionName = currentRegion.name; +export const subnetLayout = myVpc.subnetLayout; +export const subnets = myVpc.subnets.apply(s => s.map(x => ({ + availabilityZone: x.availabilityZone, + cidrBlock: x.cidrBlock +}))); diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/package.json b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/package.json new file mode 100644 index 000000000..de9290127 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/package.json @@ -0,0 +1,13 @@ +{ + "name": "vpc-ipam-ipv4-auto-cidrblock-with-specs", + "main": "index.ts", + "devDependencies": { + "@types/node": "^18", + "typescript": "^5.0.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/awsx": "^2.0.2", + "@pulumi/pulumi": "^3.113.0" + } +} diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/tsconfig.json b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/tsconfig.json new file mode 100644 index 000000000..f960d5171 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock-with-specs/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.ts" + ] +} diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/.gitignore b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/.gitignore new file mode 100644 index 000000000..c6958891d --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/node_modules/ diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/Pulumi.yaml b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/Pulumi.yaml new file mode 100644 index 000000000..ac8f85933 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/Pulumi.yaml @@ -0,0 +1,8 @@ +name: vpc-ipam-ipv4-auto-cidrblock +runtime: + name: nodejs +description: Test inheriting the cidrBlock from an IPv4 IPAM pool +config: + pulumi:tags: + value: + pulumi:template: aws-typescript diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/index.ts b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/index.ts new file mode 100644 index 000000000..7d2248851 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/index.ts @@ -0,0 +1,48 @@ +import * as awsx from "@pulumi/awsx"; +import * as aws from "@pulumi/aws"; + +const repository = "pulumi/pulumi-awsx"; +const testcase = "vpc-ipam-ipv4-auto-cidrblock"; + +const tags = { + "repository": repository, + "testcase": testcase, +}; + +const currentRegion = aws.getRegionOutput({}); + +const myVpcIpam = new aws.ec2.VpcIpam("myVpcIpam", { + operatingRegions: [{ + regionName: currentRegion.name, + }], + description: `IPAM for ${repository} example ${testcase}`, + tags: tags, +}); + +const myVpcIpamPool = new aws.ec2.VpcIpamPool("myVpcIpamPool", { + addressFamily: "ipv4", + ipamScopeId: myVpcIpam.privateDefaultScopeId, + locale: currentRegion.name, + tags: tags, +}); + +const myVpcIpamPoolCidr = new aws.ec2.VpcIpamPoolCidr("myVpcIpamPoolCidr", { + ipamPoolId: myVpcIpamPool.id, + cidr: "172.20.0.0/16", +}); + +const myVpc = new awsx.ec2.Vpc("myVpc", { + ipv4IpamPoolId: myVpcIpamPool.id, + ipv4NetmaskLength: 24, + tags: tags, + subnetStrategy: "Auto", +}, { + dependsOn: [myVpcIpamPoolCidr], +}); + +export const regionName = currentRegion.name; +export const subnetLayout = myVpc.subnetLayout; +export const subnets = myVpc.subnets.apply(s => s.map(x => ({ + availabilityZone: x.availabilityZone, + cidrBlock: x.cidrBlock +}))); diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/package.json b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/package.json new file mode 100644 index 000000000..887016242 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/package.json @@ -0,0 +1,13 @@ +{ + "name": "vpc-ipam-ipv4-auto-cidrblock", + "main": "index.ts", + "devDependencies": { + "@types/node": "^18", + "typescript": "^5.0.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/awsx": "^2.0.2", + "@pulumi/pulumi": "^3.113.0" + } +} diff --git a/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/tsconfig.json b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/tsconfig.json new file mode 100644 index 000000000..f960d5171 --- /dev/null +++ b/examples/vpc/nodejs/vpc-ipam-ipv4-auto-cidrblock/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.ts" + ] +} diff --git a/schema.json b/schema.json index 1eed268f9..cddc33bbb 100644 --- a/schema.json +++ b/schema.json @@ -581,30 +581,24 @@ "cidrBlocks": { "type": "array", "items": { - "type": "string", - "plain": true + "type": "string" }, - "plain": true, "description": "An optional list of CIDR blocks to assign to the subnet spec for each AZ. If specified, the count must match the number of AZs being used for the VPC, and must also be specified for all other subnet specs." }, "cidrMask": { "type": "integer", - "plain": true, "description": "The netmask for the subnet's CIDR block. This is optional, the default value is inferred from the `cidrMask`, `cidrBlocks` or based on an even distribution of available space from the VPC's CIDR block after being divided evenly by availability zone." }, "name": { "type": "string", - "plain": true, "description": "The subnet's name. Will be templated upon creation." }, "size": { "type": "integer", - "plain": true, "description": "Optional size of the subnet's CIDR block - the number of hosts. This value must be a power of 2 (e.g. 256, 512, 1024, etc.). This is optional, the default value is inferred from the `cidrMask`, `cidrBlocks` or based on an even distribution of available space from the VPC's CIDR block after being divided evenly by availability zone." }, "type": { "$ref": "#/types/awsx:ec2:SubnetType", - "plain": true, "description": "The type of subnet." } }, diff --git a/schemagen/pkg/gen/ec2.go b/schemagen/pkg/gen/ec2.go index 5fa2e331c..c0e58cb29 100644 --- a/schemagen/pkg/gen/ec2.go +++ b/schemagen/pkg/gen/ec2.go @@ -367,28 +367,28 @@ func resolvedSubnetSpecType() schema.ComplexTypeSpec { Properties: map[string]schema.PropertySpec{ "type": { Description: "The type of subnet.", - TypeSpec: schema.TypeSpec{ - Ref: localRef("ec2", "SubnetType"), - Plain: true, - }, + TypeSpec: schema.TypeSpec{Ref: localRef("ec2", "SubnetType")}, }, "name": { Description: "The subnet's name. Will be templated upon creation.", - TypeSpec: plainString(), + TypeSpec: schema.TypeSpec{Type: "string"}, }, "cidrMask": { // The validation rules are too difficult to concisely describe here, so we'll leave that job to any // error messages generated from the component itself. Description: "The netmask for the subnet's CIDR block. This is optional, the default value is inferred from the `cidrMask`, `cidrBlocks` or based on an even distribution of available space from the VPC's CIDR block after being divided evenly by availability zone.", - TypeSpec: plainInt(), + TypeSpec: schema.TypeSpec{Type: "integer"}, }, "cidrBlocks": { Description: "An optional list of CIDR blocks to assign to the subnet spec for each AZ. If specified, the count must match the number of AZs being used for the VPC, and must also be specified for all other subnet specs.", - TypeSpec: plainArrayOfPlainStrings(), + TypeSpec: schema.TypeSpec{ + Type: "array", + Items: &schema.TypeSpec{Type: "string"}, + }, }, "size": { Description: "Optional size of the subnet's CIDR block - the number of hosts. This value must be a power of 2 (e.g. 256, 512, 1024, etc.). This is optional, the default value is inferred from the `cidrMask`, `cidrBlocks` or based on an even distribution of available space from the VPC's CIDR block after being divided evenly by availability zone.", - TypeSpec: plainInt(), + TypeSpec: schema.TypeSpec{Type: "integer"}, }, }, Required: []string{