Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support to manually distribute CP machines in different zones with AvailabilityZones field #244

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions api/v1alpha1/ionoscloudmachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 6 additions & 12 deletions api/v1alpha1/ionoscloudmachine_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -162,32 +162,26 @@ 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),
Entry("ZONE_2", AvailabilityZoneTwo),
)
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())
})
})
Expand Down
10 changes: 10 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

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

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions internal/service/cloud/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"math/rand"
"net/http"
"path"
"strconv"
Expand All @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
}
68 changes: 68 additions & 0 deletions internal/service/cloud/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package cloud

import (
"context"
"fmt"
"net/http"
"path"
Expand All @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
Loading
Loading