diff --git a/Makefile b/Makefile index 1908e328..6fe52261 100644 --- a/Makefile +++ b/Makefile @@ -178,7 +178,7 @@ ENVTEST ?= $(LOCALBIN)/setup-envtest ## Tool Versions KUSTOMIZE_VERSION ?= v5.1.1 -CONTROLLER_TOOLS_VERSION ?= v0.16.1 +CONTROLLER_TOOLS_VERSION ?= v0.16.5 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. If wrong version is installed, it will be removed before downloading. diff --git a/api/v1alpha1/ionoscloudmachine_types.go b/api/v1alpha1/ionoscloudmachine_types.go index 0a4abae3..05cf13cd 100644 --- a/api/v1alpha1/ionoscloudmachine_types.go +++ b/api/v1alpha1/ionoscloudmachine_types.go @@ -124,10 +124,20 @@ type IonosCloudMachineSpec struct { NumCores int32 `json:"numCores,omitempty"` // AvailabilityZone is the availability zone in which the VM should be provisioned. - //+kubebuilder:validation:Enum=AUTO;ZONE_1;ZONE_2 - //+kubebuilder:default=AUTO + // AvailabilityZone is mutually exclusive with AvailabilityZones. + // If specified, AvailabilityZone will be used to provision the VM. + // +kubebuilder:validation:default=AUTO + // +kubebuilder:validation:Enum=AUTO;ZONE_1;ZONE_2 //+optional - AvailabilityZone AvailabilityZone `json:"availabilityZone,omitempty"` + AvailabilityZone *AvailabilityZone `json:"availabilityZone,omitempty"` + + // AvailabilityZones is the list of availability zones where the VM should be provisioned. + // AvailabilityZones is mutually exclusive with AvailabilityZone. + // If specified, and the machine is a CP the VM will be created in one of the specified availability zones. + // +kube:validation:MinItems=1 + // +kubebuilder:validation:items:Enum=ZONE_1;ZONE_2 + //+optional + AvailabilityZones []AvailabilityZone `json:"availabilityZones,omitempty"` // MemoryMB is the memory size for the VM in MB. // Size must be specified in multiples of 256 MB with a minimum of 1024 MB @@ -216,9 +226,9 @@ type Volume struct { SizeGB int `json:"sizeGB,omitempty"` // AvailabilityZone is the availability zone where the volume will be created. - //+kubebuilder:validation:Enum=AUTO;ZONE_1;ZONE_2;ZONE_3 - //+kubebuilder:default=AUTO - //+optional + // +kubebuilder:validation:Enum=AUTO;ZONE_1;ZONE_2;ZONE_3 + // +kubebuilder:default=AUTO + // +optional AvailabilityZone AvailabilityZone `json:"availabilityZone,omitempty"` // Image is the image to use for the VM. diff --git a/api/v1alpha1/ionoscloudmachine_types_test.go b/api/v1alpha1/ionoscloudmachine_types_test.go index 8ce11396..d408f017 100644 --- a/api/v1alpha1/ionoscloudmachine_types_test.go +++ b/api/v1alpha1/ionoscloudmachine_types_test.go @@ -43,7 +43,7 @@ func defaultMachine() *IonosCloudMachine { ProviderID: ptr.To("ionos://ee090ff2-1eef-48ec-a246-a51a33aa4f3a"), DatacenterID: "ee090ff2-1eef-48ec-a246-a51a33aa4f3a", NumCores: 1, - AvailabilityZone: AvailabilityZoneTwo, + AvailabilityZone: ptr.To(AvailabilityZoneTwo), MemoryMB: 2048, CPUFamily: ptr.To("AMD_OPTERON"), Disk: &Volume{ @@ -162,24 +162,18 @@ var _ = Describe("IonosCloudMachine Tests", func() { }) Context("Availability zone", func() { - It("should default to AUTO", func() { - m := defaultMachine() - // because AvailabilityZone is a string, setting the value as "" is the same as not setting anything - m.Spec.AvailabilityZone = "" - Expect(k8sClient.Create(context.Background(), m)).To(Succeed()) - Expect(m.Spec.AvailabilityZone).To(Equal(AvailabilityZoneAuto)) - }) It("should fail if not part of the enum", func() { m := defaultMachine() - m.Spec.AvailabilityZone = "this-should-not-work" + var unknown AvailabilityZone = "this-should-not-work" + m.Spec.AvailabilityZone = ptr.To(unknown) Expect(k8sClient.Create(context.Background(), m)).ToNot(Succeed()) }) DescribeTable("should work for value", func(zone AvailabilityZone) { m := defaultMachine() - m.Spec.AvailabilityZone = zone + m.Spec.AvailabilityZone = &zone Expect(k8sClient.Create(context.Background(), m)).To(Succeed()) - Expect(m.Spec.AvailabilityZone).To(Equal(zone)) + Expect(ptr.Deref(m.Spec.AvailabilityZone, "")).To(Equal(zone)) }, Entry("AUTO", AvailabilityZoneAuto), Entry("ZONE_1", AvailabilityZoneOne), @@ -187,7 +181,7 @@ var _ = Describe("IonosCloudMachine Tests", func() { ) It("Should fail for ZONE_3", func() { m := defaultMachine() - m.Spec.AvailabilityZone = AvailabilityZoneThree + m.Spec.AvailabilityZone = ptr.To(AvailabilityZoneThree) Expect(k8sClient.Create(context.Background(), m)).ToNot(Succeed()) }) }) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 736fcfaf..d25dc6e4 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -371,6 +371,16 @@ func (in *IonosCloudMachineSpec) DeepCopyInto(out *IonosCloudMachineSpec) { *out = new(string) **out = **in } + if in.AvailabilityZone != nil { + in, out := &in.AvailabilityZone, &out.AvailabilityZone + *out = new(AvailabilityZone) + **out = **in + } + if in.AvailabilityZones != nil { + in, out := &in.AvailabilityZones, &out.AvailabilityZones + *out = make([]AvailabilityZone, len(*in)) + copy(*out, *in) + } if in.CPUFamily != nil { in, out := &in.CPUFamily, &out.CPUFamily *out = new(string) diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclusters.yaml index 71923c4b..f0d71a03 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.16.5 name: ionoscloudclusters.infrastructure.cluster.x-k8s.io spec: group: infrastructure.cluster.x-k8s.io diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclustertemplates.yaml index 2524b17f..e45e266c 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudclustertemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.16.5 name: ionoscloudclustertemplates.infrastructure.cluster.x-k8s.io spec: group: infrastructure.cluster.x-k8s.io diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachines.yaml index 2072c4d2..42026f2c 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachines.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.16.5 name: ionoscloudmachines.infrastructure.cluster.x-k8s.io spec: group: infrastructure.cluster.x-k8s.io @@ -149,14 +149,28 @@ spec: type: object type: array availabilityZone: - default: AUTO - description: AvailabilityZone is the availability zone in which the - VM should be provisioned. + description: |- + AvailabilityZone is the availability zone in which the VM should be provisioned. + AvailabilityZone is mutually exclusive with AvailabilityZones. + If specified, AvailabilityZone will be used to provision the VM. enum: - AUTO - ZONE_1 - ZONE_2 type: string + availabilityZones: + description: |- + AvailabilityZones is the list of availability zones where the VM should be provisioned. + AvailabilityZones is mutually exclusive with AvailabilityZone. + If specified, and the machine is a CP the VM will be created in one of the specified availability zones. + items: + description: AvailabilityZone is the availability zone where different + cloud resources are created in. + enum: + - ZONE_1 + - ZONE_2 + type: string + type: array cpuFamily: description: |- CPUFamily defines the CPU architecture, which will be used for this VM. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachinetemplates.yaml index 2565b2a7..f7830ee1 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudmachinetemplates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.16.5 name: ionoscloudmachinetemplates.infrastructure.cluster.x-k8s.io spec: group: infrastructure.cluster.x-k8s.io @@ -169,14 +169,28 @@ spec: type: object type: array availabilityZone: - default: AUTO - description: AvailabilityZone is the availability zone in - which the VM should be provisioned. + description: |- + AvailabilityZone is the availability zone in which the VM should be provisioned. + AvailabilityZone is mutually exclusive with AvailabilityZones. + If specified, AvailabilityZone will be used to provision the VM. enum: - AUTO - ZONE_1 - ZONE_2 type: string + availabilityZones: + description: |- + AvailabilityZones is the list of availability zones where the VM should be provisioned. + AvailabilityZones is mutually exclusive with AvailabilityZone. + If specified, and the machine is a CP the VM will be created in one of the specified availability zones. + items: + description: AvailabilityZone is the availability zone where + different cloud resources are created in. + enum: + - ZONE_1 + - ZONE_2 + type: string + type: array cpuFamily: description: |- CPUFamily defines the CPU architecture, which will be used for this VM. diff --git a/internal/service/cloud/server.go b/internal/service/cloud/server.go index 7bbda6c5..42e31fd8 100644 --- a/internal/service/cloud/server.go +++ b/internal/service/cloud/server.go @@ -21,6 +21,7 @@ import ( "encoding/base64" "errors" "fmt" + "math/rand" "net/http" "path" "strconv" @@ -29,7 +30,10 @@ import ( sdk "github.com/ionos-cloud/sdk-go/v6" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util" "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/controller-runtime/pkg/client" infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1" "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/util/ptr" @@ -334,6 +338,11 @@ func (s *Service) createServer(ctx context.Context, secret *corev1.Secret, ms *s return fmt.Errorf("image lookup: %w", err) } + err = reconcileAvailabilityZone(ctx, ms) + if err != nil { + return fmt.Errorf("failed to reconcile availability zone %w", err) + } + renderedData := s.renderUserData(ms, string(bootstrapData)) copySpec := ms.IonosMachine.Spec.DeepCopy() entityParams := serverEntityParams{ @@ -482,3 +491,67 @@ func (*Service) serversURL(datacenterID string) string { func (*Service) volumeName(m *infrav1.IonosCloudMachine) string { return "vol-" + m.Name } + +func reconcileAvailabilityZone(ctx context.Context, ms *scope.Machine) error { + // Always return early if the AvailabilityZone is already set. + if ms.IonosMachine.Spec.AvailabilityZone != nil { + return nil + } + + // Set default availability zone, if none is set. + if ms.IonosMachine.Spec.AvailabilityZone == nil && ms.IonosMachine.Spec.AvailabilityZones == nil { + ms.IonosMachine.Spec.AvailabilityZone = ptr.To(infrav1.AvailabilityZoneAuto) + return nil + } + + if ms.IonosMachine.Spec.AvailabilityZones == nil { + return errors.New("availability zones are not set") + } + + // if control plane machine, we distribute the machines across the zones. + if util.IsControlPlaneMachine(ms.Machine) { + machines, err := ms.ListMachines(ctx, client.MatchingLabels{ + clusterv1.ClusterNameLabel: ms.ClusterScope.Cluster.GetName(), + clusterv1.MachineControlPlaneLabel: "", + }) + if err != nil { + return fmt.Errorf("failed to list machines %w", err) + } + + // Track zone usage + usedZones := make(map[infrav1.AvailabilityZone]int) + for _, machine := range machines { + if machine.Name == ms.IonosMachine.GetName() { + // Skip the current machine + continue + } + if machine.Spec.AvailabilityZone != nil { + usedZones[*machine.Spec.AvailabilityZone]++ + } + } + + // Find the next least used availability zone + var selectedZone infrav1.AvailabilityZone + + if len(usedZones) == 0 { + // If no zones are currently used, start with the first zone + selectedZone = ms.IonosMachine.Spec.AvailabilityZones[0] + } else { + minUsage := int(^uint(0) >> 1) // max int value for finding minimum + + for _, zone := range ms.IonosMachine.Spec.AvailabilityZones { + if usage, found := usedZones[zone]; !found || usage < minUsage { + selectedZone = zone + minUsage = usage + } + } + } + + // Set the selected zone + ms.IonosMachine.Spec.AvailabilityZone = ptr.To(selectedZone) + } else { + // if worker machine, we randomly select a zone. + ms.IonosMachine.Spec.AvailabilityZone = ptr.To(ms.IonosMachine.Spec.AvailabilityZones[rand.Intn(len(ms.IonosMachine.Spec.AvailabilityZones))]) //nolint:gosec + } + return nil +} diff --git a/internal/service/cloud/server_test.go b/internal/service/cloud/server_test.go index 7799e4c7..7835d6f5 100644 --- a/internal/service/cloud/server_test.go +++ b/internal/service/cloud/server_test.go @@ -17,6 +17,7 @@ limitations under the License. package cloud import ( + "context" "fmt" "net/http" "path" @@ -28,6 +29,7 @@ import ( "github.com/stretchr/testify/suite" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1" "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/ionoscloud/clienttest" @@ -446,6 +448,72 @@ func (s *serverSuite) TestGetServerWithoutProviderIDFoundInList() { s.NotNil(server) } +func (s *serverSuite) TestReconcileAvailabilityZoneDefault() { + // Given + s.machineScope.IonosMachine.Spec.AvailabilityZone = nil + + err := reconcileAvailabilityZone(context.Background(), s.machineScope) + s.NoError(err) + s.Equal(infrav1.AvailabilityZoneAuto, ptr.Deref(s.machineScope.IonosMachine.Spec.AvailabilityZone, "")) +} + +func (s *serverSuite) TestReconcileAvailabilityZoneDistribute() { + // First Machine + // Given + s.capiMachine.Labels[clusterv1.MachineControlPlaneLabel] = "" + s.infraMachine.Labels[clusterv1.MachineControlPlaneLabel] = "" + s.machineScope.IonosMachine.Spec.AvailabilityZone = nil + s.machineScope.IonosMachine.Spec.AvailabilityZones = []infrav1.AvailabilityZone{infrav1.AvailabilityZoneOne, infrav1.AvailabilityZoneTwo} + + err := reconcileAvailabilityZone(context.Background(), s.machineScope) + s.NoError(err) + s.Equal(infrav1.AvailabilityZoneOne, ptr.Deref(s.machineScope.IonosMachine.Spec.AvailabilityZone, "")) + + // Update the machine to distribute + err = s.k8sClient.Update(context.Background(), s.machineScope.IonosMachine) + s.NoError(err) + + // Second Machine + machine := &infrav1.IonosCloudMachine{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "test-cp-2", + Labels: map[string]string{ + clusterv1.ClusterNameLabel: s.capiCluster.Name, + clusterv1.MachineControlPlaneLabel: "", + }, + }, + Spec: infrav1.IonosCloudMachineSpec{ + ProviderID: ptr.To("ionos://" + exampleServerID), + DatacenterID: "ccf27092-34e8-499e-a2f5-2bdee9d34a12", + NumCores: 2, + AvailabilityZones: []infrav1.AvailabilityZone{infrav1.AvailabilityZoneOne, infrav1.AvailabilityZoneTwo}, + MemoryMB: 4096, + CPUFamily: ptr.To("AMD_OPTERON"), + Disk: &infrav1.Volume{ + Name: "test-cp-2-hdd", + DiskType: infrav1.VolumeDiskTypeHDD, + SizeGB: 20, + AvailabilityZone: infrav1.AvailabilityZoneAuto, + Image: &infrav1.ImageSpec{ + ID: "3e3e3e3e-3e3e-3e3e-3e3e-3e3e3e3e3e3e", + }, + }, + Type: infrav1.ServerTypeEnterprise, + }, + Status: infrav1.IonosCloudMachineStatus{}, + } + + err = s.k8sClient.Create(context.Background(), machine) + s.NoError(err) + + s.machineScope.IonosMachine = machine + + err = reconcileAvailabilityZone(context.Background(), s.machineScope) + s.NoError(err) + s.Equal(infrav1.AvailabilityZoneTwo, ptr.Deref(s.machineScope.IonosMachine.Spec.AvailabilityZone, "")) +} + //nolint:unused func (*serverSuite) exampleServer() sdk.Server { return sdk.Server{ diff --git a/internal/service/cloud/suite_test.go b/internal/service/cloud/suite_test.go index c6463d9b..05443613 100644 --- a/internal/service/cloud/suite_test.go +++ b/internal/service/cloud/suite_test.go @@ -155,7 +155,7 @@ func (s *ServiceTestSuite) SetupTest() { ProviderID: ptr.To("ionos://" + exampleServerID), DatacenterID: "ccf27092-34e8-499e-a2f5-2bdee9d34a12", NumCores: 2, - AvailabilityZone: infrav1.AvailabilityZoneAuto, + AvailabilityZone: ptr.To(infrav1.AvailabilityZoneAuto), MemoryMB: 4096, CPUFamily: ptr.To("AMD_OPTERON"), Disk: &infrav1.Volume{ diff --git a/internal/service/k8s/ipam_test.go b/internal/service/k8s/ipam_test.go index 68e0506f..230da4fc 100644 --- a/internal/service/k8s/ipam_test.go +++ b/internal/service/k8s/ipam_test.go @@ -115,7 +115,7 @@ func (s *IpamTestSuite) SetupTest() { ProviderID: ptr.To("ionos://dd426c63-cd1d-4c02-aca3-13b4a27c2ebf"), DatacenterID: "ccf27092-34e8-499e-a2f5-2bdee9d34a12", NumCores: 2, - AvailabilityZone: infrav1.AvailabilityZoneAuto, + AvailabilityZone: ptr.To(infrav1.AvailabilityZoneAuto), MemoryMB: 4096, CPUFamily: ptr.To("AMD_OPTERON"), Disk: &infrav1.Volume{