Skip to content

Commit fe58fe7

Browse files
committed
✨ edge subnets/gateway: add gateway routing for Local Zones
✨ edge subnets/routes: supporting custom routes for Local Zones Isolate the route table lookup into dedicated methods for private and public subnets to allow more complex requirements for edge zones, as well introduce unit tests for each scenario to cover edge cases. There is no change for private and public subnets for regular zones (standard flow), and the routes will be assigned accordainly the existing flow: private subnets uses nat gateways per public zone, and internet gateway for public zones's tables. For private and public subnets in edge zones, the following changes is introduced according to each rule: General: - IPv6 subnets is not be supported in AWS Local Zones, zone, consequently no ip6 routes will be created - nat gateways is not supported, default gateway's route for private subnets will use nat gateways from the zones in the Region (availability-zone's zone type) - one route table by zone's role by zone (standard flow) Private tables for Local Zones: - default route's gateways is assigned using nat gateway created in the region (availability-zones). Public tables for Local Zones: - default route's gateway is assigned using internet gateway The changes in the standard flow (without edge subnets' support) was isolated in the PR #4900 ✨ edge subnets/nat-gw: support private routing in Local Zones Introduce the support to lookup a nat gateway for edge zones when creating private subnets. Currently CAPA requires a NAT Gateway in the public subnet for each zone which requires private subnets to define default nat gateway in the private route table for each zone. NAT Gateway resource isn't globally supported by Local Zones, thus private subnets in Local Zones are created with default route gateway using a nat gateway selected in the Region (regular availability zones) based in the Parent Zone* for the edge subnet. *each edge zone is "tied" to a zone named "Parent Zone", a zone type availability-zone (regular zones) in the region.
1 parent a0ae72c commit fe58fe7

File tree

4 files changed

+436
-12
lines changed

4 files changed

+436
-12
lines changed

pkg/cloud/services/network/natgateways.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package network
1919
import (
2020
"context"
2121
"fmt"
22+
"sort"
2223

2324
"github.com/aws/aws-sdk-go/aws"
2425
"github.com/aws/aws-sdk-go/service/ec2"
@@ -321,23 +322,55 @@ func (s *Service) deleteNatGateway(id string) error {
321322
return nil
322323
}
323324

325+
// getNatGatewayForSubnet return the nat gateway for private subnets.
326+
// NAT gateways in edge zones (Local Zones) are not globally supported,
327+
// private subnets in those locations uses Nat Gateways from the
328+
// Parent Zone or, when not available, the first zone in the Region.
324329
func (s *Service) getNatGatewayForSubnet(sn *infrav1.SubnetSpec) (string, error) {
325330
if sn.IsPublic {
326331
return "", errors.Errorf("cannot get NAT gateway for a public subnet, got id %q", sn.GetResourceID())
327332
}
328333

329-
azGateways := make(map[string][]string)
334+
// Check if public edge subnet in the edge zone has nat gateway
335+
azGateways := make(map[string]string)
336+
azNames := []string{}
330337
for _, psn := range s.scope.Subnets().FilterPublic() {
331338
if psn.NatGatewayID == nil {
332339
continue
333340
}
334-
335-
azGateways[psn.AvailabilityZone] = append(azGateways[psn.AvailabilityZone], *psn.NatGatewayID)
341+
if _, ok := azGateways[psn.AvailabilityZone]; !ok {
342+
azGateways[psn.AvailabilityZone] = *psn.NatGatewayID
343+
azNames = append(azNames, psn.AvailabilityZone)
344+
}
336345
}
337346

338347
if gws, ok := azGateways[sn.AvailabilityZone]; ok && len(gws) > 0 {
339-
return gws[0], nil
348+
return gws, nil
349+
}
350+
351+
// return error when no gateway found for regular zones, availability-zone zone type.
352+
if !sn.IsEdge() {
353+
return "", errors.Errorf("no nat gateways available in %q for private subnet %q", sn.AvailabilityZone, sn.GetResourceID())
354+
}
355+
356+
// edge zones only: trying to find nat gateway for Local or Wavelength zone based in the zone type.
357+
358+
// Check if the parent zone public subnet has nat gateway
359+
if sn.ParentZoneName != nil {
360+
if gws, ok := azGateways[aws.StringValue(sn.ParentZoneName)]; ok && len(gws) > 0 {
361+
return gws, nil
362+
}
363+
}
364+
365+
// Get the first public subnet's nat gateway available
366+
sort.Strings(azNames)
367+
for _, zone := range azNames {
368+
gw := azGateways[zone]
369+
if len(gw) > 0 {
370+
s.scope.Debug("Assigning route table", "table ID", gw, "source zone", zone, "target zone", sn.AvailabilityZone)
371+
return gw, nil
372+
}
340373
}
341374

342-
return "", errors.Errorf("no nat gateways available in %q for private subnet %q, current state: %+v", sn.AvailabilityZone, sn.GetResourceID(), azGateways)
375+
return "", errors.Errorf("no nat gateways available in %q for private edge subnet %q, current state: %+v", sn.AvailabilityZone, sn.GetResourceID(), azGateways)
343376
}

pkg/cloud/services/network/natgateways_test.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
. "github.com/onsi/gomega"
2828
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2929
"k8s.io/apimachinery/pkg/runtime"
30+
"k8s.io/utils/ptr"
3031
"sigs.k8s.io/controller-runtime/pkg/client/fake"
3132

3233
infrav1 "sigs.k8s.io/cluster-api-provider-aws/v2/api/v1beta2"
@@ -728,3 +729,262 @@ var mockDescribeNatGatewaysOutput = func(ctx context.Context, _, y interface{},
728729
SubnetId: aws.String("subnet-1"),
729730
}}}, true)
730731
}
732+
733+
func TestGetdNatGatewayForEdgeSubnet(t *testing.T) {
734+
subnetsSpec := infrav1.Subnets{
735+
{
736+
ID: "subnet-az-1x-private",
737+
AvailabilityZone: "us-east-1x",
738+
IsPublic: false,
739+
},
740+
{
741+
ID: "subnet-az-1x-public",
742+
AvailabilityZone: "us-east-1x",
743+
IsPublic: true,
744+
NatGatewayID: aws.String("natgw-az-1b-last"),
745+
},
746+
{
747+
ID: "subnet-az-1a-private",
748+
AvailabilityZone: "us-east-1a",
749+
IsPublic: false,
750+
},
751+
{
752+
ID: "subnet-az-1a-public",
753+
AvailabilityZone: "us-east-1a",
754+
IsPublic: true,
755+
NatGatewayID: aws.String("natgw-az-1b-first"),
756+
},
757+
{
758+
ID: "subnet-az-1b-private",
759+
AvailabilityZone: "us-east-1b",
760+
IsPublic: false,
761+
},
762+
{
763+
ID: "subnet-az-1b-public",
764+
AvailabilityZone: "us-east-1b",
765+
IsPublic: true,
766+
NatGatewayID: aws.String("natgw-az-1b-second"),
767+
},
768+
{
769+
ID: "subnet-az-1p-private",
770+
AvailabilityZone: "us-east-1p",
771+
IsPublic: false,
772+
},
773+
}
774+
775+
mockCtrl := gomock.NewController(t)
776+
defer mockCtrl.Finish()
777+
778+
testCases := []struct {
779+
name string
780+
spec infrav1.Subnets
781+
input infrav1.SubnetSpec
782+
expect string
783+
expectErr bool
784+
expectErrMessage string
785+
}{
786+
{
787+
name: "zone availability-zone, valid nat gateway",
788+
input: infrav1.SubnetSpec{
789+
ID: "subnet-az-1b-private",
790+
AvailabilityZone: "us-east-1b",
791+
IsPublic: false,
792+
},
793+
expect: "natgw-az-1b-second",
794+
},
795+
{
796+
name: "zone availability-zone, valid nat gateway",
797+
input: infrav1.SubnetSpec{
798+
ID: "subnet-az-1a-private",
799+
AvailabilityZone: "us-east-1a",
800+
IsPublic: false,
801+
},
802+
expect: "natgw-az-1b-first",
803+
},
804+
{
805+
name: "zone availability-zone, valid nat gateway",
806+
input: infrav1.SubnetSpec{
807+
ID: "subnet-az-1x-private",
808+
AvailabilityZone: "us-east-1x",
809+
IsPublic: false,
810+
},
811+
expect: "natgw-az-1b-last",
812+
},
813+
{
814+
name: "zone local-zone, valid nat gateway from parent",
815+
input: infrav1.SubnetSpec{
816+
ID: "subnet-lz-nyc1a-private",
817+
AvailabilityZone: "us-east-1-nyc-1a",
818+
IsPublic: false,
819+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
820+
ParentZoneName: aws.String("us-east-1a"),
821+
},
822+
expect: "natgw-az-1b-first",
823+
},
824+
{
825+
name: "zone local-zone, valid nat gateway from parent",
826+
input: infrav1.SubnetSpec{
827+
ID: "subnet-lz-nyc1a-private",
828+
AvailabilityZone: "us-east-1-nyc-1a",
829+
IsPublic: false,
830+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
831+
ParentZoneName: aws.String("us-east-1x"),
832+
},
833+
expect: "natgw-az-1b-last",
834+
},
835+
{
836+
name: "zone local-zone, valid nat gateway from fallback",
837+
input: infrav1.SubnetSpec{
838+
ID: "subnet-lz-nyc1a-private",
839+
AvailabilityZone: "us-east-1-nyc-1a",
840+
IsPublic: false,
841+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
842+
ParentZoneName: aws.String("us-east-1-notAvailable"),
843+
},
844+
expect: "natgw-az-1b-first",
845+
},
846+
{
847+
name: "edge zones without NAT GW support, no public subnet and NAT Gateway for the parent zone, return first nat gateway available",
848+
input: infrav1.SubnetSpec{
849+
ID: "subnet-7",
850+
AvailabilityZone: "us-east-1-nyc-1a",
851+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
852+
},
853+
expect: "natgw-az-1b-first",
854+
},
855+
{
856+
name: "edge zones without NAT GW support, no public subnet and NAT Gateway for the parent zone, return first nat gateway available",
857+
input: infrav1.SubnetSpec{
858+
ID: "subnet-7",
859+
CidrBlock: "10.0.10.0/24",
860+
AvailabilityZone: "us-east-1-nyc-1a",
861+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
862+
ParentZoneName: aws.String("us-east-1-notFound"),
863+
},
864+
expect: "natgw-az-1b-first",
865+
},
866+
{
867+
name: "edge zones without NAT GW support, valid public subnet and NAT Gateway for the parent zone, return parent's zone nat gateway",
868+
input: infrav1.SubnetSpec{
869+
ID: "subnet-lz-7",
870+
AvailabilityZone: "us-east-1-nyc-1a",
871+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
872+
ParentZoneName: aws.String("us-east-1b"),
873+
},
874+
expect: "natgw-az-1b-second",
875+
},
876+
// errors
877+
{
878+
name: "error if the subnet is public",
879+
input: infrav1.SubnetSpec{
880+
ID: "subnet-az-1-public",
881+
AvailabilityZone: "us-east-1a",
882+
IsPublic: true,
883+
},
884+
expectErr: true,
885+
expectErrMessage: `cannot get NAT gateway for a public subnet, got id "subnet-az-1-public"`,
886+
},
887+
{
888+
name: "error if the subnet is public",
889+
input: infrav1.SubnetSpec{
890+
ID: "subnet-lz-1-public",
891+
AvailabilityZone: "us-east-1-nyc-1a",
892+
IsPublic: true,
893+
},
894+
expectErr: true,
895+
expectErrMessage: `cannot get NAT gateway for a public subnet, got id "subnet-lz-1-public"`,
896+
},
897+
{
898+
name: "error if there are no nat gateways available in the subnets",
899+
spec: infrav1.Subnets{},
900+
input: infrav1.SubnetSpec{
901+
ID: "subnet-az-1-private",
902+
AvailabilityZone: "us-east-1p",
903+
IsPublic: false,
904+
},
905+
expectErr: true,
906+
expectErrMessage: `no nat gateways available in "us-east-1p" for private subnet "subnet-az-1-private"`,
907+
},
908+
{
909+
name: "error if there are no nat gateways available in the subnets",
910+
spec: infrav1.Subnets{},
911+
input: infrav1.SubnetSpec{
912+
ID: "subnet-lz-1",
913+
AvailabilityZone: "us-east-1-nyc-1a",
914+
IsPublic: false,
915+
ZoneType: ptr.To(infrav1.ZoneTypeLocalZone),
916+
},
917+
expectErr: true,
918+
expectErrMessage: `no nat gateways available in "us-east-1-nyc-1a" for private edge subnet "subnet-lz-1", current state: map[]`,
919+
},
920+
{
921+
name: "error if the subnet is public",
922+
input: infrav1.SubnetSpec{
923+
ID: "subnet-lz-1",
924+
AvailabilityZone: "us-east-1-nyc-1a",
925+
IsPublic: true,
926+
},
927+
expectErr: true,
928+
expectErrMessage: `cannot get NAT gateway for a public subnet, got id "subnet-lz-1"`,
929+
},
930+
}
931+
932+
for idx, tc := range testCases {
933+
t.Run(tc.name, func(t *testing.T) {
934+
g := NewWithT(t)
935+
subnets := subnetsSpec
936+
if tc.spec != nil {
937+
subnets = tc.spec
938+
}
939+
scheme := runtime.NewScheme()
940+
_ = infrav1.AddToScheme(scheme)
941+
awsCluster := &infrav1.AWSCluster{
942+
ObjectMeta: metav1.ObjectMeta{Name: "test"},
943+
Spec: infrav1.AWSClusterSpec{
944+
NetworkSpec: infrav1.NetworkSpec{
945+
VPC: infrav1.VPCSpec{
946+
ID: subnetsVPCID,
947+
Tags: infrav1.Tags{
948+
infrav1.ClusterTagKey("test-cluster"): "owned",
949+
},
950+
},
951+
Subnets: subnets,
952+
},
953+
},
954+
}
955+
956+
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(awsCluster).WithStatusSubresource(awsCluster).Build()
957+
958+
clusterScope, err := scope.NewClusterScope(scope.ClusterScopeParams{
959+
Cluster: &clusterv1.Cluster{
960+
ObjectMeta: metav1.ObjectMeta{Name: "test-cluster"},
961+
},
962+
AWSCluster: awsCluster,
963+
Client: client,
964+
})
965+
if err != nil {
966+
t.Fatalf("Failed to create test context: %v", err)
967+
return
968+
}
969+
970+
s := NewService(clusterScope)
971+
972+
id, err := s.getNatGatewayForSubnet(&testCases[idx].input)
973+
974+
if tc.expectErr && err == nil {
975+
t.Fatal("expected error but got no error")
976+
}
977+
if err != nil && len(tc.expectErrMessage) > 0 {
978+
if err.Error() != tc.expectErrMessage {
979+
t.Fatalf("got an unexpected error message:\nwant: %v\n got: %v\n", tc.expectErrMessage, err.Error())
980+
}
981+
}
982+
if !tc.expectErr && err != nil {
983+
t.Fatalf("got an unexpected error: %v", err)
984+
}
985+
if len(tc.expect) > 0 {
986+
g.Expect(id).To(Equal(tc.expect))
987+
}
988+
})
989+
}
990+
}

pkg/cloud/services/network/routetables.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,10 +369,13 @@ func (s *Service) getRouteTableTagParams(id string, public bool, zone string) in
369369
func (s *Service) getRoutesToPublicSubnet(sn *infrav1.SubnetSpec) ([]*ec2.CreateRouteInput, error) {
370370
var routes []*ec2.CreateRouteInput
371371

372-
if s.scope.VPC().InternetGatewayID == nil {
373-
return routes, errors.Errorf("failed to create routing tables: internet gateway for %q is nil", s.scope.VPC().ID)
372+
if sn.IsEdge() && sn.IsIPv6 {
373+
return nil, errors.Errorf("can't determine routes for unsupported ipv6 subnet in zone type %q", sn.ZoneType)
374374
}
375375

376+
if s.scope.VPC().InternetGatewayID == nil {
377+
return routes, errors.Errorf("failed to create routing tables: internet gateway for VPC %q is not present", s.scope.VPC().ID)
378+
}
376379
routes = append(routes, s.getGatewayPublicRoute())
377380
if sn.IsIPv6 {
378381
routes = append(routes, s.getGatewayPublicIPv6Route())
@@ -382,7 +385,13 @@ func (s *Service) getRoutesToPublicSubnet(sn *infrav1.SubnetSpec) ([]*ec2.Create
382385
}
383386

384387
func (s *Service) getRoutesToPrivateSubnet(sn *infrav1.SubnetSpec) (routes []*ec2.CreateRouteInput, err error) {
385-
natGatewayID, err := s.getNatGatewayForSubnet(sn)
388+
var natGatewayID string
389+
390+
if sn.IsEdge() && sn.IsIPv6 {
391+
return nil, errors.Errorf("can't determine routes for unsupported ipv6 subnet in zone type %q", sn.ZoneType)
392+
}
393+
394+
natGatewayID, err = s.getNatGatewayForSubnet(sn)
386395
if err != nil {
387396
return routes, err
388397
}

0 commit comments

Comments
 (0)