Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/v1beta1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,15 @@ type LoadBalancerSpec struct {
BackendPool BackendPool `json:"backendPool,omitempty"`

LoadBalancerClassSpec `json:",inline"`

// AvailabilityZones is a list of availability zones for the load balancer.
// When specified for an internal load balancer, the frontend IP configuration
// will be zone-redundant across the specified zones.
// For public load balancers, this should be set on the associated public IP addresses instead.
// +optional
// +listType=set
// +kubebuilder:validation:MaxItems=3
AvailabilityZones []string `json:"availabilityZones,omitempty"`
}

// SKU defines an Azure load balancer SKU.
Expand Down
5 changes: 5 additions & 0 deletions api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 27 additions & 3 deletions azure/scope/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ func (s *ClusterScope) PublicIPSpecs() []azure.ResourceSpecGetter {
if s.IsAPIServerPrivate() {
// Public IP specs for control plane outbound lb
if s.ControlPlaneOutboundLB() != nil {
failureDomains := s.getPublicIPFailureDomains(s.ControlPlaneOutboundLB().AvailabilityZones)
for _, ip := range s.ControlPlaneOutboundLB().FrontendIPs {
controlPlaneOutboundIPSpecs = append(controlPlaneOutboundIPSpecs, &publicips.PublicIPSpec{
Name: ip.PublicIP.Name,
Expand All @@ -167,13 +168,14 @@ func (s *ClusterScope) PublicIPSpecs() []azure.ResourceSpecGetter {
IsIPv6: false, // Set to default value
Location: s.Location(),
ExtendedLocation: s.ExtendedLocation(),
FailureDomains: s.FailureDomains(),
FailureDomains: failureDomains,
AdditionalTags: s.AdditionalTags(),
})
}
}
} else {
if s.ControlPlaneEnabled() {
failureDomains := s.getPublicIPFailureDomains(s.APIServerLB().AvailabilityZones)
controlPlaneOutboundIPSpecs = []azure.ResourceSpecGetter{
&publicips.PublicIPSpec{
Name: s.APIServerPublicIP().Name,
Expand All @@ -183,7 +185,7 @@ func (s *ClusterScope) PublicIPSpecs() []azure.ResourceSpecGetter {
ClusterName: s.ClusterName(),
Location: s.Location(),
ExtendedLocation: s.ExtendedLocation(),
FailureDomains: s.FailureDomains(),
FailureDomains: failureDomains,
AdditionalTags: s.AdditionalTags(),
IPTags: s.APIServerPublicIP().IPTags,
},
Expand All @@ -194,6 +196,7 @@ func (s *ClusterScope) PublicIPSpecs() []azure.ResourceSpecGetter {

// Public IP specs for node outbound lb
if s.NodeOutboundLB() != nil {
failureDomains := s.getPublicIPFailureDomains(s.NodeOutboundLB().AvailabilityZones)
for _, ip := range s.NodeOutboundLB().FrontendIPs {
publicIPSpecs = append(publicIPSpecs, &publicips.PublicIPSpec{
Name: ip.PublicIP.Name,
Expand All @@ -203,7 +206,7 @@ func (s *ClusterScope) PublicIPSpecs() []azure.ResourceSpecGetter {
IsIPv6: false, // Set to default value
Location: s.Location(),
ExtendedLocation: s.ExtendedLocation(),
FailureDomains: s.FailureDomains(),
FailureDomains: failureDomains,
AdditionalTags: s.AdditionalTags(),
})
}
Expand Down Expand Up @@ -270,6 +273,7 @@ func (s *ClusterScope) LBSpecs() []azure.ResourceSpecGetter {
IdleTimeoutInMinutes: s.APIServerLB().IdleTimeoutInMinutes,
AdditionalTags: s.AdditionalTags(),
AdditionalPorts: s.AdditionalAPIServerLBPorts(),
AvailabilityZones: s.APIServerLB().AvailabilityZones,
}

if s.APIServerLB().FrontendIPs != nil {
Expand Down Expand Up @@ -304,6 +308,7 @@ func (s *ClusterScope) LBSpecs() []azure.ResourceSpecGetter {
IdleTimeoutInMinutes: s.APIServerLB().IdleTimeoutInMinutes,
AdditionalTags: s.AdditionalTags(),
AdditionalPorts: s.AdditionalAPIServerLBPorts(),
AvailabilityZones: s.APIServerLB().AvailabilityZones,
}

privateIPFound := false
Expand Down Expand Up @@ -351,6 +356,7 @@ func (s *ClusterScope) LBSpecs() []azure.ResourceSpecGetter {
IdleTimeoutInMinutes: s.NodeOutboundLB().IdleTimeoutInMinutes,
Role: infrav1.NodeOutboundRole,
AdditionalTags: s.AdditionalTags(),
AvailabilityZones: s.NodeOutboundLB().AvailabilityZones,
})
}

Expand All @@ -372,6 +378,7 @@ func (s *ClusterScope) LBSpecs() []azure.ResourceSpecGetter {
IdleTimeoutInMinutes: s.ControlPlaneOutboundLB().IdleTimeoutInMinutes,
Role: infrav1.ControlPlaneOutboundRole,
AdditionalTags: s.AdditionalTags(),
AvailabilityZones: s.ControlPlaneOutboundLB().AvailabilityZones,
})
}

Expand Down Expand Up @@ -1021,6 +1028,23 @@ func (s *ClusterScope) FailureDomains() []*string {
return fds
}

// getPublicIPFailureDomains returns the failure domains to use for public IP addresses.
// If availability zones are explicitly specified on the load balancer, those zones are used.
// Otherwise, falls back to the cluster's failure domains.
//
// This is important because for public load balancers, zone-redundancy is achieved by setting
// zones on the public IP address resource, NOT on the load balancer's frontend IP configuration.
// Azure returns error "LoadBalancerFrontendIPConfigCannotHaveZoneWhenReferencingPublicIPAddress"
// if zones are specified on a frontend that references a public IP.
//
// See https://learn.microsoft.com/en-us/azure/reliability/reliability-load-balancer for details.
func (s *ClusterScope) getPublicIPFailureDomains(lbAvailabilityZones []string) []*string {
if len(lbAvailabilityZones) > 0 {
return azure.PtrSlice(&lbAvailabilityZones)
}
return s.FailureDomains()
}

// SetControlPlaneSecurityRules sets the default security rules of the control plane subnet.
// Note that this is not done in a webhook as it requires a valid Cluster object to exist to get the API Server port.
func (s *ClusterScope) SetControlPlaneSecurityRules() {
Expand Down
143 changes: 143 additions & 0 deletions azure/scope/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,149 @@ func TestPublicIPSpecs(t *testing.T) {
},
},
},
{
name: "Azure cluster with public LB and AvailabilityZones uses LB zones instead of failure domains",
azureCluster: &infrav1.AzureCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cluster",
},
Status: infrav1.AzureClusterStatus{
FailureDomains: map[string]clusterv1beta1.FailureDomainSpec{
"failure-domain-id-1": {},
"failure-domain-id-2": {},
"failure-domain-id-3": {},
},
},
Spec: infrav1.AzureClusterSpec{
ResourceGroup: "my-rg",
ControlPlaneEnabled: true,
AzureClusterClassSpec: infrav1.AzureClusterClassSpec{
Location: "centralIndia",
AdditionalTags: infrav1.Tags{
"Name": "my-publicip-ipv6",
"sigs.k8s.io_cluster-api-provider-azure_cluster_my-cluster": "owned",
},
},
NetworkSpec: infrav1.NetworkSpec{
APIServerLB: &infrav1.LoadBalancerSpec{
FrontendIPs: []infrav1.FrontendIP{
{
PublicIP: &infrav1.PublicIPSpec{
Name: "my-apiserver-ip",
DNSName: "my-cluster.centralIndia.cloudapp.azure.com",
},
},
},
AvailabilityZones: []string{"1", "2", "3"},
LoadBalancerClassSpec: infrav1.LoadBalancerClassSpec{
Type: infrav1.Public,
},
},
},
},
},
expectedPublicIPSpec: []azure.ResourceSpecGetter{
&publicips.PublicIPSpec{
Name: "my-apiserver-ip",
ResourceGroup: "my-rg",
DNSName: "my-cluster.centralIndia.cloudapp.azure.com",
IsIPv6: false,
ClusterName: "my-cluster",
Location: "centralIndia",
FailureDomains: []*string{ptr.To("1"), ptr.To("2"), ptr.To("3")},
AdditionalTags: infrav1.Tags{
"Name": "my-publicip-ipv6",
"sigs.k8s.io_cluster-api-provider-azure_cluster_my-cluster": "owned",
},
},
},
},
{
name: "Azure cluster with internal LB and AvailabilityZones and node outbound LB with zones",
azureCluster: &infrav1.AzureCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "my-cluster",
},
Status: infrav1.AzureClusterStatus{
FailureDomains: map[string]clusterv1beta1.FailureDomainSpec{
"failure-domain-id-1": {},
"failure-domain-id-2": {},
"failure-domain-id-3": {},
},
},
Spec: infrav1.AzureClusterSpec{
ResourceGroup: "my-rg",
ControlPlaneEnabled: true,
AzureClusterClassSpec: infrav1.AzureClusterClassSpec{
Location: "centralIndia",
AdditionalTags: infrav1.Tags{
"Name": "my-publicip-ipv6",
"sigs.k8s.io_cluster-api-provider-azure_cluster_my-cluster": "owned",
},
},
NetworkSpec: infrav1.NetworkSpec{
APIServerLB: &infrav1.LoadBalancerSpec{
AvailabilityZones: []string{"1", "2", "3"},
LoadBalancerClassSpec: infrav1.LoadBalancerClassSpec{
Type: infrav1.Internal,
},
},
ControlPlaneOutboundLB: &infrav1.LoadBalancerSpec{
FrontendIPsCount: ptr.To[int32](1),
FrontendIPs: []infrav1.FrontendIP{
{
Name: "cp-outbound-frontend",
PublicIP: &infrav1.PublicIPSpec{
Name: "pip-cp-outbound",
},
},
},
AvailabilityZones: []string{"1", "2"},
},
NodeOutboundLB: &infrav1.LoadBalancerSpec{
FrontendIPsCount: ptr.To[int32](1),
FrontendIPs: []infrav1.FrontendIP{
{
Name: "node-outbound-frontend",
PublicIP: &infrav1.PublicIPSpec{
Name: "pip-node-outbound",
},
},
},
AvailabilityZones: []string{"1", "3"},
},
},
},
},
expectedPublicIPSpec: []azure.ResourceSpecGetter{
&publicips.PublicIPSpec{
Name: "pip-cp-outbound",
ResourceGroup: "my-rg",
DNSName: "",
IsIPv6: false,
ClusterName: "my-cluster",
Location: "centralIndia",
FailureDomains: []*string{ptr.To("1"), ptr.To("2")},
AdditionalTags: infrav1.Tags{
"Name": "my-publicip-ipv6",
"sigs.k8s.io_cluster-api-provider-azure_cluster_my-cluster": "owned",
},
},
&publicips.PublicIPSpec{
Name: "pip-node-outbound",
ResourceGroup: "my-rg",
DNSName: "",
IsIPv6: false,
ClusterName: "my-cluster",
Location: "centralIndia",
FailureDomains: []*string{ptr.To("1"), ptr.To("3")},
AdditionalTags: infrav1.Tags{
"Name": "my-publicip-ipv6",
"sigs.k8s.io_cluster-api-provider-azure_cluster_my-cluster": "owned",
},
},
},
},
}

for _, tc := range tests {
Expand Down
53 changes: 53 additions & 0 deletions azure/services/loadbalancers/loadbalancers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,59 @@ var (
APIServerPort: 6443,
}

fakeInternalAPILBSpecWithZones = LBSpec{
Name: "my-private-lb",
ResourceGroup: "my-rg",
SubscriptionID: "123",
ClusterName: "my-cluster",
Location: "my-location",
Role: infrav1.APIServerRole,
Type: infrav1.Internal,
SKU: infrav1.SKUStandard,
SubnetName: "my-cp-subnet",
BackendPoolName: "my-private-lb-backendPool",
IdleTimeoutInMinutes: ptr.To[int32](4),
AvailabilityZones: []string{"1", "2", "3"},
FrontendIPConfigs: []infrav1.FrontendIP{
{
Name: "my-private-lb-frontEnd",
FrontendIPClass: infrav1.FrontendIPClass{
PrivateIPAddress: "10.0.0.10",
},
},
},
APIServerPort: 6443,
}

// fakePublicAPILBSpecWithZones tests that zones are NOT applied to public LB frontends.
// Azure does not allow zones on frontend IP configurations that reference public IP addresses.
// Instead, zone-redundancy for public LBs is achieved by setting zones on the public IP itself.
// See: https://learn.microsoft.com/en-us/azure/reliability/reliability-load-balancer#zone-redundant-load-balancer
fakePublicAPILBSpecWithZones = LBSpec{
Name: "my-publiclb",
ResourceGroup: "my-rg",
SubscriptionID: "123",
ClusterName: "my-cluster",
Location: "my-location",
Role: infrav1.APIServerRole,
Type: infrav1.Public,
SKU: infrav1.SKUStandard,
SubnetName: "my-cp-subnet",
BackendPoolName: "my-publiclb-backendPool",
IdleTimeoutInMinutes: ptr.To[int32](4),
AvailabilityZones: []string{"1", "2", "3"}, // These should NOT be applied to frontend
FrontendIPConfigs: []infrav1.FrontendIP{
{
Name: "my-publiclb-frontEnd",
PublicIP: &infrav1.PublicIPSpec{
Name: "my-publicip",
DNSName: "my-cluster.12345.mydomain.com",
},
},
},
APIServerPort: 6443,
}

fakeNodeOutboundLBSpec = LBSpec{
Name: "my-cluster",
ResourceGroup: "my-rg",
Expand Down
23 changes: 23 additions & 0 deletions azure/services/loadbalancers/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type LBSpec struct {
IdleTimeoutInMinutes *int32
AdditionalTags map[string]string
AdditionalPorts []infrav1.LoadBalancerPort
AvailabilityZones []string
}

// ResourceName returns the name of the load balancer.
Expand Down Expand Up @@ -167,6 +168,27 @@ func (s *LBSpec) Parameters(_ context.Context, existing any) (parameters any, er
func getFrontendIPConfigs(lbSpec LBSpec) ([]*armnetwork.FrontendIPConfiguration, []*armnetwork.SubResource) {
frontendIPConfigurations := make([]*armnetwork.FrontendIPConfiguration, 0)
frontendIDs := make([]*armnetwork.SubResource, 0)

// Convert availability zones to []*string for Azure SDK.
// IMPORTANT: Zones can only be set on frontend IP configurations for internal load balancers
// (where the frontend references a subnet). For public load balancers, zone-redundancy is
// achieved by setting zones on the associated public IP address resource, NOT on the load
// balancer's frontend IP configuration.
//
// Azure returns error "LoadBalancerFrontendIPConfigCannotHaveZoneWhenReferencingPublicIPAddress"
// if zones are specified on a frontend that references a public IP.
//
// See: https://learn.microsoft.com/en-us/azure/reliability/reliability-load-balancer#zone-redundant-load-balancer
// Section: "Zone-redundant load balancer" - "For public load balancers, if the public IP in the
// Load balancer's frontend is zone redundant then the load balancer is also zone-redundant."
var zones []*string
if len(lbSpec.AvailabilityZones) > 0 && lbSpec.Type == infrav1.Internal {
zones = make([]*string, len(lbSpec.AvailabilityZones))
for i, zone := range lbSpec.AvailabilityZones {
zones[i] = ptr.To(zone)
}
}

for _, ipConfig := range lbSpec.FrontendIPConfigs {
var properties armnetwork.FrontendIPConfigurationPropertiesFormat
if lbSpec.Type == infrav1.Internal {
Expand All @@ -187,6 +209,7 @@ func getFrontendIPConfigs(lbSpec LBSpec) ([]*armnetwork.FrontendIPConfiguration,
frontendIPConfigurations = append(frontendIPConfigurations, &armnetwork.FrontendIPConfiguration{
Properties: &properties,
Name: ptr.To(ipConfig.Name),
Zones: zones,
})
frontendIDs = append(frontendIDs, &armnetwork.SubResource{
ID: ptr.To(azure.FrontendIPConfigID(lbSpec.SubscriptionID, lbSpec.ResourceGroup, lbSpec.Name, ipConfig.Name)),
Expand Down
Loading
Loading