-
Notifications
You must be signed in to change notification settings - Fork 103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Permit aws.ec2.Vpc to use IPAM-allocated cidrBlock ranges #1352
Conversation
awsx/ec2/vpc.ts
Outdated
|
||
const subnetSpecs = (() => { | ||
|
||
// cidrBlock is allocated dynamically but there are no explicit specs; use subnetDistributorNew. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The case analysis here is a bit uncertain for me, appreciate some help formulating which test cases we want to add.
One particular complication is that getSubnetSpecsLegacy
cannot yet accept a cidrBlock: Input<string>
and therefore this does not support IPAM-allocated cidrBlock with Legacy strategy properly. I've only done the Input threading for getSubnetSpecs
which implements the newer Auto strategy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be acceptable to only support IPAM when using the Auto
strategy? Users won't be adding IPAM to an existing VPC, so I don't think we should have to support "legacy" stuff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It now only supports the Auto strategy. Indeed cleaner.
awsx/ec2/vpc.ts
Outdated
); | ||
|
||
const subnetSpecs = validatePartialSubnetSpecs(decidedSpecs, (subnetSpecs) => { | ||
validateSubnets(subnetSpecs, getOverlappingSubnets); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, self-reviewing this change, there's a problem here that validations now happen after the new aws.ec2.Vpc call, and in the case when they used to terminate the flow promptly, that's a visible change, as now new aws.ec2.Vpc
attempts to provision before we reject the validations. It's a bit of a chicken and egg problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's the best that we can do. From the user's perspective I would have to have created the VPC resource in order to have the cidr allocated.
How does pulumi run the validation in this case? Are the creation of the other resources dependent on the validation completing or would they be run in parallel?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It runs after the internal vpc resource registers but before subnets do. If there's error reported by the internal vpc resource they now will be reported in addition to validation failures reported by awsx.Vpc. At a cost of a little bit of extra code complexity we can get the previous behavior back while using the new validation ordering behavior for the IPAM case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah pulumi runs resource allocations in parallel, so when we say new Vpc() we fork off a thread creating that VPC. Validations can run in parallel or they can block resource creation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had a first pass and mostly things look good!
azCidrMask, | ||
); | ||
const subnetSpecs = subnetInputs ?? defaultSubnetInputsBare(); | ||
return azNames.flatMap((azName, azIndex) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we combine this with the allocateSubnetCidrBlocksInput
/allocationSubnetCidrBlocks
function? It looks like we perform the same type of loop a bunch of different places which makes it a little hard to follow what is happening.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I don't know how to make this cleaner. allocateSubnetCidrBlocksInput is very intentionally broken out. Our problem here is that we're trying to keep the allocateSubnetCidrBlocks preserve the original algo as-is. But we need to interface with this algo whenvpcCidr: pulumi.Input<string>
is not known yet. Even in this case where it's not know yet, we can't assume that the entire set of SubnetSpecPartial[]
is unknown - we know how many there will be, and we know parts of information about them right away at preview, just not their respective cidrBlock values.
This indirection is accomplished by the means of Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>
helper data structure.
- allocateSubnetCidrBlocks is needed to preserve original algo in as simple code as possible
- allocateSubnetCidrBlocksInput is needed to do tricks to make allocateSubnetCidrBlocks callable with vpcCidr that is now an Input but return a Record, not an
Output<Record>
- tricks are used to not lose information - getSubnetSpecsWithPartialCidr/getSubnetSpecs is needed to do the rest of SubnetSpecPartial computation, not just the cidrBlock
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we used union types and type checking? Something like this:
export function getSubnetSpecsWithPartialCidr(
vpcName: string,
vpcCidr: pulumi.Input<string>,
azNames: string[],
subnetInputs: SubnetSpecInputs[] | undefined,
azCidrMask?: number,
): SubnetSpecPartial[] {
let alloc:
| pulumi.Output<Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>>
| Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>;
if (typeof vpcCidr === "string") {
alloc = allocateSubnetCidrBlocks(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask);
} else {
alloc = pulumi
.output(vpcCidr)
.apply((vpcCidr) =>
allocateSubnetCidrBlocks(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask),
);
}
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);
let cidrBlock: pulumi.Input<string>;
if (pulumi.Output.isInstance(alloc)) {
const allocOutput = alloc as pulumi.Output<
Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>
>;
cidrBlock = allocOutput.apply((a) => pulumi.output(a[subnetAllocID].cidrBlock));
} else {
cidrBlock = alloc[subnetAllocID].cidrBlock as pulumi.Input<string>;
}
return {
cidrBlock: cidrBlock,
type: subnetSpec.type,
azName,
subnetName: subnetName(vpcName, subnetSpec, azNum),
tags: subnetSpec.tags,
};
});
});
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a nice idea, I think you are right. This lets me delete one of the functions. I opted for an even simpler form as I think we do not need to if (typeof vpcCidr === "string") {
for cidrBlock we can just completely commit to pulumi.Output without loss of pertinent information in this case. Have a look at the latest version and thanks for pushing on this 🙏
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 : ""; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this one of those typescript issues where you've already established the type in the outer loop, but it forgets?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exactly.
awsx/ec2/vpc.ts
Outdated
|
||
const subnetSpecs = (() => { | ||
|
||
// cidrBlock is allocated dynamically but there are no explicit specs; use subnetDistributorNew. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be acceptable to only support IPAM when using the Auto
strategy? Users won't be adding IPAM to an existing VPC, so I don't think we should have to support "legacy" stuff.
awsx/ec2/vpc.ts
Outdated
); | ||
|
||
const subnetSpecs = validatePartialSubnetSpecs(decidedSpecs, (subnetSpecs) => { | ||
validateSubnets(subnetSpecs, getOverlappingSubnets); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's the best that we can do. From the user's perspective I would have to have created the VPC resource in order to have the cidr allocated.
How does pulumi run the validation in this case? Are the creation of the other resources dependent on the validation completing or would they be run in parallel?
|
||
const sharedTags = { Name: name, ...args.tags }; | ||
|
||
const cidrBlock = Vpc.decideCidrBlockVpcInput(args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we also validate here that if the user provides the ipamPoolId then they also provide the netmask?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This validation is done by the underlying awx.ec2.Vpc resource. It probably is slightly better UX to lift the validation to the awsx.ec2.Vpc layer. We can do this.
The tests pass but they take a very long time, and they start timing out the token used for other tests. I think it's actually the destroy bit that's slow for some reason, likely for the aws Ipam resource. I wonder what's a practical way forward here, is there a way to keep an IPAM resource alive for testing and import it instead of create/delete on each test. Could also trivially combine the tests into one to run against only one IPAM resource.
|
IPAM has a free tier so I wonder if it would be better to just create one and leave it there and have any tests that need it import it. |
It turns out the slowness comes from pulumi/pulumi-aws#4346 which makes deleting a VPC wait for IPAM to remove allocations. This aspect will not be helped by keeping an always-created IPAM for testing. Looks like customTimeout setting does not work either. |
Suggesting to go ahead with a workaround to the problem simply admitting longer running tests for the moment: #1357 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a couple of comments, otherwise looks good!
awsx/ec2/subnetDistributorNew.ts
Outdated
@@ -35,7 +70,48 @@ export function getSubnetSpecs( | |||
azNames: string[], | |||
subnetInputs: SubnetSpecInputs[] | undefined, | |||
azCidrMask?: number, | |||
): SubnetSpec[] { | |||
): SubnetSpecPartial[] { | |||
return getSubnetSpecsWithPartialCidr(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know you mentioned that we need to keep both getSubnetSpecs
& getSubnetSpecsWithPartialCidr
, but I'm not sure I understand why. They both have the same input and output types and getSubnetSpecs
does not have any additional logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might be right, I must have made a mistake of thinking about other functions. This can just be getSubnetSpecs.
azCidrMask, | ||
); | ||
const subnetSpecs = subnetInputs ?? defaultSubnetInputsBare(); | ||
return azNames.flatMap((azName, azIndex) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if we used union types and type checking? Something like this:
export function getSubnetSpecsWithPartialCidr(
vpcName: string,
vpcCidr: pulumi.Input<string>,
azNames: string[],
subnetInputs: SubnetSpecInputs[] | undefined,
azCidrMask?: number,
): SubnetSpecPartial[] {
let alloc:
| pulumi.Output<Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>>
| Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>;
if (typeof vpcCidr === "string") {
alloc = allocateSubnetCidrBlocks(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask);
} else {
alloc = pulumi
.output(vpcCidr)
.apply((vpcCidr) =>
allocateSubnetCidrBlocks(vpcName, vpcCidr, azNames, subnetInputs, azCidrMask),
);
}
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);
let cidrBlock: pulumi.Input<string>;
if (pulumi.Output.isInstance(alloc)) {
const allocOutput = alloc as pulumi.Output<
Record<SubnetAllocationID, { cidrBlock: pulumi.Input<string> }>
>;
cidrBlock = allocOutput.apply((a) => pulumi.output(a[subnetAllocID].cidrBlock));
} else {
cidrBlock = alloc[subnetAllocID].cidrBlock as pulumi.Input<string>;
}
return {
cidrBlock: cidrBlock,
type: subnetSpec.type,
azName,
subnetName: subnetName(vpcName, subnetSpec, azNum),
tags: subnetSpec.tags,
};
});
});
}
// 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome!
It is possible for cidrBlock to be unknown until the VPC is created. To accommodate this, this change introduces a SubnetSpecPartial type where the type of cidrBlock is now `pulumi.Input<string>`.
Looks like unit tests do not like it if we lift prompt values to pulumi.Output around cidrBlocks and that may delay validations as well. To be safe I've coded up an inputApply that allows to use apply on pulumi.Input without losing prompt-ness. I acknowledge it may be bad form to warp toward test constraints but unfortunately I still haven't worked out a ncie way to test failing Output values per pulumi/pulumi#16665 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome!
The only thing I'm wondering is if we could add some unit tests like these here to make the testing easier&faster: https://www.pulumi.com/docs/using-pulumi/testing/unit/. Might be a cool&fast hackathon project to test those
// 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>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Uff, nice!
// 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome!
return s.map(convSubnetSpecInputsToResolvedSubnetSpecOutputs); | ||
} | ||
|
||
function convSubnetSpecInputsToResolvedSubnetSpecOutputs( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yikes, inputs, outputs, unwrap... Good job with taming that complexity!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've fixed Haskell singletons code before, this is fun and games.
Indeed, testing via the integration loop was an absolute bear. I'll leave this out of this PR but return to it if doing any more serious work around this repo. |
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.
It is also possible to constrain the allocated subnets with subnetSpecs, while still using IPAM to manage the overall cidrBlock range:
Fixes #872
Note that
subnetStrategy: "Auto"
is required with this functionality, and "Legacy" strategy is not supported.