Skip to content

Commit a0ae72c

Browse files
committed
✨ edge subnets: support Local Zones provisioning networks
Introducing the mechanism to query the zone information from the subnet's AvailabilityZone, saving the ZoneType and the ParentZoneName in the SubnetSpec, both for managed and unmanaged. The ZoneType is used to group the zones from regular and the edge zones. Regular zones are with type 'availability-zone', and the edge zones are types 'local-zone' and 'wavelength-zone'. The following statements are valid for edge subnets: - private subnets supports egress traffic only using NAT Gateway in the region. - IPv6 subnets is not supported in edge zones - subnet tags (kubernetes.io/role/*) for load balancer are not set in edge subnets. Edge subnets should not be elected by CCM to create service load balancers. Use ALB ingress instead. ✨ edge subnets/test: unit for subnets in Local Zones Added unit tests to validate scenarios suing managed and unmanaged subnets in AWS Local Zones, alongside new describe availability zones API calls introduced in the subnet reconciliation loop. ✨ edge subnets/unit: fixes unit tests to describe zone calls The edge subnets feature introduce a new AWS API call to describe zones, DescribeAvailabilityZonesWithContext, to lookup zone attributes based in the zone names in the reconciliator, and the create subnets. The two new calls is required to support unmanaged subnets (BYO VPC), where the method createSubnet() is not called. There are some unit tests calling the create subnet flow, this change add the mock calls for those calls.
1 parent 810bbf4 commit a0ae72c

File tree

4 files changed

+966
-54
lines changed

4 files changed

+966
-54
lines changed

controllers/awscluster_controller_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ func TestAWSClusterReconcilerIntegrationTests(t *testing.T) {
7676
mockedVPCCallsForExistingVPCAndSubnets(m)
7777
mockedCreateSGCalls(false, "vpc-exists", m)
7878
mockedDescribeInstanceCall(m)
79+
mockedDescribeAvailabilityZones(m, []string{"us-east-1c", "us-east-1a"})
80+
7981
// Second iteration: the AWS Cluster object has been patched,
8082
// thus a valid Control Plane Endpoint has been provided
8183
mockedVPCCallsForExistingVPCAndSubnets(m)
@@ -189,7 +191,9 @@ func TestAWSClusterReconcilerIntegrationTests(t *testing.T) {
189191
mockedCreateSGCalls(false, "vpc-exists", m)
190192
mockedCreateLBCalls(t, e)
191193
mockedDescribeInstanceCall(m)
194+
mockedDescribeAvailabilityZones(m, []string{"us-east-1c", "us-east-1a"})
192195
}
196+
193197
expect(ec2Mock.EXPECT(), elbMock.EXPECT())
194198

195199
setup(t)
@@ -298,7 +302,9 @@ func TestAWSClusterReconcilerIntegrationTests(t *testing.T) {
298302
mockedCreateSGCalls(true, "vpc-exists", m)
299303
mockedCreateLBV2Calls(t, e)
300304
mockedDescribeInstanceCall(m)
305+
mockedDescribeAvailabilityZones(m, []string{"us-east-1c", "us-east-1a"})
301306
}
307+
302308
expect(ec2Mock.EXPECT(), elbv2Mock.EXPECT())
303309

304310
g.Expect(testEnv.Create(ctx, &awsCluster)).To(Succeed())
@@ -384,7 +390,9 @@ func TestAWSClusterReconcilerIntegrationTests(t *testing.T) {
384390
mockedCallsForMissingEverything(m, e, "my-managed-subnet-priv", "my-managed-subnet-pub")
385391
mockedCreateSGCalls(false, "vpc-new", m)
386392
mockedDescribeInstanceCall(m)
393+
mockedDescribeAvailabilityZones(m, []string{"us-east-1a"})
387394
}
395+
388396
expect(ec2Mock.EXPECT(), elbMock.EXPECT())
389397

390398
setup(t)
@@ -651,6 +659,26 @@ func mockedDeleteSGCalls(m *mocks.MockEC2APIMockRecorder) {
651659
m.DescribeSecurityGroupsPagesWithContext(context.TODO(), gomock.Any(), gomock.Any()).Return(nil)
652660
}
653661

662+
func mockedDescribeAvailabilityZones(m *mocks.MockEC2APIMockRecorder, zones []string) {
663+
output := &ec2.DescribeAvailabilityZonesOutput{}
664+
matcher := gomock.Any()
665+
666+
if len(zones) > 0 {
667+
input := &ec2.DescribeAvailabilityZonesInput{}
668+
for _, zone := range zones {
669+
input.ZoneNames = append(input.ZoneNames, aws.String(zone))
670+
output.AvailabilityZones = append(output.AvailabilityZones, &ec2.AvailabilityZone{
671+
ZoneName: aws.String(zone),
672+
ZoneType: aws.String("availability-zone"),
673+
})
674+
}
675+
676+
matcher = gomock.Eq(input)
677+
}
678+
m.DescribeAvailabilityZonesWithContext(context.TODO(), matcher).AnyTimes().
679+
Return(output, nil)
680+
}
681+
654682
func createControllerIdentity(g *WithT) *infrav1.AWSClusterControllerIdentity {
655683
controllerIdentity := &infrav1.AWSClusterControllerIdentity{
656684
TypeMeta: metav1.TypeMeta{

controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,18 @@ func mockedCallsForMissingEverything(ec2Rec *mocks.MockEC2APIMockRecorder, subne
309309
Subnets: []*ec2.Subnet{},
310310
}, nil)
311311

312+
zones := []*ec2.AvailabilityZone{}
313+
for _, subnet := range subnets {
314+
zones = append(zones, &ec2.AvailabilityZone{
315+
ZoneName: aws.String(subnet.AvailabilityZone),
316+
ZoneType: aws.String("availability-zone"),
317+
})
318+
}
319+
ec2Rec.DescribeAvailabilityZonesWithContext(context.TODO(), gomock.Any()).
320+
Return(&ec2.DescribeAvailabilityZonesOutput{
321+
AvailabilityZones: zones,
322+
}, nil).MaxTimes(2)
323+
312324
for subnetIndex, subnet := range subnets {
313325
subnetID := fmt.Sprintf("subnet-%d", subnetIndex+1)
314326
var kubernetesRoleTagKey string
@@ -320,6 +332,17 @@ func mockedCallsForMissingEverything(ec2Rec *mocks.MockEC2APIMockRecorder, subne
320332
kubernetesRoleTagKey = "kubernetes.io/role/internal-elb"
321333
capaRoleTagValue = "private"
322334
}
335+
ec2Rec.DescribeAvailabilityZonesWithContext(context.TODO(), &ec2.DescribeAvailabilityZonesInput{
336+
ZoneNames: aws.StringSlice([]string{subnet.AvailabilityZone}),
337+
}).
338+
Return(&ec2.DescribeAvailabilityZonesOutput{
339+
AvailabilityZones: []*ec2.AvailabilityZone{
340+
{
341+
ZoneName: aws.String(subnet.AvailabilityZone),
342+
ZoneType: aws.String("availability-zone"),
343+
},
344+
},
345+
}, nil).MaxTimes(1)
323346
ec2Rec.CreateSubnetWithContext(context.TODO(), gomock.Eq(&ec2.CreateSubnetInput{
324347
VpcId: aws.String("vpc-new"),
325348
CidrBlock: aws.String(subnet.CidrBlock),

pkg/cloud/services/network/subnets.go

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ func (s *Service) reconcileSubnets() error {
5353
defer func() {
5454
s.scope.SetSubnets(subnets)
5555
}()
56-
5756
var (
5857
err error
5958
existing infrav1.Subnets
@@ -145,7 +144,7 @@ func (s *Service) reconcileSubnets() error {
145144
// Make sure tags are up-to-date.
146145
subnetTags := sub.Tags
147146
if err := wait.WaitForWithRetryable(wait.NewBackoff(), func() (bool, error) {
148-
buildParams := s.getSubnetTagParams(unmanagedVPC, existingSubnet.GetResourceID(), existingSubnet.IsPublic, existingSubnet.AvailabilityZone, subnetTags)
147+
buildParams := s.getSubnetTagParams(unmanagedVPC, existingSubnet.GetResourceID(), existingSubnet.IsPublic, existingSubnet.AvailabilityZone, subnetTags, existingSubnet.IsEdge())
149148
tagsBuilder := tags.New(&buildParams, tags.WithEC2(s.EC2Client))
150149
if err := tagsBuilder.Ensure(existingSubnet.Tags); err != nil {
151150
return false, err
@@ -158,7 +157,7 @@ func (s *Service) reconcileSubnets() error {
158157
}
159158

160159
// We may not have a permission to tag unmanaged subnets.
161-
// When tagging unmanaged subnet fails, record an event and proceed.
160+
// When tagging unmanaged subnet fails, record an event and continue checking subnets.
162161
record.Warnf(s.scope.InfraCluster(), "FailedTagSubnet", "Failed tagging unmanaged Subnet %q: %v", existingSubnet.GetResourceID(), err)
163162
continue
164163
}
@@ -175,6 +174,14 @@ func (s *Service) reconcileSubnets() error {
175174
return errors.New("expected at least 1 subnet but got 0")
176175
}
177176

177+
// Reconciling the zone information for the subnets. Subnets are grouped
178+
// by regular zones (availability zones) or edge zones (local zones or wavelength zones)
179+
// based in the zone-type attribute for zone.
180+
if err := s.reconcileZoneInfo(subnets); err != nil {
181+
record.Warnf(s.scope.InfraCluster(), "FailedNoZoneInfo", "Expected the zone attributes to be populated to subnet")
182+
return errors.Wrapf(err, "expected the zone attributes to be populated to subnet")
183+
}
184+
178185
// When the VPC is managed by CAPA, we need to create the subnets.
179186
if !unmanagedVPC {
180187
// Check that we need at least 1 private and 1 public subnet after we have updated the metadata
@@ -209,6 +216,35 @@ func (s *Service) reconcileSubnets() error {
209216
return nil
210217
}
211218

219+
func (s *Service) retrieveZoneInfo(zoneNames []string) ([]*ec2.AvailabilityZone, error) {
220+
zones, err := s.EC2Client.DescribeAvailabilityZonesWithContext(context.TODO(), &ec2.DescribeAvailabilityZonesInput{
221+
ZoneNames: aws.StringSlice(zoneNames),
222+
})
223+
if err != nil {
224+
record.Eventf(s.scope.InfraCluster(), "FailedDescribeAvailableZones", "Failed getting available zones: %v", err)
225+
return nil, errors.Wrap(err, "failed to describe availability zones")
226+
}
227+
228+
return zones.AvailabilityZones, nil
229+
}
230+
231+
// reconcileZoneInfo discover the zones for all subnets, and retrieve
232+
// persist the zone information from resource API, such as Type and
233+
// Parent Zone.
234+
func (s *Service) reconcileZoneInfo(subnets infrav1.Subnets) error {
235+
if len(subnets) > 0 {
236+
zones, err := s.retrieveZoneInfo(subnets.GetUniqueZones())
237+
if err != nil {
238+
return err
239+
}
240+
// Extract zone attributes from resource API for each subnet.
241+
if err := subnets.SetZoneInfo(zones); err != nil {
242+
return err
243+
}
244+
}
245+
return nil
246+
}
247+
212248
func (s *Service) getDefaultSubnets() (infrav1.Subnets, error) {
213249
zones, err := s.getAvailableZones()
214250
if err != nil {
@@ -418,6 +454,26 @@ func (s *Service) createSubnet(sn *infrav1.SubnetSpec) (*infrav1.SubnetSpec, err
418454
sn.Tags["Name"] = sn.ID
419455
}
420456

457+
// Retrieve zone information used later to change the zone attributes.
458+
if len(sn.AvailabilityZone) > 0 {
459+
zones, err := s.retrieveZoneInfo([]string{sn.AvailabilityZone})
460+
if err != nil {
461+
return nil, errors.Wrapf(err, "failed to discover zone information for subnet's zone %q", sn.AvailabilityZone)
462+
}
463+
if err = sn.SetZoneInfo(zones); err != nil {
464+
return nil, errors.Wrapf(err, "failed to update zone information for subnet's zone %q", sn.AvailabilityZone)
465+
}
466+
}
467+
468+
// IPv6 subnets are not generally supported by AWS Local Zones and Wavelength Zones.
469+
// Local Zones have limited zone support for IPv6 subnets:
470+
// https://docs.aws.amazon.com/local-zones/latest/ug/how-local-zones-work.html#considerations
471+
if sn.IsIPv6 && sn.IsEdge() {
472+
err := fmt.Errorf("failed to create subnet: IPv6 is not supported with zone type %q", sn.ZoneType)
473+
record.Warnf(s.scope.InfraCluster(), "FailedCreateSubnet", "Failed creating managed Subnet for edge zones: %v", err)
474+
return nil, err
475+
}
476+
421477
// Build the subnet creation request.
422478
input := &ec2.CreateSubnetInput{
423479
VpcId: aws.String(s.scope.VPC().ID),
@@ -426,7 +482,7 @@ func (s *Service) createSubnet(sn *infrav1.SubnetSpec) (*infrav1.SubnetSpec, err
426482
TagSpecifications: []*ec2.TagSpecification{
427483
tags.BuildParamsToTagSpecification(
428484
ec2.ResourceTypeSubnet,
429-
s.getSubnetTagParams(false, services.TemporaryResourceID, sn.IsPublic, sn.AvailabilityZone, sn.Tags),
485+
s.getSubnetTagParams(false, services.TemporaryResourceID, sn.IsPublic, sn.AvailabilityZone, sn.Tags, sn.IsEdge()),
430486
),
431487
},
432488
}
@@ -544,7 +600,7 @@ func (s *Service) deleteSubnet(id string) error {
544600
return nil
545601
}
546602

547-
func (s *Service) getSubnetTagParams(unmanagedVPC bool, id string, public bool, zone string, manualTags infrav1.Tags) infrav1.BuildParams {
603+
func (s *Service) getSubnetTagParams(unmanagedVPC bool, id string, public bool, zone string, manualTags infrav1.Tags, isEdge bool) infrav1.BuildParams {
548604
var role string
549605
additionalTags := make(map[string]string)
550606

@@ -553,12 +609,16 @@ func (s *Service) getSubnetTagParams(unmanagedVPC bool, id string, public bool,
553609

554610
if public {
555611
role = infrav1.PublicRoleTagValue
556-
additionalTags[externalLoadBalancerTag] = "1"
612+
// Edge subnets should not have ELB tags to be selected by CCM to create load balancers.
613+
if !isEdge {
614+
additionalTags[externalLoadBalancerTag] = "1"
615+
}
557616
} else {
558617
role = infrav1.PrivateRoleTagValue
559-
additionalTags[internalLoadBalancerTag] = "1"
618+
if !isEdge {
619+
additionalTags[internalLoadBalancerTag] = "1"
620+
}
560621
}
561-
562622
// Add tag needed for Service type=LoadBalancer
563623
additionalTags[infrav1.ClusterAWSCloudProviderTagKey(s.scope.KubernetesClusterName())] = string(infrav1.ResourceLifecycleShared)
564624
}

0 commit comments

Comments
 (0)