diff --git a/Makefile b/Makefile index 5e9c249..8f94f27 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,7 @@ STACKIT_WORKLOAD_CNI ?= cilium CILIUM_VERSION ?= 1.19.4 CILIUM_VALUES ?= templates/addons/cilium-values.yaml CALICO_MANIFEST ?= https://raw.githubusercontent.com/projectcalico/calico/v3.30.0/manifests/calico.yaml +ENABLE_WEBHOOKS ?= false # YEAR defines the year value used for substituting the YEAR placeholder in the boilerplate header. YEAR ?= $(shell date +%Y) @@ -187,7 +188,7 @@ build: manifests generate fmt vet ## Build manager binary. .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/manager/main.go + ENABLE_WEBHOOKS=$(ENABLE_WEBHOOKS) go run ./cmd/manager/main.go # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. diff --git a/cloud/client.go b/cloud/client.go index 7038356..a3ab870 100644 --- a/cloud/client.go +++ b/cloud/client.go @@ -56,6 +56,8 @@ type Client interface { SetAPIServerLoadBalancerTargets(ctx context.Context, loadBalancerID string, port int32, targets []LoadBalancerTargetInput) error DeleteAPIServerLoadBalancer(ctx context.Context, id string) error + + ListAvailabilityZones(ctx context.Context) ([]string, error) } // Factory builds a Client from credentials (raw bytes from the configured diff --git a/cloud/fake/client.go b/cloud/fake/client.go index 93c19ca..06465cc 100644 --- a/cloud/fake/client.go +++ b/cloud/fake/client.go @@ -53,6 +53,14 @@ type Client struct { FailNextDeleteBastion error FailNextEnsureNodeSSH error FailNextDeleteNodeSSH error + FailNextListAZ error + + // AvailabilityZones is returned by ListAvailabilityZones if non-empty. + // Defaults to eu01-1, eu01-2, eu01-3. + AvailabilityZones []string + + // LastCreateServerInput records the most recent input passed to CreateServer. + LastCreateServerInput cloud.CreateServerInput // Before* hooks, if non-nil, run before the call they belong to. They let a // test observe API server state at the exact moment a cloud call would @@ -189,6 +197,7 @@ func (c *Client) CreateServer(_ context.Context, input cloud.CreateServerInput) } id := c.genID() + c.LastCreateServerInput = input zone := input.AvailabilityZone if zone == "" { zone = "eu01-1" @@ -716,6 +725,18 @@ func idOfSecurityGroup(securityGroup *cloud.SecurityGroup) string { return securityGroup.ID } +func (c *Client) ListAvailabilityZones(_ context.Context) ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if err := consume(&c.FailNextListAZ); err != nil { + return nil, err + } + if len(c.AvailabilityZones) > 0 { + return slices.Clone(c.AvailabilityZones), nil + } + return []string{"eu01-1", "eu01-2", "eu01-3"}, nil +} + func cloneServer(s *cloud.Server) *cloud.Server { out := *s if s.Addresses != nil { diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 000bdf3..433e22f 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -203,6 +203,17 @@ func (c *SDKClient) CreateServer(ctx context.Context, input CreateServerInput) ( return c.serverFromSDK(ctx, server), nil } +func (c *SDKClient) ListAvailabilityZones(ctx context.Context) ([]string, error) { + resp, err := c.iaasClient.DefaultAPI.ListAvailabilityZones(ctx, c.region).Execute() + if err != nil { + return nil, classifySDKError("list availability zones", err) + } + if resp == nil || resp.Items == nil { + return nil, nil + } + return resp.GetItems(), nil +} + func (c *SDKClient) DeleteServer(ctx context.Context, id string) error { if err := c.iaasClient.DefaultAPI.DeleteServer(ctx, c.projectID, c.region, id).Execute(); err != nil { return classifySDKError("delete server", err) diff --git a/cloud/sdk_client_test.go b/cloud/sdk_client_test.go index 52ebf00..34a3e9b 100644 --- a/cloud/sdk_client_test.go +++ b/cloud/sdk_client_test.go @@ -707,3 +707,24 @@ func TestSDKClientEnsureBastionDeduplicatesRepeatedCIDRs(t *testing.T) { len(createdRuleCIDRs), createdRuleCIDRs) } } + +func TestSDKClientListAvailabilityZones(t *testing.T) { + server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/availability-zones") { + writeJSON(t, w, map[string]any{ + "items": []any{"eu01-1", "eu01-2", "eu01-3"}, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + })) + + client := newTestSDKClient(t, server.URL) + zones, err := client.ListAvailabilityZones(context.Background()) + if err != nil { + t.Fatalf("ListAvailabilityZones() error = %v", err) + } + if len(zones) != 3 || zones[0] != "eu01-1" || zones[1] != "eu01-2" || zones[2] != "eu01-3" { + t.Fatalf("ListAvailabilityZones() = %v, want [eu01-1 eu01-2 eu01-3]", zones) + } +} diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 1992b29..682eb40 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -183,6 +183,7 @@ func main() { os.Exit(1) } + // nolint:staticcheck // SA1019 ignore this deprecation as long as cluster-api doesn't support the newer recorder record.InitFromRecorder(mgr.GetEventRecorderFor("cluster-api-provider-stackit")) if err := (&controller.StackitClusterReconciler{ diff --git a/controller/stackitcluster_controller_test.go b/controller/stackitcluster_controller_test.go index 67a42f8..65077b2 100644 --- a/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -969,6 +969,35 @@ var _ = Describe("StackitCluster Controller", func() { requests := reconciler.filterForStackitClusterResources(ctx, cluster) Expect(requests).To(BeEmpty()) }) + + It("discovers failure domains dynamically and filters out metro (-m) zones", func() { + fakeCloud.AvailabilityZones = []string{"eu01-2", "eu01-m", "eu01-1", "eu01-3"} + + result, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + got := &infrav1.StackitCluster{} + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + Expect(got.Status.FailureDomains).To(Equal([]clusterv1.FailureDomain{ + {Name: "eu01-1", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}}, + {Name: "eu01-2", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}}, + {Name: "eu01-3", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}}, + })) + }) + + It("reports an error condition when listing availability zones fails", func() { + fakeCloud.FailNextListAZ = fmt.Errorf("cloud failure: %w", cloud.ErrTransient) + + result, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) + + got := &infrav1.StackitCluster{} + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + expectCondition(got.Status.Conditions, infrav1.ClusterReadyCondition, metav1.ConditionFalse, "AvailabilityZonesError") + }) }) func expectBastionCloudInit( diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index 4e2eab2..f4c4f8e 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -15,6 +15,8 @@ import ( "errors" "fmt" "net/netip" + "slices" + "strings" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -48,7 +50,7 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS return ctrl.Result{}, fmt.Errorf("persist finalizer: %w", err) } } - stackitCluster.Status.FailureDomains = stackitFailureDomains(stackitCluster.Spec.Region) + clusterScope.SetFailureDomains(stackitFailureDomains(stackitCluster.Spec.Region)) cloudClient, err := util.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, stackitCluster) if err != nil { @@ -71,6 +73,21 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS infrav1.ClusterCredentialsReadyCondition, ) + zones, err := cloudClient.ListAvailabilityZones(ctx) + if err != nil { + stackitCluster.Status.Ready = false + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "AvailabilityZonesError", + err, + retryableErrorRequeueAfter, + true, + infrav1.ClusterReadyCondition, + ) + } + clusterScope.SetFailureDomains(failureDomainsFromZones(stackitCluster.Spec.Region, zones)) + network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) if err != nil { log.Error(err, "Failed to get network", @@ -272,6 +289,31 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS return ctrl.Result{}, nil } +func failureDomainsFromZones(region string, zones []string) []clusterv1.FailureDomain { + controlPlane := true + failureDomains := make([]clusterv1.FailureDomain, 0, len(zones)) + for _, zone := range zones { + // Filter out virtual/metro zones (such as eu01-m or any zone with suffix -m) + if strings.HasSuffix(zone, "-m") { + continue + } + failureDomains = append(failureDomains, clusterv1.FailureDomain{ + Name: zone, + ControlPlane: &controlPlane, + Attributes: map[string]string{ + "region": region, + }, + }) + } + if len(failureDomains) == 0 { + return stackitFailureDomains(region) + } + slices.SortFunc(failureDomains, func(a, b clusterv1.FailureDomain) int { + return strings.Compare(a.Name, b.Name) + }) + return failureDomains +} + func stackitFailureDomains(region string) []clusterv1.FailureDomain { controlPlane := true return []clusterv1.FailureDomain{ diff --git a/controller/stackitmachine_controller_test.go b/controller/stackitmachine_controller_test.go index 148ac44..0efd701 100644 --- a/controller/stackitmachine_controller_test.go +++ b/controller/stackitmachine_controller_test.go @@ -285,6 +285,70 @@ var _ = Describe("StackitMachine Controller", func() { expectCondition(got.Status.Conditions, infrav1.MachineReadyCondition, metav1.ConditionFalse, "InvalidFailureDomain") }) + //nolint:dupl // Failure domain resolution cases test explicit failureDomain vs cluster default. + It("respects Machine.spec.failureDomain when StackitMachine.spec.availabilityZone is empty", func() { + got := &infrav1.StackitMachine{} + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + got.Spec.AvailabilityZone = "" + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + + ownerMachine := &clusterv1.Machine{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: machineName, Namespace: namespace}, ownerMachine)).To(Succeed()) + ownerMachine.Spec.FailureDomain = "eu01-2" + Expect(k8sClient.Update(ctx, ownerMachine)).To(Succeed()) + + result, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(fakeCloud.ServerCount()).To(Equal(1)) + Expect(fakeCloud.LastCreateServerInput.AvailabilityZone).To(Equal("eu01-2")) + + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeTrue()) + Expect(got.Status.AvailabilityZone).To(Equal("eu01-2")) + }) + + //nolint:dupl // Failure domain resolution cases test explicit failureDomain vs cluster default. + It("defaults to the first failure domain when neither failureDomain nor availabilityZone is set", func() { + got := &infrav1.StackitMachine{} + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + got.Spec.AvailabilityZone = "" + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + + ownerMachine := &clusterv1.Machine{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: machineName, Namespace: namespace}, ownerMachine)).To(Succeed()) + ownerMachine.Spec.FailureDomain = "" + Expect(k8sClient.Update(ctx, ownerMachine)).To(Succeed()) + + result, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(fakeCloud.ServerCount()).To(Equal(1)) + Expect(fakeCloud.LastCreateServerInput.AvailabilityZone).To(Equal("eu01-1")) + + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeTrue()) + Expect(got.Status.AvailabilityZone).To(Equal("eu01-1")) + }) + + It("does not create a VM when Machine.spec.failureDomain is outside published failure domains", func() { + ownerMachine := &clusterv1.Machine{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: machineName, Namespace: namespace}, ownerMachine)).To(Succeed()) + ownerMachine.Spec.FailureDomain = "eu01-9" + Expect(k8sClient.Update(ctx, ownerMachine)).To(Succeed()) + + result, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(fakeCloud.ServerCount()).To(Equal(0)) + + got := &infrav1.StackitMachine{} + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + expectCondition(got.Status.Conditions, infrav1.MachineInstanceReadyCondition, metav1.ConditionFalse, "InvalidFailureDomain") + expectCondition(got.Status.Conditions, infrav1.MachineReadyCondition, metav1.ConditionFalse, "InvalidFailureDomain") + }) + It("requeues on unauthorized credentials and recovers once they are corrected", func() { reconciler.CloudClientFactory = func(context.Context, cloud.Credentials) (cloud.Client, error) { return nil, fmt.Errorf("authenticate: %w", cloud.ErrUnauthorized) diff --git a/controller/stackitmachine_infrastructure.go b/controller/stackitmachine_infrastructure.go index 62bec6c..920f016 100644 --- a/controller/stackitmachine_infrastructure.go +++ b/controller/stackitmachine_infrastructure.go @@ -184,16 +184,28 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, machineS } func validateMachineAvailabilityZone(machineScope *scope.MachineScope) error { - availabilityZone := machineScope.StackitMachine.Spec.AvailabilityZone - if availabilityZone == "" || len(machineScope.StackitCluster.Status.FailureDomains) == 0 { + clusterFailureDomains := machineScope.StackitCluster.Status.FailureDomains + if len(clusterFailureDomains) == 0 { return nil } - for _, failureDomain := range machineScope.StackitCluster.Status.FailureDomains { - if failureDomain.Name == availabilityZone { - return nil + knownAZ := func(zone string) bool { + for _, failureDomain := range clusterFailureDomains { + if failureDomain.Name == zone { + return true + } } + return false + } + if az := machineScope.StackitMachine.Spec.AvailabilityZone; az != "" && !knownAZ(az) { + return fmt.Errorf("availabilityZone %q is not published in StackitCluster status.failureDomains", az) } - return fmt.Errorf("availabilityZone %q is not published in StackitCluster status.failureDomains", availabilityZone) + if machineScope.Machine != nil && machineScope.Machine.Spec.FailureDomain != "" { + fd := machineScope.Machine.Spec.FailureDomain + if !knownAZ(fd) { + return fmt.Errorf("machine failureDomain %q is not published in StackitCluster status.failureDomains", fd) + } + } + return nil } func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, machineScope *scope.MachineScope) error { @@ -335,7 +347,7 @@ func (r *StackitMachineReconciler) ensureServer( Region: machineScope.StackitCluster.Spec.Region, ImageID: stackitMachine.Spec.ImageID, MachineType: stackitMachine.Spec.MachineType, - AvailabilityZone: stackitMachine.Spec.AvailabilityZone, + AvailabilityZone: machineScope.AvailabilityZone(), SSHKeyName: stackitMachine.Spec.SSHKeyName, NetworkID: stackitMachine.Spec.Network.ID, SecurityGroups: stackitMachine.Spec.SecurityGroups, diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index cc690bd..0e00d61 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,7 +14,7 @@ - [Topics](./topics/index.md) - [OS Images](./topics/images.md) - [Accessing VM instances](./topics/accessing-vm-instances.md) - - [Failure domains](./topics/failure-domains.md) + - [HA, Availability Zones & Failure Domains](./topics/failure-domains.md) - [IAM Permissions Used](./topics/iam-permissions.md) diff --git a/docs/src/getting-started/overview.md b/docs/src/getting-started/overview.md index 7f6ee8c..f21f7b6 100644 --- a/docs/src/getting-started/overview.md +++ b/docs/src/getting-started/overview.md @@ -61,13 +61,11 @@ API contract details. a clear prerequisite flow, for example Terraform modules or explicit documented setup steps, unless the provider grows first-class management for those resources. -- Failure domains are currently derived from the configured region as - `-1`, `-2`, and `-3`. Production behavior should - discover available STACKIT zones dynamically and publish only real failure - domains. -- Admission webhooks are missing. CRD validation exists, but there are no - defaulting / validation webhooks for immutability, safe updates, template - fields, cross-resource checks, or clearer user-facing validation failures. +- Failure domains are discovered dynamically from STACKIT IaaS availability + zones, filtering out virtual metro zones (`-m`) so VMs are not placed into + expensive replicated zones by default. +- Admission webhooks are implemented for mutating defaulting and validating + immutability, safe updates, and spec constraints. - Operational hardening is still needed. The manager deployment is still close to the Kubebuilder scaffold in places, and production use should define resource sizing, metrics TLS, HA/replica behavior, alerting, and runbooks. @@ -76,11 +74,6 @@ API contract details. For full Cluster API support, the provider should complete the following areas: -- Respect `Machine.spec.failureDomain`. The provider currently uses - `StackitMachine.spec.availabilityZone`. For full CAPI behavior, if CAPI sets - `Machine.spec.failureDomain`, the infrastructure machine must be placed in - that failure domain. The provider should also consider surfacing the actual - placement through `StackitMachine.status.failureDomain`. - Continue hardening ClusterClass support. Topology create/ready/delete e2e now covers a 1 control-plane / 1 worker workload cluster, including template metadata propagation to generated STACKIT infrastructure objects. Highly diff --git a/docs/src/topics/failure-domains.md b/docs/src/topics/failure-domains.md index 6590d7f..113e1fb 100644 --- a/docs/src/topics/failure-domains.md +++ b/docs/src/topics/failure-domains.md @@ -1,9 +1,14 @@ -# Failure Domains +# HA, Availability Zones & Failure Domains -The provider publishes STACKIT availability zones as Cluster API failure -domains on `StackitCluster.status.failureDomains`. +"Availability Zone" is a cloud provider-specific term, whereas "Failure Domain" is the broader, environment-agnostic concept used throughout Cluster API. A failure domain can represent a separation of physical switches, power feeds, rooms, racks, or distinct data centers. In the context of public cloud providers like STACKIT, failure domains and availability zones (AZs) are equivalent. -For a region such as `eu01`, the current model exposes: +## Failure Domain Discovery + +The provider dynamically discovers STACKIT availability zones from the STACKIT +IaaS API (`/v2/regions/{region}/availability-zones`) and publishes them as Cluster +API failure domains on `StackitCluster.status.failureDomains`. + +For a region such as `eu01`, the provider exposes the real physical zones: ```text eu01-1 @@ -11,18 +16,48 @@ eu01-2 eu01-3 ``` -`StackitMachine.spec.availabilityZone` is validated against the published -failure domains when they are available. Invalid zones result in clear -conditions instead of cloud API calls. +Virtual metro zones (such as `eu01-m` with an `-m` suffix) are filtered out from +published failure domains so that machines are not placed into expensive +replicated metro zones by default. + +## Control Plane Spreading + +Control plane spreading across failure domains is handled directly by Cluster API +(`KubeadmControlPlane`). When scaling or rolling out control plane nodes, KCP inspects +existing control plane machines and assigns the least-populated failure domain to +`Machine.spec.failureDomain` (breaking ties deterministically). + +For this automatic spreading to function correctly, the failure domain list in +`StackitCluster.status.failureDomains` must be accurately populated and marked with +`controlPlane: true`. + +## Machine Placement and Defaulting + +When provisioning a machine, the provider determines the target availability zone +using the following precedence: -The existing single-zone templates continue to work. More advanced multi-AZ -scheduling remains intentionally limited for now. +1. **`Machine.spec.failureDomain`**: Honors the placement selected by Cluster API core + (e.g., KCP spreading control plane nodes or a MachineDeployment configured with a + failure domain). +2. **`StackitMachine.spec.availabilityZone`**: If explicitly configured on the + `StackitMachine` (or via `StackitMachineTemplate`), this zone is used. +3. **Provider Defaulting**: When `failureDomain` is omitted, placement depends entirely on + the infrastructure provider. If neither `Machine.spec.failureDomain` nor + `StackitMachine.spec.availabilityZone` is specified (such as worker machines + in a default `MachineDeployment`), the provider sorts the cluster's published + failure domains alphabetically and selects the first zone (e.g., `eu01-1`). This + matches standard Cluster API conventions (such as CAPG) and ensures VMs are never + placed into the expensive STACKIT metro zone (`eu01-m`) by default. ---- -A future improvement: +The actual zone where the VM was placed is surfaced on `StackitMachine.status.availabilityZone`. -Failure domains are currently derived from the configured region as `-1`, `-2`, and `-3`. Production behavior should discover available STACKIT zones dynamically and publish only real failure domains. +## Worker Node Spreading -Furthermore, Respect `Machine.spec.failureDomain`. The provider currently uses `StackitMachine.spec.availabilityZone`. For full CAPI behavior, if CAPI sets `Machine.spec.failureDomain`, the infrastructure machine must be placed in that failure domain. The provider should also consider surfacing the actual placement through `StackitMachine.status.failureDomain`. +Unlike the control plane, worker nodes do not automatically spread across failure domains +within a single `MachineDeployment`. Spreading worker nodes across AZs is an end-user +architecture decision made using the configuration fields on `MachineDeployment` or +`MachinePool` types. -We should document FailureDomain configuration for Controlplanes and Worker nodes. +The standard Cluster API pattern is to define multiple `MachineDeployment` resources (one +per availability zone), pinning each to a specific failure domain via +`spec.template.spec.failureDomain`. diff --git a/scope/cluster_scope.go b/scope/cluster_scope.go index f8d67ea..d1a4705 100644 --- a/scope/cluster_scope.go +++ b/scope/cluster_scope.go @@ -99,3 +99,7 @@ func (s *ClusterScope) SetBastionStatus(bastion *cloud.Bastion, cloudInitHash st func (s *ClusterScope) ClearBastionStatus() { s.StackitCluster.Status.Bastion = infrav1.StackitBastionStatus{} } + +func (s *ClusterScope) SetFailureDomains(failureDomains []clusterv1.FailureDomain) { + s.StackitCluster.Status.FailureDomains = failureDomains +} diff --git a/scope/machine_scope.go b/scope/machine_scope.go index 766c5e4..58bafde 100644 --- a/scope/machine_scope.go +++ b/scope/machine_scope.go @@ -12,6 +12,7 @@ package scope import ( "context" + "slices" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -109,3 +110,36 @@ func (s *MachineScope) ClearInstance() { s.StackitMachine.Status.InstanceState = "" s.StackitMachine.Status.AvailabilityZone = "" } + +// AvailabilityZone returns the resolved availability zone for the machine. +// Following Cluster API conventions: +// 1. If Machine.Spec.FailureDomain is set (e.g. by KCP spreading control plane nodes or MachineDeployment), +// it is used. +// 2. Otherwise, if StackitMachine.Spec.AvailabilityZone is set, it is used. +// 3. Otherwise, if neither is set, it defaults to the first available failure domain +// from StackitCluster.Status.FailureDomains (sorted alphabetically). This prevents the STACKIT IaaS +// API from defaulting to the expensive metro zone (eu01-m). +func (s *MachineScope) AvailabilityZone() string { + if s.Machine != nil && s.Machine.Spec.FailureDomain != "" { + return s.Machine.Spec.FailureDomain + } + + if s.StackitMachine != nil && s.StackitMachine.Spec.AvailabilityZone != "" { + return s.StackitMachine.Spec.AvailabilityZone + } + + if s.StackitCluster != nil && len(s.StackitCluster.Status.FailureDomains) > 0 { + zones := make([]string, 0, len(s.StackitCluster.Status.FailureDomains)) + for _, fd := range s.StackitCluster.Status.FailureDomains { + zones = append(zones, fd.Name) + } + slices.Sort(zones) + return zones[0] + } + + if s.StackitCluster != nil && s.StackitCluster.Spec.Region != "" { + return s.StackitCluster.Spec.Region + "-1" + } + + return "" +}