Repository navigation
OCPBUGS-82443: fix(cpo): deduplicate VPC endpoint subnets by AZ #8651
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import ( | |
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "sort" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
@@ -225,6 +226,9 @@ type AWSEndpointServiceReconciler struct { | |
| client.Client | ||
| upsert.CreateOrUpdateProvider | ||
| awsClientBuilder awsClientProvider | ||
|
|
||
| subnetAZMu sync.RWMutex | ||
| subnetAZCache map[string]string | ||
| } | ||
|
|
||
| // awsClientProvider abstracts AWS client creation for testability. | ||
|
|
@@ -582,6 +586,93 @@ func diffIDs(desired []string, existing []string) (added, removed []string) { | |
| return | ||
| } | ||
|
|
||
| // deduplicateSubnetsByAZ ensures at most one subnet per AZ is passed to | ||
| // CreateVpcEndpoint/ModifyVpcEndpoint, since AWS rejects requests with | ||
| // multiple subnets in the same AZ. | ||
| func (r *AWSEndpointServiceReconciler) deduplicateSubnetsByAZ(ctx context.Context, ec2Client awsapi.EC2API, subnetIDs []string) ([]string, error) { | ||
| if len(subnetIDs) <= 1 { | ||
| return subnetIDs, nil | ||
| } | ||
|
Comment on lines
+593
to
+595
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a cluster has 2 subnets, then goes down to 1, the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah good callout — the <= 1 short-circuit skips pruning so the old entry lingers. Since it's just unused cache data in Status it does no actual harm. We could have it prune on every reconcile if we really don't want to have stale data in the status
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No worries, we want to keep the reconcile fast and no-op as much as possible, so I think given how unlikely it is to go from >1 subnet down to 1, it's fine. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could foresee a future where customers start using additional subnets as a "testing ground", where they have a static cluster and then add a new machine pool and subnet for a day, then destroy it later. Would this cause an issue?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With the new in-memory cache approach no. When the NodePool is deleted, the hypershift-operator removes that subnet from AWSEndpointService.Spec.SubnetIDs. The dedup function only looks at subnets in subnetIDs — the stale cache entry for the removed subnet is never read. The only effect is a few bytes of orphaned cache entries in memory, which clear on CPO restart. |
||
|
|
||
| // Read-only path: all subnets already cached, no AWS call needed. | ||
| r.subnetAZMu.RLock() | ||
| allCached := r.subnetAZCache != nil | ||
| if allCached { | ||
| for _, id := range subnetIDs { | ||
| if _, ok := r.subnetAZCache[id]; !ok { | ||
| allCached = false | ||
| break | ||
| } | ||
| } | ||
| } | ||
| if allCached { | ||
| azForSubnet := make(map[string]string, len(subnetIDs)) | ||
| for _, id := range subnetIDs { | ||
| azForSubnet[id] = r.subnetAZCache[id] | ||
| } | ||
| r.subnetAZMu.RUnlock() | ||
| return pickOneSubnetPerAZ(subnetIDs, azForSubnet), nil | ||
| } | ||
| r.subnetAZMu.RUnlock() | ||
|
|
||
| // Write path: new subnets found, call DescribeSubnets to populate cache. | ||
| r.subnetAZMu.Lock() | ||
| if r.subnetAZCache == nil { | ||
| r.subnetAZCache = make(map[string]string) | ||
| } | ||
|
|
||
| // Re-check which subnets are uncached — between the RUnlock and Lock above, | ||
| // another reconcile may have already fetched them. | ||
| var uncached []string | ||
| for _, id := range subnetIDs { | ||
| if _, ok := r.subnetAZCache[id]; !ok { | ||
| uncached = append(uncached, id) | ||
| } | ||
| } | ||
|
|
||
| if len(uncached) > 0 { | ||
| output, err := ec2Client.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{ | ||
| SubnetIds: uncached, | ||
| }) | ||
| if err != nil { | ||
| r.subnetAZMu.Unlock() | ||
| return nil, fmt.Errorf("failed to describe subnets for AZ deduplication: %w", err) | ||
| } | ||
| for _, subnet := range output.Subnets { | ||
| r.subnetAZCache[aws.ToString(subnet.SubnetId)] = aws.ToString(subnet.AvailabilityZone) | ||
| } | ||
| } | ||
|
|
||
| azForSubnet := make(map[string]string, len(subnetIDs)) | ||
| for _, id := range subnetIDs { | ||
| azForSubnet[id] = r.subnetAZCache[id] | ||
| } | ||
| r.subnetAZMu.Unlock() | ||
|
|
||
| return pickOneSubnetPerAZ(subnetIDs, azForSubnet), nil | ||
| } | ||
|
|
||
| func pickOneSubnetPerAZ(subnetIDs []string, azForSubnet map[string]string) []string { | ||
| sorted := make([]string, len(subnetIDs)) | ||
| copy(sorted, subnetIDs) | ||
| sort.Strings(sorted) | ||
|
|
||
| azToSubnet := make(map[string]string) | ||
| for _, id := range sorted { | ||
| az := azForSubnet[id] | ||
| if _, exists := azToSubnet[az]; !exists { | ||
| azToSubnet[az] = id | ||
| } | ||
| } | ||
|
|
||
| deduped := make([]string, 0, len(azToSubnet)) | ||
| for _, id := range azToSubnet { | ||
| deduped = append(deduped, id) | ||
| } | ||
| sort.Strings(deduped) | ||
| return deduped | ||
| } | ||
|
|
||
| func (r *AWSEndpointServiceReconciler) reconcileAWSEndpointService(ctx context.Context, awsEndpointService *hyperv1.AWSEndpointService, hcp *hyperv1.HostedControlPlane, ec2Client awsapi.EC2API, route53Client awsapi.ROUTE53API) error { | ||
| log, err := logr.FromContext(ctx) | ||
| if err != nil { | ||
|
|
@@ -619,6 +710,13 @@ func (r *AWSEndpointServiceReconciler) reconcileAWSEndpointService(ctx context.C | |
| } | ||
|
|
||
| func (r *AWSEndpointServiceReconciler) ensureVPCEndpoint(ctx context.Context, ec2Client awsapi.EC2API, awsEndpointService *hyperv1.AWSEndpointService, hcp *hyperv1.HostedControlPlane, log logr.Logger) (string, []ec2types.DnsEntry, error) { | ||
| deduped, err := r.deduplicateSubnetsByAZ(ctx, ec2Client, awsEndpointService.Spec.SubnetIDs) | ||
| if err != nil { | ||
| log.Error(err, "failed to deduplicate subnets by AZ, proceeding with original list") | ||
| } else { | ||
| awsEndpointService.Spec.SubnetIDs = deduped | ||
| } | ||
|
|
||
| endpointID := awsEndpointService.Status.EndpointID | ||
| if endpointID != "" { | ||
| return r.reconcileExistingEndpoint(ctx, ec2Client, awsEndpointService, endpointID, log) | ||
|
|
||
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 confirm we need this for the
sharedVPCPolicyBinding? I am not sure, just curiousThere 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.
Yes — in SharedVPC mode the CPO assumes the sharedVPCEndpointRole for all EC2 operations.