Skip to content

Commit 2c657f3

Browse files
committed
feat: retrieve, verify and apply availability zones correctly
Signed-off-by: Jan Larwig <jan.larwig@digits.schwarz>
1 parent 8c4c141 commit 2c657f3

15 files changed

Lines changed: 306 additions & 36 deletions

‎Makefile‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ STACKIT_WORKLOAD_CNI ?= cilium
2323
CILIUM_VERSION ?= 1.19.4
2424
CILIUM_VALUES ?= templates/addons/cilium-values.yaml
2525
CALICO_MANIFEST ?= https://raw.githubusercontent.com/projectcalico/calico/v3.30.0/manifests/calico.yaml
26+
ENABLE_WEBHOOKS ?= false
2627
# YEAR defines the year value used for substituting the YEAR placeholder in the boilerplate header.
2728
YEAR ?= $(shell date +%Y)
2829

@@ -187,7 +188,7 @@ build: manifests generate fmt vet ## Build manager binary.
187188

188189
.PHONY: run
189190
run: manifests generate fmt vet ## Run a controller from your host.
190-
go run ./cmd/manager/main.go
191+
ENABLE_WEBHOOKS=$(ENABLE_WEBHOOKS) go run ./cmd/manager/main.go
191192

192193
# If you wish to build the manager image targeting other platforms you can use the --platform flag.
193194
# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it.

‎cloud/client.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ type Client interface {
5656
SetAPIServerLoadBalancerTargets(ctx context.Context, loadBalancerID string, port int32, targets []LoadBalancerTargetInput) error
5757

5858
DeleteAPIServerLoadBalancer(ctx context.Context, id string) error
59+
60+
ListAvailabilityZones(ctx context.Context) ([]string, error)
5961
}
6062

6163
// Factory builds a Client from credentials (raw bytes from the configured

‎cloud/fake/client.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ type Client struct {
5353
FailNextDeleteBastion error
5454
FailNextEnsureNodeSSH error
5555
FailNextDeleteNodeSSH error
56+
FailNextListAZ error
57+
58+
// AvailabilityZones is returned by ListAvailabilityZones if non-empty.
59+
// Defaults to eu01-1, eu01-2, eu01-3.
60+
AvailabilityZones []string
61+
62+
// LastCreateServerInput records the most recent input passed to CreateServer.
63+
LastCreateServerInput cloud.CreateServerInput
5664

5765
// Before* hooks, if non-nil, run before the call they belong to. They let a
5866
// 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)
189197
}
190198

191199
id := c.genID()
200+
c.LastCreateServerInput = input
192201
zone := input.AvailabilityZone
193202
if zone == "" {
194203
zone = "eu01-1"
@@ -716,6 +725,18 @@ func idOfSecurityGroup(securityGroup *cloud.SecurityGroup) string {
716725
return securityGroup.ID
717726
}
718727

728+
func (c *Client) ListAvailabilityZones(_ context.Context) ([]string, error) {
729+
c.mu.Lock()
730+
defer c.mu.Unlock()
731+
if err := consume(&c.FailNextListAZ); err != nil {
732+
return nil, err
733+
}
734+
if len(c.AvailabilityZones) > 0 {
735+
return slices.Clone(c.AvailabilityZones), nil
736+
}
737+
return []string{"eu01-1", "eu01-2", "eu01-3"}, nil
738+
}
739+
719740
func cloneServer(s *cloud.Server) *cloud.Server {
720741
out := *s
721742
if s.Addresses != nil {

‎cloud/sdk_client.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,17 @@ func (c *SDKClient) CreateServer(ctx context.Context, input CreateServerInput) (
203203
return c.serverFromSDK(ctx, server), nil
204204
}
205205

206+
func (c *SDKClient) ListAvailabilityZones(ctx context.Context) ([]string, error) {
207+
resp, err := c.iaasClient.DefaultAPI.ListAvailabilityZones(ctx, c.region).Execute()
208+
if err != nil {
209+
return nil, classifySDKError("list availability zones", err)
210+
}
211+
if resp == nil || resp.Items == nil {
212+
return nil, nil
213+
}
214+
return resp.GetItems(), nil
215+
}
216+
206217
func (c *SDKClient) DeleteServer(ctx context.Context, id string) error {
207218
if err := c.iaasClient.DefaultAPI.DeleteServer(ctx, c.projectID, c.region, id).Execute(); err != nil {
208219
return classifySDKError("delete server", err)

‎cloud/sdk_client_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,24 @@ func TestSDKClientEnsureBastionDeduplicatesRepeatedCIDRs(t *testing.T) {
707707
len(createdRuleCIDRs), createdRuleCIDRs)
708708
}
709709
}
710+
711+
func TestSDKClientListAvailabilityZones(t *testing.T) {
712+
server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
713+
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/availability-zones") {
714+
writeJSON(t, w, map[string]any{
715+
"items": []any{"eu01-1", "eu01-2", "eu01-3"},
716+
})
717+
return
718+
}
719+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
720+
}))
721+
722+
client := newTestSDKClient(t, server.URL)
723+
zones, err := client.ListAvailabilityZones(context.Background())
724+
if err != nil {
725+
t.Fatalf("ListAvailabilityZones() error = %v", err)
726+
}
727+
if len(zones) != 3 || zones[0] != "eu01-1" || zones[1] != "eu01-2" || zones[2] != "eu01-3" {
728+
t.Fatalf("ListAvailabilityZones() = %v, want [eu01-1 eu01-2 eu01-3]", zones)
729+
}
730+
}

‎cmd/manager/main.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ func main() {
183183
os.Exit(1)
184184
}
185185

186+
// nolint:staticcheck // SA1019 ignore this deprecation as long as cluster-api doesn't support the newer recorder
186187
record.InitFromRecorder(mgr.GetEventRecorderFor("cluster-api-provider-stackit"))
187188

188189
if err := (&controller.StackitClusterReconciler{

‎controller/stackitcluster_controller_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,35 @@ var _ = Describe("StackitCluster Controller", func() {
969969
requests := reconciler.filterForStackitClusterResources(ctx, cluster)
970970
Expect(requests).To(BeEmpty())
971971
})
972+
973+
It("discovers failure domains dynamically and filters out metro (-m) zones", func() {
974+
fakeCloud.AvailabilityZones = []string{"eu01-2", "eu01-m", "eu01-1", "eu01-3"}
975+
976+
result, err := reconciler.Reconcile(ctx, request)
977+
Expect(err).NotTo(HaveOccurred())
978+
Expect(result).To(Equal(reconcile.Result{}))
979+
980+
got := &infrav1.StackitCluster{}
981+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
982+
Expect(got.Status.FailureDomains).To(Equal([]clusterv1.FailureDomain{
983+
{Name: "eu01-1", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}},
984+
{Name: "eu01-2", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}},
985+
{Name: "eu01-3", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}},
986+
}))
987+
})
988+
989+
It("reports an error condition when listing availability zones fails", func() {
990+
fakeCloud.FailNextListAZ = fmt.Errorf("cloud failure: %w", cloud.ErrTransient)
991+
992+
result, err := reconciler.Reconcile(ctx, request)
993+
Expect(err).NotTo(HaveOccurred())
994+
Expect(result.RequeueAfter).To(BeNumerically(">", 0))
995+
996+
got := &infrav1.StackitCluster{}
997+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
998+
Expect(got.Status.Ready).To(BeFalse())
999+
expectCondition(got.Status.Conditions, infrav1.ClusterReadyCondition, metav1.ConditionFalse, "AvailabilityZonesError")
1000+
})
9721001
})
9731002

9741003
func expectBastionCloudInit(

‎controller/stackitcluster_infrastructure.go‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"errors"
1616
"fmt"
1717
"net/netip"
18+
"slices"
19+
"strings"
1820
"time"
1921

2022
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -48,7 +50,7 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS
4850
return ctrl.Result{}, fmt.Errorf("persist finalizer: %w", err)
4951
}
5052
}
51-
stackitCluster.Status.FailureDomains = stackitFailureDomains(stackitCluster.Spec.Region)
53+
clusterScope.SetFailureDomains(stackitFailureDomains(stackitCluster.Spec.Region))
5254

5355
cloudClient, err := util.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, stackitCluster)
5456
if err != nil {
@@ -71,6 +73,21 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS
7173
infrav1.ClusterCredentialsReadyCondition,
7274
)
7375

76+
zones, err := cloudClient.ListAvailabilityZones(ctx)
77+
if err != nil {
78+
stackitCluster.Status.Ready = false
79+
return util.CloudFailureResult(
80+
&stackitCluster.Status.Conditions,
81+
stackitCluster.Generation,
82+
"AvailabilityZonesError",
83+
err,
84+
retryableErrorRequeueAfter,
85+
true,
86+
infrav1.ClusterReadyCondition,
87+
)
88+
}
89+
clusterScope.SetFailureDomains(failureDomainsFromZones(stackitCluster.Spec.Region, zones))
90+
7491
network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID)
7592
if err != nil {
7693
log.Error(err, "Failed to get network",
@@ -272,6 +289,31 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS
272289
return ctrl.Result{}, nil
273290
}
274291

292+
func failureDomainsFromZones(region string, zones []string) []clusterv1.FailureDomain {
293+
controlPlane := true
294+
failureDomains := make([]clusterv1.FailureDomain, 0, len(zones))
295+
for _, zone := range zones {
296+
// Filter out virtual/metro zones (such as eu01-m or any zone with suffix -m)
297+
if strings.HasSuffix(zone, "-m") {
298+
continue
299+
}
300+
failureDomains = append(failureDomains, clusterv1.FailureDomain{
301+
Name: zone,
302+
ControlPlane: &controlPlane,
303+
Attributes: map[string]string{
304+
"region": region,
305+
},
306+
})
307+
}
308+
if len(failureDomains) == 0 {
309+
return stackitFailureDomains(region)
310+
}
311+
slices.SortFunc(failureDomains, func(a, b clusterv1.FailureDomain) int {
312+
return strings.Compare(a.Name, b.Name)
313+
})
314+
return failureDomains
315+
}
316+
275317
func stackitFailureDomains(region string) []clusterv1.FailureDomain {
276318
controlPlane := true
277319
return []clusterv1.FailureDomain{

‎controller/stackitmachine_controller_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,70 @@ var _ = Describe("StackitMachine Controller", func() {
285285
expectCondition(got.Status.Conditions, infrav1.MachineReadyCondition, metav1.ConditionFalse, "InvalidFailureDomain")
286286
})
287287

288+
//nolint:dupl // Failure domain resolution cases test explicit failureDomain vs cluster default.
289+
It("respects Machine.spec.failureDomain when StackitMachine.spec.availabilityZone is empty", func() {
290+
got := &infrav1.StackitMachine{}
291+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
292+
got.Spec.AvailabilityZone = ""
293+
Expect(k8sClient.Update(ctx, got)).To(Succeed())
294+
295+
ownerMachine := &clusterv1.Machine{}
296+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: machineName, Namespace: namespace}, ownerMachine)).To(Succeed())
297+
ownerMachine.Spec.FailureDomain = "eu01-2"
298+
Expect(k8sClient.Update(ctx, ownerMachine)).To(Succeed())
299+
300+
result, err := reconciler.Reconcile(ctx, request)
301+
Expect(err).NotTo(HaveOccurred())
302+
Expect(result).To(Equal(reconcile.Result{}))
303+
Expect(fakeCloud.ServerCount()).To(Equal(1))
304+
Expect(fakeCloud.LastCreateServerInput.AvailabilityZone).To(Equal("eu01-2"))
305+
306+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
307+
Expect(got.Status.Ready).To(BeTrue())
308+
Expect(got.Status.AvailabilityZone).To(Equal("eu01-2"))
309+
})
310+
311+
//nolint:dupl // Failure domain resolution cases test explicit failureDomain vs cluster default.
312+
It("defaults to the first failure domain when neither failureDomain nor availabilityZone is set", func() {
313+
got := &infrav1.StackitMachine{}
314+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
315+
got.Spec.AvailabilityZone = ""
316+
Expect(k8sClient.Update(ctx, got)).To(Succeed())
317+
318+
ownerMachine := &clusterv1.Machine{}
319+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: machineName, Namespace: namespace}, ownerMachine)).To(Succeed())
320+
ownerMachine.Spec.FailureDomain = ""
321+
Expect(k8sClient.Update(ctx, ownerMachine)).To(Succeed())
322+
323+
result, err := reconciler.Reconcile(ctx, request)
324+
Expect(err).NotTo(HaveOccurred())
325+
Expect(result).To(Equal(reconcile.Result{}))
326+
Expect(fakeCloud.ServerCount()).To(Equal(1))
327+
Expect(fakeCloud.LastCreateServerInput.AvailabilityZone).To(Equal("eu01-1"))
328+
329+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
330+
Expect(got.Status.Ready).To(BeTrue())
331+
Expect(got.Status.AvailabilityZone).To(Equal("eu01-1"))
332+
})
333+
334+
It("does not create a VM when Machine.spec.failureDomain is outside published failure domains", func() {
335+
ownerMachine := &clusterv1.Machine{}
336+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: machineName, Namespace: namespace}, ownerMachine)).To(Succeed())
337+
ownerMachine.Spec.FailureDomain = "eu01-9"
338+
Expect(k8sClient.Update(ctx, ownerMachine)).To(Succeed())
339+
340+
result, err := reconciler.Reconcile(ctx, request)
341+
Expect(err).NotTo(HaveOccurred())
342+
Expect(result).To(Equal(reconcile.Result{}))
343+
Expect(fakeCloud.ServerCount()).To(Equal(0))
344+
345+
got := &infrav1.StackitMachine{}
346+
Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed())
347+
Expect(got.Status.Ready).To(BeFalse())
348+
expectCondition(got.Status.Conditions, infrav1.MachineInstanceReadyCondition, metav1.ConditionFalse, "InvalidFailureDomain")
349+
expectCondition(got.Status.Conditions, infrav1.MachineReadyCondition, metav1.ConditionFalse, "InvalidFailureDomain")
350+
})
351+
288352
It("requeues on unauthorized credentials and recovers once they are corrected", func() {
289353
reconciler.CloudClientFactory = func(context.Context, cloud.Credentials) (cloud.Client, error) {
290354
return nil, fmt.Errorf("authenticate: %w", cloud.ErrUnauthorized)

‎controller/stackitmachine_infrastructure.go‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,28 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, machineS
184184
}
185185

186186
func validateMachineAvailabilityZone(machineScope *scope.MachineScope) error {
187-
availabilityZone := machineScope.StackitMachine.Spec.AvailabilityZone
188-
if availabilityZone == "" || len(machineScope.StackitCluster.Status.FailureDomains) == 0 {
187+
clusterFailureDomains := machineScope.StackitCluster.Status.FailureDomains
188+
if len(clusterFailureDomains) == 0 {
189189
return nil
190190
}
191-
for _, failureDomain := range machineScope.StackitCluster.Status.FailureDomains {
192-
if failureDomain.Name == availabilityZone {
193-
return nil
191+
knownAZ := func(zone string) bool {
192+
for _, failureDomain := range clusterFailureDomains {
193+
if failureDomain.Name == zone {
194+
return true
195+
}
194196
}
197+
return false
198+
}
199+
if az := machineScope.StackitMachine.Spec.AvailabilityZone; az != "" && !knownAZ(az) {
200+
return fmt.Errorf("availabilityZone %q is not published in StackitCluster status.failureDomains", az)
195201
}
196-
return fmt.Errorf("availabilityZone %q is not published in StackitCluster status.failureDomains", availabilityZone)
202+
if machineScope.Machine != nil && machineScope.Machine.Spec.FailureDomain != "" {
203+
fd := machineScope.Machine.Spec.FailureDomain
204+
if !knownAZ(fd) {
205+
return fmt.Errorf("machine failureDomain %q is not published in StackitCluster status.failureDomains", fd)
206+
}
207+
}
208+
return nil
197209
}
198210

199211
func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, machineScope *scope.MachineScope) error {
@@ -335,7 +347,7 @@ func (r *StackitMachineReconciler) ensureServer(
335347
Region: machineScope.StackitCluster.Spec.Region,
336348
ImageID: stackitMachine.Spec.ImageID,
337349
MachineType: stackitMachine.Spec.MachineType,
338-
AvailabilityZone: stackitMachine.Spec.AvailabilityZone,
350+
AvailabilityZone: machineScope.AvailabilityZone(),
339351
SSHKeyName: stackitMachine.Spec.SSHKeyName,
340352
NetworkID: stackitMachine.Spec.Network.ID,
341353
SecurityGroups: stackitMachine.Spec.SecurityGroups,

0 commit comments

Comments
 (0)