Repository: kubernetes/kops (go)

Issue:
Add the ability to use Availability zones for Azure provider
/kind feature
/area provider/azure

Azure supports Availability Zones for  Virtual Machine Scale Sets: https://docs.microsoft.com/en-us/azure/virtual-machine-scale-sets/virtual-machine-scale-sets-use-availability-zones
In terms of resilience and high availability, it would be good to have the option to use  Availability Zones for mater and node Scale Sets.

So I offer to add the following:
1. specify the Azure Availability Zones flag:
--azure-availability-zones Set the number of  Availability Zones (max 3)
2. use "availabilityZones:"  in the kops config file.



Pull request description:
Azure - support VMSS availability zones
Azure's subnets are regional so we use similar functionality to GCE where we reference the InstanceGroup's zones rather than a subnet's zone.
IG Zones are already populated from `--zones` and `--master-zones` at cluster creation time here:

https://github.com/kubernetes/kops/blob/b35803789627345f58eb979cf53fa87bfe422f28/upup/pkg/fi/cloudup/new_cluster.go#L682-L684

fixes #11955

Patch:
diff --git a/pkg/model/azuremodel/vmscaleset.go b/pkg/model/azuremodel/vmscaleset.go
index 9cd716ca7c..c06efcf049 100644
--- a/pkg/model/azuremodel/vmscaleset.go
+++ b/pkg/model/azuremodel/vmscaleset.go
@@ -26,6 +26,7 @@ import (
 	"k8s.io/kops/pkg/model"
 	"k8s.io/kops/pkg/model/defaults"
 	"k8s.io/kops/upup/pkg/fi"
+	"k8s.io/kops/upup/pkg/fi/cloudup/azure"
 	"k8s.io/kops/upup/pkg/fi/cloudup/azuretasks"
 )
 
@@ -70,6 +71,14 @@ func (b *VMScaleSetModelBuilder) buildVMScaleSetTask(
 	name string,
 	ig *kops.InstanceGroup,
 ) (*azuretasks.VMScaleSet, error) {
+	var azNumbers []string
+	for _, zone := range ig.Spec.Zones {
+		az, err := azure.ZoneToAvailabilityZoneNumber(zone)
+		if err != nil {
+			return nil, err
+		}
+		azNumbers = append(azNumbers, az)
+	}
 	t := &azuretasks.VMScaleSet{
 		Name:               fi.String(name),
 		Lifecycle:          b.Lifecycle,
@@ -78,6 +87,7 @@ func (b *VMScaleSetModelBuilder) buildVMScaleSetTask(
 		SKUName:            fi.String(ig.Spec.MachineType),
 		ComputerNamePrefix: fi.String(ig.Name),
 		AdminUser:          fi.String(b.Cluster.Spec.CloudConfig.Azure.AdminUser),
+		Zones:              azNumbers,
 	}
 
 	var err error
diff --git a/upup/pkg/fi/cloudup/azure/azure_utils.go b/upup/pkg/fi/cloudup/azure/azure_utils.go
index 68e91bc242..f2688a9ce8 100644
--- a/upup/pkg/fi/cloudup/azure/azure_utils.go
+++ b/upup/pkg/fi/cloudup/azure/azure_utils.go
@@ -30,3 +30,13 @@ func ZoneToLocation(zone string) (string, error) {
 	}
 	return l[0], nil
 }
+
+// ZoneToAvailabilityZoneNumber extracts the availability zone number from a zone of the
+// form <location>-<available-zone-number>..
+func ZoneToAvailabilityZoneNumber(zone string) (string, error) {
+	l := strings.Split(zone, "-")
+	if len(l) != 2 {
+		return "", fmt.Errorf("invalid Azure zone: %q ", zone)
+	}
+	return l[1], nil
+}
diff --git a/upup/pkg/fi/cloudup/azuretasks/vmscaleset.go b/upup/pkg/fi/cloudup/azuretasks/vmscaleset.go
index 402dc0252b..e84ae13bae 100644
--- a/upup/pkg/fi/cloudup/azuretasks/vmscaleset.go
+++ b/upup/pkg/fi/cloudup/azuretasks/vmscaleset.go
@@ -116,6 +116,7 @@ type VMScaleSet struct {
 	// CustomData is the user data configuration
 	CustomData  fi.Resource
 	Tags        map[string]*string
+	Zones       []string
 	PrincipalID *string
 }
 
@@ -231,6 +232,9 @@ func (s *VMScaleSet) Find(c *fi.Context) (*VMScaleSet, error) {
 			Name: to.StringPtr(loadBalancerID.LoadBalancerName),
 		}
 	}
+	if found.Zones != nil {
+		vmss.Zones = *found.Zones
+	}
 	return vmss, nil
 }
 
@@ -367,7 +371,8 @@ func (s *VMScaleSet) RenderAzure(t *azure.AzureAPITarget, a, e, changes *VMScale
 		Identity: &compute.VirtualMachineScaleSetIdentity{
 			Type: compute.ResourceIdentityTypeSystemAssigned,
 		},
-		Tags: e.Tags,
+		Tags:  e.Tags,
+		Zones: &e.Zones,
 	}
 
 	result, err := t.Cloud.VMScaleSet().CreateOrUpdate(


Test patch:
diff --git a/upup/pkg/fi/cloudup/azure/azure_utils_test.go b/upup/pkg/fi/cloudup/azure/azure_utils_test.go
index a0d4926e1e..0641321bd0 100644
--- a/upup/pkg/fi/cloudup/azure/azure_utils_test.go
+++ b/upup/pkg/fi/cloudup/azure/azure_utils_test.go
@@ -61,3 +61,44 @@ func TestZoneToLocation(t *testing.T) {
 		})
 	}
 }
+
+func TestZoneToAvailabilityZoneNumber(t *testing.T) {
+	testCases := []struct {
+		zone     string
+		success  bool
+		azNumber string
+	}{
+		{
+			zone:     "eastus-1",
+			success:  true,
+			azNumber: "1",
+		},
+		{
+			zone:    "eastus",
+			success: false,
+		},
+		{
+			zone:    "eastus-1-2",
+			success: false,
+		},
+	}
+
+	for i, tc := range testCases {
+		t.Run(fmt.Sprintf("test case %d", i), func(t *testing.T) {
+			azNum, err := ZoneToAvailabilityZoneNumber(tc.zone)
+			if !tc.success {
+				if err == nil {
+					t.Fatalf("unexpected success")
+				}
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("unexpected error: %s", err)
+			}
+			if azNum != tc.azNumber {
+				t.Errorf("expected %s but got %s", tc.azNumber, azNum)
+			}
+		})
+	}
+}
diff --git a/upup/pkg/fi/cloudup/azuretasks/vmscaleset_test.go b/upup/pkg/fi/cloudup/azuretasks/vmscaleset_test.go
index 9859eb7ab2..2df118fdec 100644
--- a/upup/pkg/fi/cloudup/azuretasks/vmscaleset_test.go
+++ b/upup/pkg/fi/cloudup/azuretasks/vmscaleset_test.go
@@ -86,6 +86,7 @@ func newTestVMScaleSet() *VMScaleSet {
 		SSHPublicKey:       to.StringPtr("ssh"),
 		CustomData:         fi.NewStringResource("custom"),
 		Tags:               map[string]*string{},
+		Zones:              []string{"zone1"},
 	}
 }
 
@@ -128,6 +129,10 @@ func TestVMScaleSetRenderAzure(t *testing.T) {
 	if expected.PrincipalID == nil {
 		t.Errorf("unexpected nil principalID")
 	}
+
+	if a, e := *actual.Zones, expected.Zones; !reflect.DeepEqual(a, e) {
+		t.Errorf("unexpected Zone: expected %s, but got %s", e, a)
+	}
 }
 
 func TestVMScaleSetFind(t *testing.T) {
@@ -253,6 +258,7 @@ func TestVMScaleSetFind(t *testing.T) {
 		Identity: &compute.VirtualMachineScaleSetIdentity{
 			Type: compute.ResourceIdentityTypeSystemAssigned,
 		},
+		Zones: &[]string{"zone1"},
 	}
 	if _, err := cloud.VMScaleSet().CreateOrUpdate(context.Background(), *rg.Name, *vmss.Name, vmssParameters); err != nil {
 		t.Fatalf("failed to create: %s", err)
@@ -288,6 +294,9 @@ func TestVMScaleSetFind(t *testing.T) {
 	if !*actual.RequirePublicIP {
 		t.Errorf("unexpected require public IP")
 	}
+	if a, e := actual.Zones, *vmssParameters.Zones; !reflect.DeepEqual(a, e) {
+		t.Errorf("unexpected Zone: expected %s, but got %s", e, a)
+	}
 }
 
 func TestVMScaleSetRun(t *testing.T) {