From 3e05dc76f1fb4ba8e8b1e6e41c7940bc3c39d58a Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Mon, 22 Jun 2026 10:47:03 +0200 Subject: [PATCH 1/9] refactor(controller): reduce duplication by introducing helpers and better abstractions Signed-off-by: Jan Larwig --- .../controller_test_helpers_test.go | 15 + internal/controller/helpers/bastion.go | 51 +++ internal/controller/helpers/cloud_client.go | 60 ++++ internal/controller/helpers/conditions.go | 66 ++++ internal/controller/helpers/loadbalancer.go | 131 ++++++++ .../controller/stackitcluster_controller.go | 285 +++++++++-------- .../stackitcluster_controller_test.go | 7 +- .../controller/stackitmachine_controller.go | 291 ++++++++---------- .../stackitmachine_controller_test.go | 2 + 9 files changed, 603 insertions(+), 305 deletions(-) create mode 100644 internal/controller/helpers/bastion.go create mode 100644 internal/controller/helpers/cloud_client.go create mode 100644 internal/controller/helpers/conditions.go create mode 100644 internal/controller/helpers/loadbalancer.go diff --git a/internal/controller/controller_test_helpers_test.go b/internal/controller/controller_test_helpers_test.go index 75081ad..6705ade 100644 --- a/internal/controller/controller_test_helpers_test.go +++ b/internal/controller/controller_test_helpers_test.go @@ -18,6 +18,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" @@ -176,6 +177,20 @@ func enableStackitClusterLoadBalancer(ctx context.Context, name, namespace strin Expect(k8sClient.Update(ctx, stackitCluster)).To(Succeed()) } +func reconcileStackitClusterOnce(ctx context.Context, name, namespace string, cloudClient cloud.Client) { + reconciler := &StackitClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + CloudClientFactory: func(context.Context, cloud.Credentials) (cloud.Client, error) { + return cloudClient, nil + }, + } + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKey{Name: name, Namespace: namespace}, + }) + Expect(err).NotTo(HaveOccurred()) +} + func expectCondition(conditions []metav1.Condition, conditionType string, status metav1.ConditionStatus, reason string) { for _, condition := range conditions { if condition.Type == conditionType { diff --git a/internal/controller/helpers/bastion.go b/internal/controller/helpers/bastion.go new file mode 100644 index 0000000..b6cac12 --- /dev/null +++ b/internal/controller/helpers/bastion.go @@ -0,0 +1,51 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package helpers + +import ( + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" +) + +func BastionInput(stackitCluster *infrav1.StackitCluster, cloudInit []byte) cloud.BastionInput { + deleteOnTermination := true + if stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination != nil { + deleteOnTermination = *stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination + } + + tags := util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) + tags[util.LabelResourceRole] = util.ResourceRoleBastion + + return cloud.BastionInput{ + Name: stackitCluster.Name + "-bastion", + ProjectID: stackitCluster.Spec.ProjectID, + Region: stackitCluster.Spec.Region, + NetworkID: stackitCluster.Spec.Network.ID, + ImageID: stackitCluster.Spec.Bastion.ImageID, + MachineType: stackitCluster.Spec.Bastion.MachineType, + SSHKeyName: stackitCluster.Spec.Bastion.SSHKeyName, + AllowedCIDRs: stackitCluster.Spec.Bastion.AllowedCIDRs, + Tags: tags, + RootVolume: cloud.RootVolumeInput{ + SizeGiB: stackitCluster.Spec.Bastion.RootVolume.SizeGiB, + PerformanceClass: stackitCluster.Spec.Bastion.RootVolume.PerformanceClass, + DeleteOnTermination: deleteOnTermination, + }, + CloudInit: cloudInit, + } +} + +func NodeSSHAccessTags(stackitCluster *infrav1.StackitCluster) map[string]string { + tags := util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) + tags[util.LabelResourceRole] = util.ResourceRoleNodeSSH + return tags +} diff --git a/internal/controller/helpers/cloud_client.go b/internal/controller/helpers/cloud_client.go new file mode 100644 index 0000000..af5e35b --- /dev/null +++ b/internal/controller/helpers/cloud_client.go @@ -0,0 +1,60 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package helpers + +import ( + "context" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" +) + +func BuildCloudClient( + ctx context.Context, + k8sClient client.Client, + factory cloud.Factory, + stackitCluster *infrav1.StackitCluster, +) (cloud.Client, error) { + if factory == nil { + return nil, errors.New("CloudClientFactory is not configured") + } + + secret := &corev1.Secret{} + key := CredentialsSecretKey(stackitCluster) + if err := k8sClient.Get(ctx, key, secret); err != nil { + return nil, fmt.Errorf("get credentials secret %s: %w", key, err) + } + + creds, err := util.ParseCredentialsSecret(secret, stackitCluster.Spec.ProjectID, stackitCluster.Spec.Region) + if err != nil { + return nil, err + } + + return factory(ctx, creds) +} + +func CredentialsSecretKey(stackitCluster *infrav1.StackitCluster) types.NamespacedName { + namespace := stackitCluster.Spec.CredentialsSecretRef.Namespace + if namespace == "" { + namespace = stackitCluster.Namespace + } + return types.NamespacedName{ + Namespace: namespace, + Name: stackitCluster.Spec.CredentialsSecretRef.Name, + } +} diff --git a/internal/controller/helpers/conditions.go b/internal/controller/helpers/conditions.go new file mode 100644 index 0000000..a9fda9b --- /dev/null +++ b/internal/controller/helpers/conditions.go @@ -0,0 +1,66 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package helpers + +import ( + "errors" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" +) + +func SetConditions( + conditions *[]metav1.Condition, + generation int64, + status metav1.ConditionStatus, + reason, message string, + conditionTypes ...string, +) { + for _, conditionType := range conditionTypes { + util.SetCondition(conditions, conditionType, status, reason, message, generation) + } +} + +func CredentialFailureResult( + conditions *[]metav1.Condition, + generation int64, + err error, + conditionTypes ...string, +) (ctrl.Result, error) { + SetConditions(conditions, generation, metav1.ConditionFalse, "CredentialsInvalid", err.Error(), conditionTypes...) + if cloud.IsUnauthorized(err) || cloud.IsInvalidInput(err) || errors.Is(err, util.ErrCredentialsInvalid) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err +} + +func CloudFailureResult( + conditions *[]metav1.Condition, + generation int64, + reason string, + err error, + requeueAfter time.Duration, + returnError bool, + conditionTypes ...string, +) (ctrl.Result, error) { + SetConditions(conditions, generation, metav1.ConditionFalse, reason, err.Error(), conditionTypes...) + if cloud.IsRetryable(err) { + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + if returnError { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} diff --git a/internal/controller/helpers/loadbalancer.go b/internal/controller/helpers/loadbalancer.go new file mode 100644 index 0000000..f5478f7 --- /dev/null +++ b/internal/controller/helpers/loadbalancer.go @@ -0,0 +1,131 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package helpers + +import ( + "context" + "fmt" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" +) + +const defaultAPIServerPort int32 = 6443 + +func APIServerLoadBalancerTags(stackitCluster *infrav1.StackitCluster) map[string]string { + return util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) +} + +func APIServerLoadBalancerInput( + stackitCluster *infrav1.StackitCluster, + targets []cloud.LoadBalancerTargetInput, +) cloud.LoadBalancerInput { + return cloud.LoadBalancerInput{ + Name: stackitCluster.Name + "-apiserver", + ProjectID: stackitCluster.Spec.ProjectID, + Region: stackitCluster.Spec.Region, + NetworkID: stackitCluster.Spec.Network.ID, + Port: defaultAPIServerPort, + Tags: APIServerLoadBalancerTags(stackitCluster), + Targets: targets, + } +} + +func BootstrapAPIServerLoadBalancerTarget(ip string) cloud.LoadBalancerTargetInput { + return cloud.LoadBalancerTargetInput{ + Name: "capi-bootstrap-placeholder", + IP: ip, + Port: defaultAPIServerPort, + } +} + +func APIServerLoadBalancerTargetForMachine( + machineName string, + addresses []cloud.Address, +) (cloud.LoadBalancerTargetInput, error) { + ip := FirstInternalIP(addresses) + if ip == "" { + return cloud.LoadBalancerTargetInput{}, fmt.Errorf("%w: server has no internal IP address", cloud.ErrTransient) + } + + return cloud.LoadBalancerTargetInput{ + Name: machineName, + IP: ip, + Port: defaultAPIServerPort, + }, nil +} + +func ResolveAPIServerLoadBalancerID( + ctx context.Context, + cloudClient cloud.Client, + stackitCluster *infrav1.StackitCluster, +) (string, error) { + if stackitCluster.Status.APIServerLoadBalancerID != "" { + return stackitCluster.Status.APIServerLoadBalancerID, nil + } + + loadBalancers, err := cloudClient.ListAPIServerLoadBalancersByTags(ctx, APIServerLoadBalancerTags(stackitCluster)) + if err != nil { + return "", err + } + if len(loadBalancers) == 0 { + return "", nil + } + if len(loadBalancers) > 1 { + return "", fmt.Errorf("multiple API server load balancers match cluster tags: %w", cloud.ErrConflict) + } + return loadBalancers[0].ID, nil +} + +func EnsureAPIServerLoadBalancerForMachine( + ctx context.Context, + cloudClient cloud.Client, + stackitCluster *infrav1.StackitCluster, + machineName string, + addresses []cloud.Address, +) (string, error) { + loadBalancerID, err := ResolveAPIServerLoadBalancerID(ctx, cloudClient, stackitCluster) + if err != nil { + return "", err + } + if loadBalancerID != "" { + return loadBalancerID, nil + } + + target, err := APIServerLoadBalancerTargetForMachine(machineName, addresses) + if err != nil { + return "", err + } + + loadBalancer, err := cloudClient.EnsureAPIServerLoadBalancer( + ctx, + APIServerLoadBalancerInput(stackitCluster, []cloud.LoadBalancerTargetInput{target}), + ) + if err != nil { + return "", err + } + if loadBalancer == nil || loadBalancer.ID == "" { + return "", fmt.Errorf("%w: API server load balancer ID is empty", cloud.ErrTransient) + } + return loadBalancer.ID, nil +} + +func FirstInternalIP(addresses []cloud.Address) string { + for _, address := range addresses { + if address.Type == string(clusterv1.MachineInternalIP) && address.Address != "" { + return address.Address + } + } + return "" +} diff --git a/internal/controller/stackitcluster_controller.go b/internal/controller/stackitcluster_controller.go index 7db1e8e..a519937 100644 --- a/internal/controller/stackitcluster_controller.go +++ b/internal/controller/stackitcluster_controller.go @@ -19,7 +19,6 @@ package controller import ( "context" "crypto/sha256" - "errors" "fmt" "net/netip" "time" @@ -39,6 +38,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + ctrlhlp "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller/helpers" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/scope" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" @@ -48,8 +48,6 @@ const ( // defaultAPIServerPort is used when an LB is created without an explicit port. defaultAPIServerPort int32 = 6443 - bootstrapTargetName = "capi-bootstrap-placeholder" - cloudInitRefKindSecret = "Secret" retryableErrorRequeueAfter = 5 * time.Second @@ -124,108 +122,153 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, s *scope } sc.Status.FailureDomains = stackitFailureDomains(sc.Spec.Region) - cloudClient, err := r.buildCloudClient(ctx, sc) + cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, sc) if err != nil { sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterCredentialsReadyCondition, - metav1.ConditionFalse, "CredentialsInvalid", err.Error(), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "CredentialsInvalid", err.Error(), sc.Generation) - // Auth/invalid input errors should not aggressively requeue. - if cloud.IsUnauthorized(err) || cloud.IsInvalidInput(err) || errors.Is(err, util.ErrCredentialsInvalid) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterCredentialsReadyCondition, - metav1.ConditionTrue, "Available", "", sc.Generation) + return ctrlhlp.CredentialFailureResult( + &sc.Status.Conditions, + sc.Generation, + err, + infrav1.ClusterCredentialsReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionTrue, + "Available", + "", + infrav1.ClusterCredentialsReadyCondition, + ) network, err := cloudClient.GetNetwork(ctx, sc.Spec.Network.ID) if err != nil { sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterNetworkReadyCondition, - metav1.ConditionFalse, "NetworkNotFound", err.Error(), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "NetworkNotFound", err.Error(), sc.Generation) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - return ctrl.Result{}, nil - } - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterNetworkReadyCondition, - metav1.ConditionTrue, "Available", "", sc.Generation) + return ctrlhlp.CloudFailureResult( + &sc.Status.Conditions, + sc.Generation, + "NetworkNotFound", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionTrue, + "Available", + "", + infrav1.ClusterNetworkReadyCondition, + ) if sc.Spec.APIServerLoadBalancer.Enabled { - lb, err := cloudClient.EnsureAPIServerLoadBalancer(ctx, cloud.LoadBalancerInput{ - Name: sc.Name + "-apiserver", - ProjectID: sc.Spec.ProjectID, - Region: sc.Spec.Region, - NetworkID: sc.Spec.Network.ID, - Port: defaultAPIServerPort, - Tags: util.ClusterTags(sc.Name, sc.Namespace, sc.Spec.AdditionalLabels), - Targets: []cloud.LoadBalancerTargetInput{{ - Name: bootstrapTargetName, - IP: bootstrapTargetIP(network), - Port: defaultAPIServerPort, - }}, - }) + lb, err := cloudClient.EnsureAPIServerLoadBalancer( + ctx, + ctrlhlp.APIServerLoadBalancerInput( + sc, + []cloud.LoadBalancerTargetInput{ctrlhlp.BootstrapAPIServerLoadBalancerTarget(bootstrapTargetIP(network))}, + ), + ) if err != nil { sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, - metav1.ConditionFalse, "LoadBalancerError", err.Error(), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "LoadBalancerError", err.Error(), sc.Generation) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - return ctrl.Result{}, nil + return ctrlhlp.CloudFailureResult( + &sc.Status.Conditions, + sc.Generation, + "LoadBalancerError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterLoadBalancerReadyCondition, + infrav1.ClusterReadyCondition, + ) } if lb != nil { sc.Status.APIServerLoadBalancerID = lb.ID } if lb == nil || lb.IP == "" { sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, - metav1.ConditionFalse, "Provisioning", "waiting for API server load balancer IP address", sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "Provisioning", "waiting for API server load balancer IP address", sc.Generation) + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionFalse, + "Provisioning", + "waiting for API server load balancer IP address", + infrav1.ClusterLoadBalancerReadyCondition, + infrav1.ClusterReadyCondition, + ) return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } - sc.Spec.ControlPlaneEndpoint = clusterv1.APIEndpoint{Host: lb.IP, Port: defaultAPIServerPort} - sc.Status.APIServerEndpoint = sc.Spec.ControlPlaneEndpoint - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, - metav1.ConditionTrue, "Available", "", sc.Generation) + if lb == nil { + return ctrl.Result{}, fmt.Errorf("%w: API server load balancer is nil", cloud.ErrTransient) + } + endpoint := clusterv1.APIEndpoint{ + Host: lb.IP, + Port: defaultAPIServerPort, + } + sc.Spec.ControlPlaneEndpoint = endpoint + sc.Status.APIServerEndpoint = endpoint + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionTrue, + "Available", + "", + infrav1.ClusterLoadBalancerReadyCondition, + ) } else if sc.Spec.ControlPlaneEndpoint.Host != "" { sc.Status.APIServerEndpoint = sc.Spec.ControlPlaneEndpoint - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, - metav1.ConditionTrue, "Skipped", "external endpoint provided", sc.Generation) + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionTrue, + "Skipped", + "external endpoint provided", + infrav1.ClusterLoadBalancerReadyCondition, + ) } else { sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, - metav1.ConditionFalse, "EndpointMissing", "apiServerLoadBalancer.enabled is false and controlPlaneEndpoint is empty", sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "EndpointMissing", "apiServerLoadBalancer.enabled is false and controlPlaneEndpoint is empty", sc.Generation) + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionFalse, + "EndpointMissing", + "apiServerLoadBalancer.enabled is false and controlPlaneEndpoint is empty", + infrav1.ClusterLoadBalancerReadyCondition, + infrav1.ClusterReadyCondition, + ) return ctrl.Result{}, nil } if result, ready, err := r.reconcileBastion(ctx, cloudClient, sc); err != nil { sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionFalse, "BastionError", err.Error(), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "BastionError", err.Error(), sc.Generation) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - return ctrl.Result{}, nil + return ctrlhlp.CloudFailureResult( + &sc.Status.Conditions, + sc.Generation, + "BastionError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterBastionReadyCondition, + infrav1.ClusterReadyCondition, + ) } else if !ready { return result, nil } sc.Status.Ready = true sc.Status.Initialization.Provisioned = true - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionTrue, "Available", "", sc.Generation) + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionTrue, + "Available", + "", + infrav1.ClusterReadyCondition, + ) log.V(1).Info("StackitCluster ready", "endpoint", sc.Status.APIServerEndpoint) return ctrl.Result{}, nil } @@ -282,7 +325,7 @@ func (r *StackitClusterReconciler) reconcileBastion( cloudClient cloud.Client, sc *infrav1.StackitCluster, ) (ctrl.Result, bool, error) { - input := bastionInput(sc, nil) + input := ctrlhlp.BastionInput(sc, nil) status := cloud.Bastion{ ServerID: sc.Status.Bastion.ServerID, PublicIPID: sc.Status.Bastion.PublicIPID, @@ -292,7 +335,7 @@ func (r *StackitClusterReconciler) reconcileBastion( if !sc.Spec.Bastion.Enabled { if hasBastionStatus(sc.Status.Bastion) { - if err := cloudClient.DeleteNodeSSHAccess(ctx, nodeSSHAccessTags(sc)); err != nil { + if err := cloudClient.DeleteNodeSSHAccess(ctx, ctrlhlp.NodeSSHAccessTags(sc)); err != nil { return ctrl.Result{}, false, err } if err := cloudClient.DeleteBastion(ctx, input, status); err != nil { @@ -326,7 +369,7 @@ func (r *StackitClusterReconciler) reconcileBastion( input.CloudInit = cloudInit if bastionNeedsRecreate(sc, cloudInit) { - if err := cloudClient.DeleteNodeSSHAccess(ctx, nodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { + if err := cloudClient.DeleteNodeSSHAccess(ctx, ctrlhlp.NodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { return ctrl.Result{}, false, err } if err := cloudClient.DeleteBastion(ctx, input, status); err != nil && !cloud.IsNotFound(err) { @@ -375,27 +418,37 @@ func (r *StackitClusterReconciler) reconcileBastion( func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, s *scope.ClusterScope) error { sc := s.StackitCluster - if sc.Status.APIServerLoadBalancerID != "" || hasBastionStatus(sc.Status.Bastion) { - cloudClient, err := r.buildCloudClient(ctx, sc) + if sc.Status.APIServerLoadBalancerID != "" || hasBastionStatus(sc.Status.Bastion) || sc.Spec.APIServerLoadBalancer.Enabled { + cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, sc) if err != nil { // If we cannot reach the cloud during delete, surface the condition // but do not block forever; finalizer removal is gated on the LB // deletion succeeding (or being already absent). - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterCredentialsReadyCondition, - metav1.ConditionFalse, "CredentialsInvalid", err.Error(), sc.Generation) + ctrlhlp.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionFalse, + "CredentialsInvalid", + err.Error(), + infrav1.ClusterCredentialsReadyCondition, + ) + return err + } + loadBalancerID, err := ctrlhlp.ResolveAPIServerLoadBalancerID(ctx, cloudClient, sc) + if err != nil { return err } - if sc.Status.APIServerLoadBalancerID != "" { - if err := cloudClient.DeleteAPIServerLoadBalancer(ctx, sc.Status.APIServerLoadBalancerID); err != nil && !cloud.IsNotFound(err) { + if loadBalancerID != "" { + if err := cloudClient.DeleteAPIServerLoadBalancer(ctx, loadBalancerID); err != nil && !cloud.IsNotFound(err) { return err } sc.Status.APIServerLoadBalancerID = "" } if hasBastionStatus(sc.Status.Bastion) { - if err := cloudClient.DeleteNodeSSHAccess(ctx, nodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { + if err := cloudClient.DeleteNodeSSHAccess(ctx, ctrlhlp.NodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { return err } - if err := cloudClient.DeleteBastion(ctx, bastionInput(sc, nil), cloud.Bastion{ + if err := cloudClient.DeleteBastion(ctx, ctrlhlp.BastionInput(sc, nil), cloud.Bastion{ ServerID: sc.Status.Bastion.ServerID, PublicIPID: sc.Status.Bastion.PublicIPID, PublicIP: sc.Status.Bastion.PublicIP, @@ -410,38 +463,6 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, s *scope return nil } -func bastionInput(sc *infrav1.StackitCluster, cloudInit []byte) cloud.BastionInput { - deleteOnTermination := true - if sc.Spec.Bastion.RootVolume.DeleteOnTermination != nil { - deleteOnTermination = *sc.Spec.Bastion.RootVolume.DeleteOnTermination - } - tags := util.ClusterTags(sc.Name, sc.Namespace, sc.Spec.AdditionalLabels) - tags[util.LabelResourceRole] = util.ResourceRoleBastion - return cloud.BastionInput{ - Name: sc.Name + "-bastion", - ProjectID: sc.Spec.ProjectID, - Region: sc.Spec.Region, - NetworkID: sc.Spec.Network.ID, - ImageID: sc.Spec.Bastion.ImageID, - MachineType: sc.Spec.Bastion.MachineType, - SSHKeyName: sc.Spec.Bastion.SSHKeyName, - AllowedCIDRs: sc.Spec.Bastion.AllowedCIDRs, - Tags: tags, - RootVolume: cloud.RootVolumeInput{ - SizeGiB: sc.Spec.Bastion.RootVolume.SizeGiB, - PerformanceClass: sc.Spec.Bastion.RootVolume.PerformanceClass, - DeleteOnTermination: deleteOnTermination, - }, - CloudInit: cloudInit, - } -} - -func nodeSSHAccessTags(sc *infrav1.StackitCluster) map[string]string { - tags := util.ClusterTags(sc.Name, sc.Namespace, sc.Spec.AdditionalLabels) - tags[util.LabelResourceRole] = util.ResourceRoleNodeSSH - return tags -} - func validateBastionSpec(spec infrav1.StackitBastionSpec) error { if spec.ImageID == "" { return fmt.Errorf("%w: bastion.imageID is required", cloud.ErrInvalidInput) @@ -519,35 +540,19 @@ func (r *StackitClusterReconciler) resolveBastionCloudInit(ctx context.Context, } } -func (r *StackitClusterReconciler) buildCloudClient(ctx context.Context, sc *infrav1.StackitCluster) (cloud.Client, error) { - if r.CloudClientFactory == nil { - return nil, errors.New("CloudClientFactory is not configured") - } - secret := &corev1.Secret{} - ns := sc.Spec.CredentialsSecretRef.Namespace - if ns == "" { - ns = sc.Namespace - } - key := types.NamespacedName{Namespace: ns, Name: sc.Spec.CredentialsSecretRef.Name} - if err := r.Get(ctx, key, secret); err != nil { - return nil, fmt.Errorf("get credentials secret %s: %w", key, err) - } - creds, err := util.ParseCredentialsSecret(secret, sc.Spec.ProjectID, sc.Spec.Region) - if err != nil { - return nil, err - } - return r.CloudClientFactory(ctx, creds) -} - func (r *StackitClusterReconciler) stackitClusterRequestsForCluster(_ context.Context, obj client.Object) []reconcile.Request { cluster, ok := obj.(*clusterv1.Cluster) - if !ok || !isStackitClusterRef(cluster.Spec.InfrastructureRef) { + if !ok { + return nil + } + ref := cluster.Spec.InfrastructureRef + if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitCluster" || ref.Name == "" { return nil } return []reconcile.Request{{ NamespacedName: types.NamespacedName{ Namespace: cluster.Namespace, - Name: cluster.Spec.InfrastructureRef.Name, + Name: ref.Name, }, }} } @@ -585,12 +590,6 @@ func (r *StackitClusterReconciler) stackitClusterRequestsForCloudInitRef(ctx con return requests } -func isStackitClusterRef(ref clusterv1.ContractVersionedObjectReference) bool { - return ref.APIGroup == infrav1.GroupVersion.Group && - ref.Kind == "StackitCluster" && - ref.Name != "" -} - // SetupWithManager registers the controller with the manager. func (r *StackitClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/stackitcluster_controller_test.go b/internal/controller/stackitcluster_controller_test.go index 28448b0..42927b3 100644 --- a/internal/controller/stackitcluster_controller_test.go +++ b/internal/controller/stackitcluster_controller_test.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + ctrlhlp "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller/helpers" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud/fake" ) @@ -151,7 +152,7 @@ var _ = Describe("StackitCluster Controller", func() { Name: got.Name + "-node-ssh", ServerID: got.Status.Bastion.ServerID, BastionSecurityGroupID: got.Status.Bastion.SecurityGroupID, - Tags: nodeSSHAccessTags(got), + Tags: ctrlhlp.NodeSSHAccessTags(got), }) Expect(err).NotTo(HaveOccurred()) Expect(fakeCloud.SecurityGroupCount()).To(Equal(2)) @@ -184,7 +185,7 @@ var _ = Describe("StackitCluster Controller", func() { Name: got.Name + "-node-ssh", ServerID: got.Status.Bastion.ServerID, BastionSecurityGroupID: got.Status.Bastion.SecurityGroupID, - Tags: nodeSSHAccessTags(got), + Tags: ctrlhlp.NodeSSHAccessTags(got), }) Expect(err).NotTo(HaveOccurred()) Expect(fakeCloud.SecurityGroupCount()).To(Equal(2)) @@ -303,7 +304,7 @@ var _ = Describe("StackitCluster Controller", func() { Name: got.Name + "-node-ssh", ServerID: got.Status.Bastion.ServerID, BastionSecurityGroupID: got.Status.Bastion.SecurityGroupID, - Tags: nodeSSHAccessTags(got), + Tags: ctrlhlp.NodeSSHAccessTags(got), }) Expect(err).NotTo(HaveOccurred()) Expect(fakeCloud.SecurityGroupCount()).To(Equal(2)) diff --git a/internal/controller/stackitmachine_controller.go b/internal/controller/stackitmachine_controller.go index c8c65f5..c5052d9 100644 --- a/internal/controller/stackitmachine_controller.go +++ b/internal/controller/stackitmachine_controller.go @@ -18,7 +18,6 @@ package controller import ( "context" - "errors" "fmt" "time" @@ -37,6 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + ctrlhlp "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller/helpers" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/scope" "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" @@ -129,23 +129,33 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, s *scope } if !s.StackitCluster.Status.Ready { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "InfrastructureNotReady", "waiting for StackitCluster to be ready", sm.Generation) + ctrlhlp.SetConditions( + &sm.Status.Conditions, + sm.Generation, + metav1.ConditionFalse, + "InfrastructureNotReady", + "waiting for StackitCluster to be ready", + infrav1.MachineReadyCondition, + ) return ctrl.Result{}, nil } if err := validateMachineAvailabilityZone(s); err != nil { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineInstanceReadyCondition, - metav1.ConditionFalse, "InvalidFailureDomain", err.Error(), sm.Generation) - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "InvalidFailureDomain", err.Error(), sm.Generation) + ctrlhlp.SetConditions( + &sm.Status.Conditions, + sm.Generation, + metav1.ConditionFalse, + "InvalidFailureDomain", + err.Error(), + infrav1.MachineInstanceReadyCondition, + infrav1.MachineReadyCondition, + ) return ctrl.Result{}, nil } bootstrapData, condStatus, reason, msg := r.fetchBootstrapData(ctx, s.Machine) - util.SetCondition(&sm.Status.Conditions, infrav1.MachineBootstrapReadyCondition, condStatus, reason, msg, sm.Generation) + ctrlhlp.SetConditions(&sm.Status.Conditions, sm.Generation, condStatus, reason, msg, infrav1.MachineBootstrapReadyCondition) if condStatus != metav1.ConditionTrue { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, reason, msg, sm.Generation) + ctrlhlp.SetConditions(&sm.Status.Conditions, sm.Generation, metav1.ConditionFalse, reason, msg, infrav1.MachineReadyCondition) // Per spec 13.3, missing/not-found cases requeue; invalid does not. if reason == util.BootstrapReasonInvalid { return ctrl.Result{}, nil @@ -153,37 +163,50 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, s *scope return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil } - cloudClient, err := r.buildCloudClient(ctx, s.StackitCluster) + cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, s.StackitCluster) if err != nil { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineCredentialsReadyCondition, - metav1.ConditionFalse, "CredentialsInvalid", err.Error(), sm.Generation) - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "CredentialsInvalid", err.Error(), sm.Generation) - if cloud.IsUnauthorized(err) || cloud.IsInvalidInput(err) || errors.Is(err, util.ErrCredentialsInvalid) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - util.SetCondition(&sm.Status.Conditions, infrav1.MachineCredentialsReadyCondition, - metav1.ConditionTrue, "Available", "", sm.Generation) - - tags := util.MachineTags(s.Cluster.Name, s.Cluster.Namespace, s.Machine.Name, string(s.Machine.UID), sm.Spec.AdditionalLabels) + return ctrlhlp.CredentialFailureResult( + &sm.Status.Conditions, + sm.Generation, + err, + infrav1.MachineCredentialsReadyCondition, + infrav1.MachineReadyCondition, + ) + } + ctrlhlp.SetConditions( + &sm.Status.Conditions, + sm.Generation, + metav1.ConditionTrue, + "Available", + "", + infrav1.MachineCredentialsReadyCondition, + ) + + tags := util.MachineTags( + s.Cluster.Name, + s.Cluster.Namespace, + s.Machine.Name, + string(s.Machine.UID), + s.StackitMachine.Spec.AdditionalLabels, + ) server, err := r.ensureServer(ctx, cloudClient, s, bootstrapData, tags) if err != nil { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineInstanceReadyCondition, - metav1.ConditionFalse, "InstanceError", err.Error(), sm.Generation) - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "InstanceError", err.Error(), sm.Generation) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - return ctrl.Result{}, err + return ctrlhlp.CloudFailureResult( + &sm.Status.Conditions, + sm.Generation, + "InstanceError", + err, + retryableErrorRequeueAfter, + true, + infrav1.MachineInstanceReadyCondition, + infrav1.MachineReadyCondition, + ) } sm.Status.InstanceID = server.ID sm.Status.InstanceState = server.State - sm.Status.Addresses = toMachineAddresses(server.Addresses) + sm.Status.Addresses = machineAddressesFromCloud(server.Addresses) providerID := cloud.NewProviderID(s.StackitCluster.Spec.ProjectID, s.StackitCluster.Spec.Region, server.ID) sm.Spec.ProviderID = &providerID @@ -192,37 +215,53 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, s *scope if server.State != "" && server.State != "ACTIVE" { sm.Status.Ready = false - util.SetCondition(&sm.Status.Conditions, infrav1.MachineInstanceReadyCondition, - metav1.ConditionFalse, "Provisioning", fmt.Sprintf("server state is %s", server.State), sm.Generation) - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "Provisioning", fmt.Sprintf("server state is %s", server.State), sm.Generation) + ctrlhlp.SetConditions( + &sm.Status.Conditions, + sm.Generation, + metav1.ConditionFalse, + "Provisioning", + fmt.Sprintf("server state is %s", server.State), + infrav1.MachineInstanceReadyCondition, + infrav1.MachineReadyCondition, + ) return ctrl.Result{RequeueAfter: 15 * time.Second}, nil } if err := r.reconcileBastionNodeSSHAccess(ctx, cloudClient, s, server); err != nil { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "BastionSSHAccessError", err.Error(), sm.Generation) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - return ctrl.Result{}, err + return ctrlhlp.CloudFailureResult( + &sm.Status.Conditions, + sm.Generation, + "BastionSSHAccessError", + err, + retryableErrorRequeueAfter, + true, + infrav1.MachineReadyCondition, + ) } if err := r.reconcileAPIServerLoadBalancerTarget(ctx, cloudClient, s, server); err != nil { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionFalse, "LoadBalancerTargetError", err.Error(), sm.Generation) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - return ctrl.Result{}, err + return ctrlhlp.CloudFailureResult( + &sm.Status.Conditions, + sm.Generation, + "LoadBalancerTargetError", + err, + retryableErrorRequeueAfter, + true, + infrav1.MachineReadyCondition, + ) } sm.Status.Ready = true - util.SetCondition(&sm.Status.Conditions, infrav1.MachineInstanceReadyCondition, - metav1.ConditionTrue, "Available", "", sm.Generation) - util.SetCondition(&sm.Status.Conditions, infrav1.MachineReadyCondition, - metav1.ConditionTrue, "Available", "", sm.Generation) + ctrlhlp.SetConditions( + &sm.Status.Conditions, + sm.Generation, + metav1.ConditionTrue, + "Available", + "", + infrav1.MachineInstanceReadyCondition, + infrav1.MachineReadyCondition, + ) log.V(1).Info("StackitMachine ready", "providerID", providerID) return ctrl.Result{}, nil } @@ -249,11 +288,15 @@ func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, s *scope controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) return nil } - cloudClient, err := r.buildCloudClient(ctx, s.StackitCluster) + cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, s.StackitCluster) if err != nil { - util.SetCondition(&sm.Status.Conditions, infrav1.MachineCredentialsReadyCondition, - metav1.ConditionFalse, "CredentialsInvalid", err.Error(), sm.Generation) - return err + _, resultErr := ctrlhlp.CredentialFailureResult( + &sm.Status.Conditions, + sm.Generation, + err, + infrav1.MachineCredentialsReadyCondition, + ) + return resultErr } if err := r.deleteAPIServerLoadBalancerTarget(ctx, cloudClient, s); err != nil { return err @@ -356,7 +399,7 @@ func (r *StackitMachineReconciler) reconcileBastionNodeSSHAccess( Name: s.StackitCluster.Name + "-node-ssh", ServerID: server.ID, BastionSecurityGroupID: s.StackitCluster.Status.Bastion.SecurityGroupID, - Tags: nodeSSHAccessTags(s.StackitCluster), + Tags: ctrlhlp.NodeSSHAccessTags(s.StackitCluster), }) return err } @@ -370,78 +413,38 @@ func (r *StackitMachineReconciler) reconcileAPIServerLoadBalancerTarget( if !isControlPlaneMachine(s.Machine) || !s.StackitCluster.Spec.APIServerLoadBalancer.Enabled { return nil } - if s.StackitCluster.Status.APIServerLoadBalancerID == "" { - ip := firstInternalIP(server.Addresses) - if ip == "" { - return fmt.Errorf("%w: server has no internal IP address", cloud.ErrTransient) - } - lb, err := c.EnsureAPIServerLoadBalancer(ctx, cloud.LoadBalancerInput{ - Name: s.StackitCluster.Name + "-apiserver", - ProjectID: s.StackitCluster.Spec.ProjectID, - Region: s.StackitCluster.Spec.Region, - NetworkID: s.StackitCluster.Spec.Network.ID, - Port: defaultAPIServerPort, - Tags: util.ClusterTags(s.StackitCluster.Name, s.StackitCluster.Namespace, s.StackitCluster.Spec.AdditionalLabels), - Targets: []cloud.LoadBalancerTargetInput{{ - Name: s.Machine.Name, - IP: ip, - Port: defaultAPIServerPort, - }}, - }) - if err != nil { - return err - } - if err := r.patchAPIServerLoadBalancerStatus(ctx, s, lb); err != nil { - return err - } - } - ip := firstInternalIP(server.Addresses) - if ip == "" { - return fmt.Errorf("%w: server has no internal IP address", cloud.ErrTransient) + loadBalancerID, err := ctrlhlp.EnsureAPIServerLoadBalancerForMachine( + ctx, + c, + s.StackitCluster, + s.Machine.Name, + server.Addresses, + ) + if err != nil { + return err } - return c.EnsureAPIServerLoadBalancerTarget(ctx, cloud.LoadBalancerTargetInput{ - LoadBalancerID: s.StackitCluster.Status.APIServerLoadBalancerID, - Name: s.Machine.Name, - IP: ip, - Port: defaultAPIServerPort, - }) -} -func (r *StackitMachineReconciler) patchAPIServerLoadBalancerStatus( - ctx context.Context, - s *scope.MachineScope, - lb *cloud.LoadBalancer, -) error { - if lb == nil { - return fmt.Errorf("%w: API server load balancer is nil", cloud.ErrTransient) - } - endpoint := clusterv1.APIEndpoint{Host: lb.IP, Port: defaultAPIServerPort} - beforeSpec := s.StackitCluster.DeepCopy() - s.StackitCluster.Spec.ControlPlaneEndpoint = endpoint - if err := r.Patch(ctx, s.StackitCluster, client.MergeFrom(beforeSpec)); err != nil { + target, err := ctrlhlp.APIServerLoadBalancerTargetForMachine(s.Machine.Name, server.Addresses) + if err != nil { return err } - - beforeStatus := s.StackitCluster.DeepCopy() - s.StackitCluster.Status.APIServerLoadBalancerID = lb.ID - s.StackitCluster.Status.APIServerEndpoint = endpoint - s.StackitCluster.Status.Initialization.Provisioned = true - util.SetCondition(&s.StackitCluster.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, - metav1.ConditionTrue, "Available", "", s.StackitCluster.Generation) - util.SetCondition(&s.StackitCluster.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionTrue, "Available", "", s.StackitCluster.Generation) - return r.Status().Patch(ctx, s.StackitCluster, client.MergeFrom(beforeStatus)) + target.LoadBalancerID = loadBalancerID + return c.EnsureAPIServerLoadBalancerTarget(ctx, target) } func (r *StackitMachineReconciler) deleteAPIServerLoadBalancerTarget(ctx context.Context, c cloud.Client, s *scope.MachineScope) error { if !isControlPlaneMachine(s.Machine) || !s.StackitCluster.Spec.APIServerLoadBalancer.Enabled { return nil } - if s.StackitCluster.Status.APIServerLoadBalancerID == "" { + loadBalancerID, err := ctrlhlp.ResolveAPIServerLoadBalancerID(ctx, c, s.StackitCluster) + if err != nil { + return err + } + if loadBalancerID == "" { return nil } - err := c.DeleteAPIServerLoadBalancerTarget(ctx, cloud.LoadBalancerTargetInput{ - LoadBalancerID: s.StackitCluster.Status.APIServerLoadBalancerID, + err = c.DeleteAPIServerLoadBalancerTarget(ctx, cloud.LoadBalancerTargetInput{ + LoadBalancerID: loadBalancerID, Name: s.Machine.Name, Port: defaultAPIServerPort, }) @@ -466,35 +469,16 @@ func (r *StackitMachineReconciler) getStackitCluster(ctx context.Context, cluste return stackitCluster, nil } -func (r *StackitMachineReconciler) buildCloudClient(ctx context.Context, sc *infrav1.StackitCluster) (cloud.Client, error) { - if r.CloudClientFactory == nil { - return nil, errors.New("CloudClientFactory is not configured") - } - secret := &corev1.Secret{} - ns := sc.Spec.CredentialsSecretRef.Namespace - if ns == "" { - ns = sc.Namespace - } - key := types.NamespacedName{Namespace: ns, Name: sc.Spec.CredentialsSecretRef.Name} - if err := r.Get(ctx, key, secret); err != nil { - return nil, fmt.Errorf("get credentials secret %s: %w", key, err) - } - creds, err := util.ParseCredentialsSecret(secret, sc.Spec.ProjectID, sc.Spec.Region) - if err != nil { - return nil, err - } - return r.CloudClientFactory(ctx, creds) -} - -func toMachineAddresses(in []cloud.Address) []clusterv1.MachineAddress { +func machineAddressesFromCloud(in []cloud.Address) []clusterv1.MachineAddress { if len(in) == 0 { return nil } + out := make([]clusterv1.MachineAddress, len(in)) - for i, a := range in { + for i, address := range in { out[i] = clusterv1.MachineAddress{ - Type: clusterv1.MachineAddressType(a.Type), - Address: a.Address, + Type: clusterv1.MachineAddressType(address.Type), + Address: address.Address, } } return out @@ -508,15 +492,6 @@ func isControlPlaneMachine(machine *clusterv1.Machine) bool { return ok } -func firstInternalIP(addresses []cloud.Address) string { - for _, address := range addresses { - if address.Type == string(clusterv1.MachineInternalIP) && address.Address != "" { - return address.Address - } - } - return "" -} - func (r *StackitMachineReconciler) stackitMachineRequestsForMachine(_ context.Context, obj client.Object) []reconcile.Request { machine, ok := obj.(*clusterv1.Machine) if !ok { @@ -579,23 +554,21 @@ func stackitMachineRequestsForMachines(machines []clusterv1.Machine, matches fun } func stackitMachineRequestForMachine(machine *clusterv1.Machine) []reconcile.Request { - if machine == nil || !isStackitMachineRef(machine.Spec.InfrastructureRef) { + if machine == nil { + return nil + } + ref := machine.Spec.InfrastructureRef + if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitMachine" || ref.Name == "" { return nil } return []reconcile.Request{{ NamespacedName: types.NamespacedName{ Namespace: machine.Namespace, - Name: machine.Spec.InfrastructureRef.Name, + Name: ref.Name, }, }} } -func isStackitMachineRef(ref clusterv1.ContractVersionedObjectReference) bool { - return ref.APIGroup == infrav1.GroupVersion.Group && - ref.Kind == "StackitMachine" && - ref.Name != "" -} - // SetupWithManager registers the controller with the manager. func (r *StackitMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/stackitmachine_controller_test.go b/internal/controller/stackitmachine_controller_test.go index a9f7200..4751dcb 100644 --- a/internal/controller/stackitmachine_controller_test.go +++ b/internal/controller/stackitmachine_controller_test.go @@ -283,6 +283,7 @@ var _ = Describe("StackitMachine Controller", func() { updateMachineBootstrapSecret(ctx, machineName, bootstrapName) updateMachineControlPlaneLabel(ctx, machineName, namespace) enableStackitClusterLoadBalancer(ctx, clusterName, namespace) + reconcileStackitClusterOnce(ctx, clusterName, namespace, fakeCloud) createBootstrapSecret(ctx, bootstrapName) result, err := reconciler.Reconcile(ctx, request) @@ -319,6 +320,7 @@ var _ = Describe("StackitMachine Controller", func() { updateMachineBootstrapSecret(ctx, machineName, bootstrapName) updateMachineControlPlaneLabel(ctx, machineName, namespace) enableStackitClusterLoadBalancer(ctx, clusterName, namespace) + reconcileStackitClusterOnce(ctx, clusterName, namespace, fakeCloud) createBootstrapSecret(ctx, bootstrapName) _, err := reconciler.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) From 3d197641a9f3ee8f83c3ecb1844edc63db862dde Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Thu, 25 Jun 2026 08:15:47 +0200 Subject: [PATCH 2/9] chore(deps): bump golang version 1.26 and move main to new cmd/manager/ package Signed-off-by: Jan Larwig --- .devcontainer/devcontainer.json | 2 +- Dockerfile | 4 ++-- Makefile | 4 ++-- go.mod | 2 +- tilt-provider.yaml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6d4865a..a96838b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Kubebuilder DevContainer", - "image": "golang:1.25", + "image": "golang:1.26", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": false, diff --git a/Dockerfile b/Dockerfile index a022882..5f13ee5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.25 AS builder +FROM golang:1.26 AS builder ARG TARGETOS ARG TARGETARCH @@ -19,7 +19,7 @@ COPY . . # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/manager/main.go # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details diff --git a/Makefile b/Makefile index c3c6d50..a88c218 100644 --- a/Makefile +++ b/Makefile @@ -170,11 +170,11 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go + go build -o bin/manager cmd/manager/main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/main.go + 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/go.mod b/go.mod index b5aa9b5..0e0e677 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stackitcloud/cluster-api-provider-stackit -go 1.25.7 +go 1.26.0 require ( github.com/onsi/ginkgo/v2 v2.28.1 diff --git a/tilt-provider.yaml b/tilt-provider.yaml index 17dbd42..9fcf9e4 100644 --- a/tilt-provider.yaml +++ b/tilt-provider.yaml @@ -10,4 +10,4 @@ config: - cmd - internal - pkg - go_main: cmd/main.go + go_main: cmd/manager/main.go From 90238741ec186fbbda6361d3eb078986ece5c5e6 Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Thu, 25 Jun 2026 09:30:59 +0200 Subject: [PATCH 3/9] refactor: full restructure of package layout and introducing clear abstraction and boundaries between resources and reducing the controllers package to the minimum flow Signed-off-by: Jan Larwig --- AGENTS.md | 14 +- {pkg/cloud => cloud}/cleanup.go | 0 {pkg/cloud => cloud}/cleanup_test.go | 4 +- {pkg/cloud => cloud}/client.go | 0 {pkg/cloud => cloud}/errors.go | 0 {pkg/cloud => cloud}/errors_test.go | 0 {pkg/cloud => cloud}/fake/client.go | 2 +- {pkg/cloud => cloud}/providerid.go | 0 {pkg/cloud => cloud}/providerid_test.go | 0 {pkg/cloud => cloud}/sdk_client.go | 8 +- .../sdk_client_integration_test.go | 0 {pkg/cloud => cloud}/sdk_client_test.go | 0 .../services/bastion}/bastion.go | 8 +- .../services/loadbalancer}/loadbalancer.go | 34 +- {pkg/cloud => cloud}/types.go | 0 cmd/cleanup-stackit/main.go | 4 +- cmd/{ => manager}/main.go | 8 +- controller/constants.go | 21 + .../controller_test_helpers_test.go | 2 +- controller/stackitcluster_bastion.go | 186 ++++++ controller/stackitcluster_controller.go | 161 +++++ .../stackitcluster_controller_test.go | 12 +- controller/stackitcluster_infrastructure.go | 236 +++++++ controller/stackitmachine_controller.go | 127 ++++ .../stackitmachine_controller_test.go | 6 +- controller/stackitmachine_infrastructure.go | 362 +++++++++++ controller/stackitmachine_watches.go | 101 +++ .../controller => controller}/suite_test.go | 4 +- docs/src/development/architecture.md | 8 +- internal/controller/contract_helpers.go | 52 -- internal/controller/helpers/conditions.go | 66 -- .../controller/stackitcluster_controller.go | 602 ------------------ .../controller/stackitmachine_controller.go | 581 ----------------- pkg/util/conditions.go | 57 -- {pkg/scope => scope}/cluster_scope.go | 37 ++ {pkg/scope => scope}/machine_scope.go | 42 ++ test/e2e/e2e_test.go | 4 +- tilt-provider.yaml | 6 +- {pkg/util => util}/bootstrap.go | 0 {pkg/util => util}/bootstrap_test.go | 0 util/conditions.go | 114 ++++ {pkg/util => util}/credentials.go | 2 +- {pkg/util => util}/credentials_test.go | 0 .../cloud_client.go => util/reconcile.go | 41 +- {pkg/util => util}/tags.go | 0 {pkg/util => util}/tags_test.go | 0 .../v1alpha1/stackitcluster_webhook.go | 0 .../stackitclustertemplate_webhook.go | 0 .../v1alpha1/stackitmachine_webhook.go | 0 .../stackitmachinetemplate_webhook.go | 0 .../v1alpha1/validation_helpers.go | 2 +- .../v1alpha1/webhook_suite_test.go | 6 +- .../v1alpha1/webhook_validation_test.go | 0 53 files changed, 1488 insertions(+), 1432 deletions(-) rename {pkg/cloud => cloud}/cleanup.go (100%) rename {pkg/cloud => cloud}/cleanup_test.go (96%) rename {pkg/cloud => cloud}/client.go (100%) rename {pkg/cloud => cloud}/errors.go (100%) rename {pkg/cloud => cloud}/errors_test.go (100%) rename {pkg/cloud => cloud}/fake/client.go (99%) rename {pkg/cloud => cloud}/providerid.go (100%) rename {pkg/cloud => cloud}/providerid_test.go (100%) rename {pkg/cloud => cloud}/sdk_client.go (99%) rename {pkg/cloud => cloud}/sdk_client_integration_test.go (100%) rename {pkg/cloud => cloud}/sdk_client_test.go (100%) rename {internal/controller/helpers => cloud/services/bastion}/bastion.go (87%) rename {internal/controller/helpers => cloud/services/loadbalancer}/loadbalancer.go (72%) rename {pkg/cloud => cloud}/types.go (100%) rename cmd/{ => manager}/main.go (96%) create mode 100644 controller/constants.go rename {internal/controller => controller}/controller_test_helpers_test.go (99%) create mode 100644 controller/stackitcluster_bastion.go create mode 100644 controller/stackitcluster_controller.go rename {internal/controller => controller}/stackitcluster_controller_test.go (98%) create mode 100644 controller/stackitcluster_infrastructure.go create mode 100644 controller/stackitmachine_controller.go rename {internal/controller => controller}/stackitmachine_controller_test.go (99%) create mode 100644 controller/stackitmachine_infrastructure.go create mode 100644 controller/stackitmachine_watches.go rename {internal/controller => controller}/suite_test.go (97%) delete mode 100644 internal/controller/contract_helpers.go delete mode 100644 internal/controller/helpers/conditions.go delete mode 100644 internal/controller/stackitcluster_controller.go delete mode 100644 internal/controller/stackitmachine_controller.go delete mode 100644 pkg/util/conditions.go rename {pkg/scope => scope}/cluster_scope.go (54%) rename {pkg/scope => scope}/machine_scope.go (53%) rename {pkg/util => util}/bootstrap.go (100%) rename {pkg/util => util}/bootstrap_test.go (100%) create mode 100644 util/conditions.go rename {pkg/util => util}/credentials.go (96%) rename {pkg/util => util}/credentials_test.go (100%) rename internal/controller/helpers/cloud_client.go => util/reconcile.go (50%) rename {pkg/util => util}/tags.go (100%) rename {pkg/util => util}/tags_test.go (100%) rename {internal/webhook => webhook}/v1alpha1/stackitcluster_webhook.go (100%) rename {internal/webhook => webhook}/v1alpha1/stackitclustertemplate_webhook.go (100%) rename {internal/webhook => webhook}/v1alpha1/stackitmachine_webhook.go (100%) rename {internal/webhook => webhook}/v1alpha1/stackitmachinetemplate_webhook.go (100%) rename {internal/webhook => webhook}/v1alpha1/validation_helpers.go (99%) rename {internal/webhook => webhook}/v1alpha1/webhook_suite_test.go (95%) rename {internal/webhook => webhook}/v1alpha1/webhook_validation_test.go (100%) diff --git a/AGENTS.md b/AGENTS.md index a27f60b..1defe21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,8 @@ STACKIT-VM based clusters (unmanaged), and aims to support SKE clusters (managed cmd/main.go Manager entry (registers controllers/webhooks) api//*_types.go CRD schemas (+kubebuilder markers) api//zz_generated.* Auto-generated (DO NOT EDIT) -internal/controller/* Reconciliation logic -internal/webhook/* Validation/defaulting (if present) +controller/* Reconciliation logic +webhook/* Validation/defaulting (if present) config/crd/bases/* Generated CRDs (DO NOT EDIT) config/rbac/role.yaml Generated RBAC (DO NOT EDIT) config/samples/* Example CRs (edit these) @@ -27,8 +27,8 @@ PROJECT Kubebuilder metadata Auto-generated (DO NOT EDIT) **Multi-group layout** (for projects with multiple API groups): ``` api///*_types.go CRD schemas by group -internal/controller//* Controllers by group -internal/webhook///* Webhooks by group and version (if present) +controller//* Controllers by group +webhook///* Webhooks by group and version (if present) ``` Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check the `PROJECT` file for `multigroup: true`. @@ -36,8 +36,8 @@ Multi-group layout organizes APIs by group name (e.g., `batch`, `apps`). Check t **To convert to multi-group layout:** 1. Run: `kubebuilder edit --multigroup=true` 2. Move APIs: `mkdir -p api/ && mv api/ api//` -3. Move controllers: `mkdir -p internal/controller/ && mv internal/controller/*.go internal/controller//` -4. Move webhooks (if present): `mkdir -p internal/webhook/ && mv internal/webhook/ internal/webhook//` +3. Move controllers: `mkdir -p controller/ && mv controller/*.go controller//` +4. Move webhooks (if present): `mkdir -p webhook/ && mv webhook/ webhook//` 5. Update import paths in all files 6. Fix `path` in `PROJECT` file for each resource 7. Update test suite CRD paths (add one more `..` to relative paths) @@ -200,7 +200,7 @@ kubectl logs -n -system deployment/-controller-manager -c mana ### Controller Design -**RBAC markers in** `internal/controller/*_controller.go`: +**RBAC markers in** `controller/*_controller.go`: ```go // +kubebuilder:rbac:groups=mygroup.example.com,resources=mykinds,verbs=get;list;watch;create;update;patch;delete diff --git a/pkg/cloud/cleanup.go b/cloud/cleanup.go similarity index 100% rename from pkg/cloud/cleanup.go rename to cloud/cleanup.go diff --git a/pkg/cloud/cleanup_test.go b/cloud/cleanup_test.go similarity index 96% rename from pkg/cloud/cleanup_test.go rename to cloud/cleanup_test.go index 1b27709..6ef2778 100644 --- a/pkg/cloud/cleanup_test.go +++ b/cloud/cleanup_test.go @@ -14,8 +14,8 @@ import ( "context" "testing" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud/fake" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/cloud/fake" ) func TestCleanupByTagsDeletesMatchingCloudResources(t *testing.T) { diff --git a/pkg/cloud/client.go b/cloud/client.go similarity index 100% rename from pkg/cloud/client.go rename to cloud/client.go diff --git a/pkg/cloud/errors.go b/cloud/errors.go similarity index 100% rename from pkg/cloud/errors.go rename to cloud/errors.go diff --git a/pkg/cloud/errors_test.go b/cloud/errors_test.go similarity index 100% rename from pkg/cloud/errors_test.go rename to cloud/errors_test.go diff --git a/pkg/cloud/fake/client.go b/cloud/fake/client.go similarity index 99% rename from pkg/cloud/fake/client.go rename to cloud/fake/client.go index b68f291..0d08966 100644 --- a/pkg/cloud/fake/client.go +++ b/cloud/fake/client.go @@ -19,7 +19,7 @@ import ( "slices" "sync" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" ) const ( diff --git a/pkg/cloud/providerid.go b/cloud/providerid.go similarity index 100% rename from pkg/cloud/providerid.go rename to cloud/providerid.go diff --git a/pkg/cloud/providerid_test.go b/cloud/providerid_test.go similarity index 100% rename from pkg/cloud/providerid_test.go rename to cloud/providerid_test.go diff --git a/pkg/cloud/sdk_client.go b/cloud/sdk_client.go similarity index 99% rename from pkg/cloud/sdk_client.go rename to cloud/sdk_client.go index 9185419..53d7460 100644 --- a/pkg/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -705,7 +705,7 @@ func (c *SDKClient) ensureBastionSecurityGroupRules(ctx context.Context, securit if hasSSHRule(existingRules, cidr) { continue } - protocol := iaas.StringAsCreateProtocol(ptrTo(tcpProtocol)) + protocol := iaas.StringAsCreateProtocol(new(tcpProtocol)) rule := iaas.NewCreateSecurityGroupRulePayload("ingress") rule.SetIpRange(cidr) rule.SetPortRange(*iaas.NewPortRange(sshPort, sshPort)) @@ -733,7 +733,7 @@ func (c *SDKClient) ensureNodeSSHSecurityGroupRule( if hasRemoteSecurityGroupSSHRule(resp.GetItems(), bastionSecurityGroupID) { return nil } - protocol := iaas.StringAsCreateProtocol(ptrTo(tcpProtocol)) + protocol := iaas.StringAsCreateProtocol(new(tcpProtocol)) rule := iaas.NewCreateSecurityGroupRulePayload("ingress") rule.SetRemoteSecurityGroupId(bastionSecurityGroupID) rule.SetPortRange(*iaas.NewPortRange(sshPort, sshPort)) @@ -1037,10 +1037,6 @@ func firstNonEmpty(values ...string) string { return "" } -func ptrTo[T any](value T) *T { - return &value -} - func classifySDKError(op string, err error) error { var oapiErr *oapierror.GenericOpenAPIError if errors.As(err, &oapiErr) { diff --git a/pkg/cloud/sdk_client_integration_test.go b/cloud/sdk_client_integration_test.go similarity index 100% rename from pkg/cloud/sdk_client_integration_test.go rename to cloud/sdk_client_integration_test.go diff --git a/pkg/cloud/sdk_client_test.go b/cloud/sdk_client_test.go similarity index 100% rename from pkg/cloud/sdk_client_test.go rename to cloud/sdk_client_test.go diff --git a/internal/controller/helpers/bastion.go b/cloud/services/bastion/bastion.go similarity index 87% rename from internal/controller/helpers/bastion.go rename to cloud/services/bastion/bastion.go index b6cac12..6b7f5d9 100644 --- a/internal/controller/helpers/bastion.go +++ b/cloud/services/bastion/bastion.go @@ -8,15 +8,15 @@ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -package helpers +package bastion import ( infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) -func BastionInput(stackitCluster *infrav1.StackitCluster, cloudInit []byte) cloud.BastionInput { +func Input(stackitCluster *infrav1.StackitCluster, cloudInit []byte) cloud.BastionInput { deleteOnTermination := true if stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination != nil { deleteOnTermination = *stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination diff --git a/internal/controller/helpers/loadbalancer.go b/cloud/services/loadbalancer/loadbalancer.go similarity index 72% rename from internal/controller/helpers/loadbalancer.go rename to cloud/services/loadbalancer/loadbalancer.go index f5478f7..f5d2f3a 100644 --- a/internal/controller/helpers/loadbalancer.go +++ b/cloud/services/loadbalancer/loadbalancer.go @@ -8,7 +8,7 @@ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -package helpers +package loadbalancer import ( "context" @@ -17,17 +17,17 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) const defaultAPIServerPort int32 = 6443 -func APIServerLoadBalancerTags(stackitCluster *infrav1.StackitCluster) map[string]string { +func APIServerTags(stackitCluster *infrav1.StackitCluster) map[string]string { return util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) } -func APIServerLoadBalancerInput( +func APIServerInput( stackitCluster *infrav1.StackitCluster, targets []cloud.LoadBalancerTargetInput, ) cloud.LoadBalancerInput { @@ -37,12 +37,12 @@ func APIServerLoadBalancerInput( Region: stackitCluster.Spec.Region, NetworkID: stackitCluster.Spec.Network.ID, Port: defaultAPIServerPort, - Tags: APIServerLoadBalancerTags(stackitCluster), + Tags: APIServerTags(stackitCluster), Targets: targets, } } -func BootstrapAPIServerLoadBalancerTarget(ip string) cloud.LoadBalancerTargetInput { +func BootstrapTarget(ip string) cloud.LoadBalancerTargetInput { return cloud.LoadBalancerTargetInput{ Name: "capi-bootstrap-placeholder", IP: ip, @@ -50,10 +50,7 @@ func BootstrapAPIServerLoadBalancerTarget(ip string) cloud.LoadBalancerTargetInp } } -func APIServerLoadBalancerTargetForMachine( - machineName string, - addresses []cloud.Address, -) (cloud.LoadBalancerTargetInput, error) { +func TargetForMachine(machineName string, addresses []cloud.Address) (cloud.LoadBalancerTargetInput, error) { ip := FirstInternalIP(addresses) if ip == "" { return cloud.LoadBalancerTargetInput{}, fmt.Errorf("%w: server has no internal IP address", cloud.ErrTransient) @@ -66,7 +63,7 @@ func APIServerLoadBalancerTargetForMachine( }, nil } -func ResolveAPIServerLoadBalancerID( +func ResolveID( ctx context.Context, cloudClient cloud.Client, stackitCluster *infrav1.StackitCluster, @@ -75,7 +72,7 @@ func ResolveAPIServerLoadBalancerID( return stackitCluster.Status.APIServerLoadBalancerID, nil } - loadBalancers, err := cloudClient.ListAPIServerLoadBalancersByTags(ctx, APIServerLoadBalancerTags(stackitCluster)) + loadBalancers, err := cloudClient.ListAPIServerLoadBalancersByTags(ctx, APIServerTags(stackitCluster)) if err != nil { return "", err } @@ -88,14 +85,14 @@ func ResolveAPIServerLoadBalancerID( return loadBalancers[0].ID, nil } -func EnsureAPIServerLoadBalancerForMachine( +func EnsureForMachine( ctx context.Context, cloudClient cloud.Client, stackitCluster *infrav1.StackitCluster, machineName string, addresses []cloud.Address, ) (string, error) { - loadBalancerID, err := ResolveAPIServerLoadBalancerID(ctx, cloudClient, stackitCluster) + loadBalancerID, err := ResolveID(ctx, cloudClient, stackitCluster) if err != nil { return "", err } @@ -103,15 +100,12 @@ func EnsureAPIServerLoadBalancerForMachine( return loadBalancerID, nil } - target, err := APIServerLoadBalancerTargetForMachine(machineName, addresses) + target, err := TargetForMachine(machineName, addresses) if err != nil { return "", err } - loadBalancer, err := cloudClient.EnsureAPIServerLoadBalancer( - ctx, - APIServerLoadBalancerInput(stackitCluster, []cloud.LoadBalancerTargetInput{target}), - ) + loadBalancer, err := cloudClient.EnsureAPIServerLoadBalancer(ctx, APIServerInput(stackitCluster, []cloud.LoadBalancerTargetInput{target})) if err != nil { return "", err } diff --git a/pkg/cloud/types.go b/cloud/types.go similarity index 100% rename from pkg/cloud/types.go rename to cloud/types.go diff --git a/cmd/cleanup-stackit/main.go b/cmd/cleanup-stackit/main.go index fda039e..579ff62 100644 --- a/cmd/cleanup-stackit/main.go +++ b/cmd/cleanup-stackit/main.go @@ -15,8 +15,8 @@ import ( "fmt" "os" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) const ( diff --git a/cmd/main.go b/cmd/manager/main.go similarity index 96% rename from cmd/main.go rename to cmd/manager/main.go index 5dd2807..abb536d 100644 --- a/cmd/main.go +++ b/cmd/manager/main.go @@ -37,9 +37,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" infrastructurev1alpha1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller" - webhookv1alpha1 "github.com/stackitcloud/cluster-api-provider-stackit/internal/webhook/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/controller" + webhookv1alpha1 "github.com/stackitcloud/cluster-api-provider-stackit/webhook/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -186,6 +186,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), CloudClientFactory: cloud.NewClient, + Recorder: mgr.GetEventRecorderFor("stackitcluster-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "stackitcluster") os.Exit(1) @@ -194,6 +195,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), CloudClientFactory: cloud.NewClient, + Recorder: mgr.GetEventRecorderFor("stackitmachine-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "stackitmachine") os.Exit(1) diff --git a/controller/constants.go b/controller/constants.go new file mode 100644 index 0000000..6a27a27 --- /dev/null +++ b/controller/constants.go @@ -0,0 +1,21 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package controller + +import "time" + +const ( + defaultAPIServerPort int32 = 6443 + + cloudInitRefKindSecret = "Secret" + + retryableErrorRequeueAfter = 5 * time.Second +) diff --git a/internal/controller/controller_test_helpers_test.go b/controller/controller_test_helpers_test.go similarity index 99% rename from internal/controller/controller_test_helpers_test.go rename to controller/controller_test_helpers_test.go index 6705ade..c495ee8 100644 --- a/internal/controller/controller_test_helpers_test.go +++ b/controller/controller_test_helpers_test.go @@ -23,7 +23,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" ) const ( diff --git a/controller/stackitcluster_bastion.go b/controller/stackitcluster_bastion.go new file mode 100644 index 0000000..2acf293 --- /dev/null +++ b/controller/stackitcluster_bastion.go @@ -0,0 +1,186 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package controller + +import ( + "context" + "crypto/sha256" + "fmt" + "net/netip" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + bastionservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/bastion" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" +) + +func (r *StackitClusterReconciler) reconcileBastion( + ctx context.Context, + cloudClient cloud.Client, + s *scope.ClusterScope, +) (ctrl.Result, bool, error) { + sc := s.StackitCluster + input := bastionservice.Input(sc, nil) + status := cloud.Bastion{ + ServerID: sc.Status.Bastion.ServerID, + PublicIPID: sc.Status.Bastion.PublicIPID, + PublicIP: sc.Status.Bastion.PublicIP, + SecurityGroupID: sc.Status.Bastion.SecurityGroupID, + } + + if !sc.Spec.Bastion.Enabled { + if hasBastionStatus(sc.Status.Bastion) { + if err := cloudClient.DeleteNodeSSHAccess(ctx, bastionservice.NodeSSHAccessTags(sc)); err != nil { + return ctrl.Result{}, false, err + } + if err := cloudClient.DeleteBastion(ctx, input, status); err != nil { + return ctrl.Result{}, false, err + } + s.ClearBastionStatus() + if r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionDeleted", "Deleted bastion") + } + } + s.SetConditions(metav1.ConditionTrue, "Skipped", "bastion disabled", infrav1.ClusterBastionReadyCondition) + return ctrl.Result{}, true, nil + } + + if err := validateBastionSpec(sc.Spec.Bastion); err != nil { + s.SetNotReady("InvalidBastionSpec", err.Error(), infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) + return ctrl.Result{}, false, nil + } + + cloudInit, err := r.resolveBastionCloudInit(ctx, sc) + if err != nil { + s.SetNotReady("CloudInitRefError", err.Error(), infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) + return ctrl.Result{}, false, nil + } + input.CloudInit = cloudInit + + if bastionNeedsRecreate(sc, cloudInit) { + if err := cloudClient.DeleteNodeSSHAccess(ctx, bastionservice.NodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { + return ctrl.Result{}, false, err + } + if err := cloudClient.DeleteBastion(ctx, input, status); err != nil && !cloud.IsNotFound(err) { + return ctrl.Result{}, false, err + } + s.ClearBastionStatus() + s.SetNotReady("Recreating", "recreating bastion because cloudInitRef content changed", infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) + if r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionRecreating", "Recreating bastion because cloudInitRef content changed") + } + return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, false, nil + } + + hadBastionStatus := hasBastionStatus(sc.Status.Bastion) + bastion, err := cloudClient.EnsureBastion(ctx, input) + if err != nil { + return ctrl.Result{}, false, err + } + s.SetBastionStatus(bastion, bastionCloudInitHash(cloudInit)) + if !hadBastionStatus && r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionCreated", "Created bastion %s", bastion.ServerID) + } + if bastion.ServerState != "" && bastion.ServerState != "ACTIVE" { + s.SetNotReady("Provisioning", fmt.Sprintf("bastion server state is %s", bastion.ServerState), infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) + return ctrl.Result{RequeueAfter: 15 * time.Second}, false, nil + } + if bastion.PublicIP == "" { + s.SetNotReady("Provisioning", "waiting for bastion public IP address", infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) + return ctrl.Result{RequeueAfter: 10 * time.Second}, false, nil + } + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ClusterBastionReadyCondition) + return ctrl.Result{}, true, nil +} + +func validateBastionSpec(spec infrav1.StackitBastionSpec) error { + if spec.ImageID == "" { + return fmt.Errorf("%w: bastion.imageID is required", cloud.ErrInvalidInput) + } + if spec.MachineType == "" { + return fmt.Errorf("%w: bastion.machineType is required", cloud.ErrInvalidInput) + } + if spec.SSHKeyName == "" { + return fmt.Errorf("%w: bastion.sshKeyName is required", cloud.ErrInvalidInput) + } + if len(spec.AllowedCIDRs) == 0 { + return fmt.Errorf("%w: bastion.allowedCIDRs is required", cloud.ErrInvalidInput) + } + for _, cidr := range spec.AllowedCIDRs { + if _, err := netip.ParsePrefix(cidr); err != nil { + return fmt.Errorf("%w: bastion.allowedCIDRs contains invalid CIDR %q", cloud.ErrInvalidInput, cidr) + } + } + return nil +} + +func hasBastionStatus(status infrav1.StackitBastionStatus) bool { + return status.ServerID != "" || status.PublicIPID != "" || status.PublicIP != "" || status.SecurityGroupID != "" +} + +func bastionNeedsRecreate(sc *infrav1.StackitCluster, cloudInit []byte) bool { + if !hasBastionStatus(sc.Status.Bastion) { + return false + } + return sc.Status.Bastion.CloudInitHash != bastionCloudInitHash(cloudInit) +} + +func bastionCloudInitHash(cloudInit []byte) string { + if len(cloudInit) == 0 { + return "" + } + return fmt.Sprintf("%x", sha256.Sum256(cloudInit)) +} + +func (r *StackitClusterReconciler) resolveBastionCloudInit(ctx context.Context, sc *infrav1.StackitCluster) ([]byte, error) { + ref := sc.Spec.Bastion.CloudInitRef + if ref == nil { + return nil, nil + } + key := types.NamespacedName{Namespace: sc.Namespace, Name: ref.Name} + switch ref.Kind { + case "ConfigMap": + configMap := &corev1.ConfigMap{} + if err := r.Get(ctx, key, configMap); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("bastion.cloudInitRef ConfigMap %s not found", key) + } + return nil, err + } + value, ok := configMap.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("bastion.cloudInitRef key %q not found in ConfigMap %s", ref.Key, key) + } + return []byte(value), nil + case cloudInitRefKindSecret: + secret := &corev1.Secret{} + if err := r.Get(ctx, key, secret); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("bastion.cloudInitRef Secret %s not found", key) + } + return nil, err + } + value, ok := secret.Data[ref.Key] + if !ok { + return nil, fmt.Errorf("bastion.cloudInitRef key %q not found in Secret %s", ref.Key, key) + } + return append([]byte(nil), value...), nil + default: + return nil, fmt.Errorf("bastion.cloudInitRef.kind must be ConfigMap or Secret") + } +} diff --git a/controller/stackitcluster_controller.go b/controller/stackitcluster_controller.go new file mode 100644 index 0000000..1b42c34 --- /dev/null +++ b/controller/stackitcluster_controller.go @@ -0,0 +1,161 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + clusterutil "sigs.k8s.io/cluster-api/util" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +// StackitClusterReconciler reconciles a StackitCluster object. +type StackitClusterReconciler struct { + client.Client + Scheme *runtime.Scheme + + // CloudClientFactory builds a cloud.Client from parsed credentials. It is + // injected so tests can swap in the in-memory fake. + CloudClientFactory cloud.Factory + Recorder record.EventRecorder +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters/finalizers,verbs=update +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +func (r *StackitClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { + log := logf.FromContext(ctx) + + stackitCluster := &infrav1.StackitCluster{} + if err := r.Get(ctx, req.NamespacedName, stackitCluster); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + cluster, err := clusterutil.GetOwnerCluster(ctx, r.Client, stackitCluster.ObjectMeta) + if err != nil { + return ctrl.Result{}, fmt.Errorf("get owner cluster: %w", err) + } + if cluster == nil { + log.Info("StackitCluster has no owning Cluster yet, requeueing") + return ctrl.Result{}, nil + } + + clusterScope, err := scope.NewClusterScope(r.Client, cluster, stackitCluster) + if err != nil { + return ctrl.Result{}, fmt.Errorf("create cluster scope: %w", err) + } + defer func() { + if patchErr := clusterScope.PatchObject(ctx); patchErr != nil && err == nil { + err = patchErr + } + }() + + if paused, message := util.ReconciliationPaused(cluster, stackitCluster); paused { + util.SetPausedCondition(&stackitCluster.Status.Conditions, stackitCluster.Generation, true, message) + return ctrl.Result{}, nil + } + util.SetPausedCondition(&stackitCluster.Status.Conditions, stackitCluster.Generation, false, "") + + if !stackitCluster.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.reconcileDelete(ctx, clusterScope) + } + return r.reconcileNormal(ctx, clusterScope) +} + +func (r *StackitClusterReconciler) stackitClusterRequestsForCluster(_ context.Context, obj client.Object) []reconcile.Request { + cluster, ok := obj.(*clusterv1.Cluster) + if !ok { + return nil + } + ref := cluster.Spec.InfrastructureRef + if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitCluster" || ref.Name == "" { + return nil + } + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: cluster.Namespace, + Name: ref.Name, + }, + }} +} + +func (r *StackitClusterReconciler) stackitClusterRequestsForCloudInitRef(ctx context.Context, obj client.Object) []reconcile.Request { + kind := "" + switch obj.(type) { + case *corev1.ConfigMap: + kind = "ConfigMap" + case *corev1.Secret: + kind = "Secret" + default: + return nil + } + + clusters := &infrav1.StackitClusterList{} + if err := r.List(ctx, clusters, client.InNamespace(obj.GetNamespace())); err != nil { + logf.FromContext(ctx).Error(err, "Failed to list StackitClusters for bastion cloud-init watch", "object", client.ObjectKeyFromObject(obj)) + return nil + } + + requests := make([]reconcile.Request, 0, len(clusters.Items)) + for _, cluster := range clusters.Items { + ref := cluster.Spec.Bastion.CloudInitRef + if ref == nil || ref.Kind != kind || ref.Name != obj.GetName() { + continue + } + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: cluster.Namespace, + Name: cluster.Name, + }, + }) + } + return requests +} + +// SetupWithManager registers the controller with the manager. +func (r *StackitClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&infrav1.StackitCluster{}). + Watches(&clusterv1.Cluster{}, handler.EnqueueRequestsFromMapFunc(r.stackitClusterRequestsForCluster)). + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.stackitClusterRequestsForCloudInitRef)). + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.stackitClusterRequestsForCloudInitRef)). + Named("stackitcluster"). + Complete(r) +} diff --git a/internal/controller/stackitcluster_controller_test.go b/controller/stackitcluster_controller_test.go similarity index 98% rename from internal/controller/stackitcluster_controller_test.go rename to controller/stackitcluster_controller_test.go index 42927b3..ade34ee 100644 --- a/internal/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -26,9 +26,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - ctrlhlp "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller/helpers" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud/fake" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/cloud/fake" + bastionservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/bastion" ) var _ = Describe("StackitCluster Controller", func() { @@ -152,7 +152,7 @@ var _ = Describe("StackitCluster Controller", func() { Name: got.Name + "-node-ssh", ServerID: got.Status.Bastion.ServerID, BastionSecurityGroupID: got.Status.Bastion.SecurityGroupID, - Tags: ctrlhlp.NodeSSHAccessTags(got), + Tags: bastionservice.NodeSSHAccessTags(got), }) Expect(err).NotTo(HaveOccurred()) Expect(fakeCloud.SecurityGroupCount()).To(Equal(2)) @@ -185,7 +185,7 @@ var _ = Describe("StackitCluster Controller", func() { Name: got.Name + "-node-ssh", ServerID: got.Status.Bastion.ServerID, BastionSecurityGroupID: got.Status.Bastion.SecurityGroupID, - Tags: ctrlhlp.NodeSSHAccessTags(got), + Tags: bastionservice.NodeSSHAccessTags(got), }) Expect(err).NotTo(HaveOccurred()) Expect(fakeCloud.SecurityGroupCount()).To(Equal(2)) @@ -304,7 +304,7 @@ var _ = Describe("StackitCluster Controller", func() { Name: got.Name + "-node-ssh", ServerID: got.Status.Bastion.ServerID, BastionSecurityGroupID: got.Status.Bastion.SecurityGroupID, - Tags: ctrlhlp.NodeSSHAccessTags(got), + Tags: bastionservice.NodeSSHAccessTags(got), }) Expect(err).NotTo(HaveOccurred()) Expect(fakeCloud.SecurityGroupCount()).To(Equal(2)) diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go new file mode 100644 index 0000000..fb23031 --- /dev/null +++ b/controller/stackitcluster_infrastructure.go @@ -0,0 +1,236 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package controller + +import ( + "context" + "net/netip" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + bastionservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/bastion" + loadbalancerservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/loadbalancer" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, s *scope.ClusterScope) (ctrl.Result, error) { + log := logf.FromContext(ctx) + sc := s.StackitCluster + + if !controllerutil.ContainsFinalizer(sc, infrav1.ClusterFinalizer) { + controllerutil.AddFinalizer(sc, infrav1.ClusterFinalizer) + } + sc.Status.FailureDomains = stackitFailureDomains(sc.Spec.Region) + + cloudClient, err := util.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, sc) + if err != nil { + sc.Status.Ready = false + return util.CredentialFailureResult( + &sc.Status.Conditions, + sc.Generation, + err, + infrav1.ClusterCredentialsReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ClusterCredentialsReadyCondition) + + network, err := cloudClient.GetNetwork(ctx, sc.Spec.Network.ID) + if err != nil { + sc.Status.Ready = false + return util.CloudFailureResult( + &sc.Status.Conditions, + sc.Generation, + "NetworkNotFound", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ClusterNetworkReadyCondition) + + if sc.Spec.APIServerLoadBalancer.Enabled { + lb, err := cloudClient.EnsureAPIServerLoadBalancer( + ctx, + loadbalancerservice.APIServerInput( + sc, + []cloud.LoadBalancerTargetInput{loadbalancerservice.BootstrapTarget(bootstrapTargetIP(network))}, + ), + ) + if err != nil { + sc.Status.Ready = false + return util.CloudFailureResult( + &sc.Status.Conditions, + sc.Generation, + "LoadBalancerError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterLoadBalancerReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + hadLoadBalancerID := sc.Status.APIServerLoadBalancerID != "" + if lb != nil { + sc.Status.APIServerLoadBalancerID = lb.ID + if !hadLoadBalancerID && lb.ID != "" && r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "LoadBalancerCreated", "Created API server load balancer %s", lb.ID) + } + } + if lb == nil || lb.IP == "" { + s.SetNotReady("Provisioning", "waiting for API server load balancer IP address", infrav1.ClusterLoadBalancerReadyCondition, infrav1.ClusterReadyCondition) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + endpoint := clusterv1.APIEndpoint{ + Host: lb.IP, + Port: defaultAPIServerPort, + } + s.SetAPIServerEndpoint(endpoint) + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ClusterLoadBalancerReadyCondition) + if r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "LoadBalancerReady", "API server load balancer is ready at %s", lb.IP) + } + } else if sc.Spec.ControlPlaneEndpoint.Host != "" { + sc.Status.APIServerEndpoint = sc.Spec.ControlPlaneEndpoint + s.SetConditions(metav1.ConditionTrue, "Skipped", "external endpoint provided", infrav1.ClusterLoadBalancerReadyCondition) + } else { + s.SetNotReady("EndpointMissing", "apiServerLoadBalancer.enabled is false and controlPlaneEndpoint is empty", infrav1.ClusterLoadBalancerReadyCondition, infrav1.ClusterReadyCondition) + return ctrl.Result{}, nil + } + + if result, ready, err := r.reconcileBastion(ctx, cloudClient, s); err != nil { + sc.Status.Ready = false + return util.CloudFailureResult( + &sc.Status.Conditions, + sc.Generation, + "BastionError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterBastionReadyCondition, + infrav1.ClusterReadyCondition, + ) + } else if !ready { + return result, nil + } + + s.SetReady() + log.V(1).Info("StackitCluster ready", "endpoint", sc.Status.APIServerEndpoint) + return ctrl.Result{}, nil +} + +func stackitFailureDomains(region string) []clusterv1.FailureDomain { + controlPlane := true + return []clusterv1.FailureDomain{ + { + Name: region + "-1", + ControlPlane: &controlPlane, + Attributes: map[string]string{ + "region": region, + }, + }, + { + Name: region + "-2", + ControlPlane: &controlPlane, + Attributes: map[string]string{ + "region": region, + }, + }, + { + Name: region + "-3", + ControlPlane: &controlPlane, + Attributes: map[string]string{ + "region": region, + }, + }, + } +} + +func bootstrapTargetIP(network *cloud.Network) string { + if network == nil { + return "10.0.0.1" + } + for _, prefixValue := range network.IPv4Prefixes { + prefix, err := netip.ParsePrefix(prefixValue) + if err != nil || !prefix.Addr().Is4() { + continue + } + address := prefix.Masked().Addr() + for range 10 { + address = address.Next() + } + if prefix.Contains(address) { + return address.String() + } + } + return "10.0.0.1" +} + +func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, s *scope.ClusterScope) error { + sc := s.StackitCluster + if sc.Status.APIServerLoadBalancerID != "" || hasBastionStatus(sc.Status.Bastion) || sc.Spec.APIServerLoadBalancer.Enabled { + cloudClient, err := util.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, sc) + if err != nil { + util.SetConditions( + &sc.Status.Conditions, + sc.Generation, + metav1.ConditionFalse, + "CredentialsInvalid", + err.Error(), + infrav1.ClusterCredentialsReadyCondition, + ) + return err + } + loadBalancerID, err := loadbalancerservice.ResolveID(ctx, cloudClient, sc) + if err != nil { + return err + } + if loadBalancerID != "" { + if err := cloudClient.DeleteAPIServerLoadBalancer(ctx, loadBalancerID); err != nil && !cloud.IsNotFound(err) { + return err + } + sc.Status.APIServerLoadBalancerID = "" + if r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "LoadBalancerDeleted", "Deleted API server load balancer %s", loadBalancerID) + } + } + if hasBastionStatus(sc.Status.Bastion) { + if err := cloudClient.DeleteNodeSSHAccess(ctx, bastionservice.NodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { + return err + } + if err := cloudClient.DeleteBastion(ctx, bastionservice.Input(sc, nil), cloud.Bastion{ + ServerID: sc.Status.Bastion.ServerID, + PublicIPID: sc.Status.Bastion.PublicIPID, + PublicIP: sc.Status.Bastion.PublicIP, + SecurityGroupID: sc.Status.Bastion.SecurityGroupID, + }); err != nil && !cloud.IsNotFound(err) { + return err + } + s.ClearBastionStatus() + if r.Recorder != nil { + r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionDeleted", "Deleted bastion") + } + } + } + controllerutil.RemoveFinalizer(sc, infrav1.ClusterFinalizer) + return nil +} diff --git a/controller/stackitmachine_controller.go b/controller/stackitmachine_controller.go new file mode 100644 index 0000000..47b1b54 --- /dev/null +++ b/controller/stackitmachine_controller.go @@ -0,0 +1,127 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + clusterutil "sigs.k8s.io/cluster-api/util" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +// StackitMachineReconciler reconciles a StackitMachine object. +type StackitMachineReconciler struct { + client.Client + Scheme *runtime.Scheme + + // CloudClientFactory builds a cloud.Client from parsed credentials. + CloudClientFactory cloud.Factory + Recorder record.EventRecorder +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines/finalizers,verbs=update +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters,verbs=get;list;watch +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machines,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +func (r *StackitMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { + log := logf.FromContext(ctx) + + stackitMachine := &infrav1.StackitMachine{} + if err := r.Get(ctx, req.NamespacedName, stackitMachine); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + machine, err := clusterutil.GetOwnerMachine(ctx, r.Client, stackitMachine.ObjectMeta) + if err != nil { + return ctrl.Result{}, fmt.Errorf("get owner machine: %w", err) + } + if machine == nil { + log.Info("StackitMachine has no owning Machine yet, requeueing") + return ctrl.Result{}, nil + } + + cluster, err := clusterutil.GetClusterFromMetadata(ctx, r.Client, machine.ObjectMeta) + if err != nil { + return ctrl.Result{}, fmt.Errorf("get cluster from machine metadata: %w", err) + } + if cluster == nil { + log.Info("Machine has no owning Cluster yet, requeueing") + return ctrl.Result{}, nil + } + + stackitCluster, err := r.getStackitCluster(ctx, cluster) + if err != nil { + return ctrl.Result{}, err + } + if stackitCluster == nil { + log.Info("StackitCluster not found, requeueing") + return ctrl.Result{}, nil + } + + machineScope, err := scope.NewMachineScope(r.Client, cluster, machine, stackitCluster, stackitMachine) + if err != nil { + return ctrl.Result{}, fmt.Errorf("create machine scope: %w", err) + } + defer func() { + if patchErr := machineScope.PatchObject(ctx); patchErr != nil && err == nil { + err = patchErr + } + }() + + if paused, message := util.ReconciliationPaused(cluster, stackitMachine); paused { + util.SetPausedCondition(&stackitMachine.Status.Conditions, stackitMachine.Generation, true, message) + return ctrl.Result{}, nil + } + util.SetPausedCondition(&stackitMachine.Status.Conditions, stackitMachine.Generation, false, "") + + if !stackitMachine.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.reconcileDelete(ctx, machineScope) + } + return r.reconcileNormal(ctx, machineScope) +} + +// SetupWithManager registers the controller with the manager. +func (r *StackitMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&infrav1.StackitMachine{}). + Watches(&clusterv1.Machine{}, handler.EnqueueRequestsFromMapFunc(r.stackitMachineRequestsForMachine)). + Watches(&infrav1.StackitCluster{}, handler.EnqueueRequestsFromMapFunc(r.stackitMachineRequestsForStackitCluster)). + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.stackitMachineRequestsForBootstrapSecret)). + Named("stackitmachine"). + Complete(r) +} diff --git a/internal/controller/stackitmachine_controller_test.go b/controller/stackitmachine_controller_test.go similarity index 99% rename from internal/controller/stackitmachine_controller_test.go rename to controller/stackitmachine_controller_test.go index 4751dcb..b3b4d7b 100644 --- a/internal/controller/stackitmachine_controller_test.go +++ b/controller/stackitmachine_controller_test.go @@ -26,9 +26,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud/fake" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/cloud/fake" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) var _ = Describe("StackitMachine Controller", func() { diff --git a/controller/stackitmachine_infrastructure.go b/controller/stackitmachine_infrastructure.go new file mode 100644 index 0000000..c472edf --- /dev/null +++ b/controller/stackitmachine_infrastructure.go @@ -0,0 +1,362 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + bastionservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/bastion" + loadbalancerservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/loadbalancer" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, s *scope.MachineScope) (ctrl.Result, error) { + log := logf.FromContext(ctx) + sm := s.StackitMachine + + if !controllerutil.ContainsFinalizer(sm, infrav1.MachineFinalizer) { + controllerutil.AddFinalizer(sm, infrav1.MachineFinalizer) + } + + if !s.StackitCluster.Status.Ready { + s.SetNotReady("InfrastructureNotReady", "waiting for StackitCluster to be ready", infrav1.MachineReadyCondition) + return ctrl.Result{}, nil + } + if err := validateMachineAvailabilityZone(s); err != nil { + s.SetNotReady("InvalidFailureDomain", err.Error(), infrav1.MachineInstanceReadyCondition, infrav1.MachineReadyCondition) + return ctrl.Result{}, nil + } + + bootstrapData, condStatus, reason, msg := r.fetchBootstrapData(ctx, s.Machine) + s.SetConditions(condStatus, reason, msg, infrav1.MachineBootstrapReadyCondition) + if condStatus != metav1.ConditionTrue { + s.SetConditions(metav1.ConditionFalse, reason, msg, infrav1.MachineReadyCondition) + if reason == util.BootstrapReasonInvalid { + return ctrl.Result{}, nil + } + return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil + } + + cloudClient, err := util.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, s.StackitCluster) + if err != nil { + return util.CredentialFailureResult( + &sm.Status.Conditions, + sm.Generation, + err, + infrav1.MachineCredentialsReadyCondition, + infrav1.MachineReadyCondition, + ) + } + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.MachineCredentialsReadyCondition) + + server, created, err := r.ensureServer(ctx, cloudClient, s, bootstrapData) + if err != nil { + return util.CloudFailureResult( + &sm.Status.Conditions, + sm.Generation, + "InstanceError", + err, + retryableErrorRequeueAfter, + true, + infrav1.MachineInstanceReadyCondition, + infrav1.MachineReadyCondition, + ) + } + if created && r.Recorder != nil { + r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceCreated", "Created instance %s", server.ID) + } + + sm.Status.InstanceState = server.State + sm.Status.Addresses = machineAddressesFromCloud(server.Addresses) + providerID := s.SetInstance(server) + + if server.State != "" && server.State != "ACTIVE" { + s.SetNotReady("Provisioning", fmt.Sprintf("server state is %s", server.State), infrav1.MachineInstanceReadyCondition, infrav1.MachineReadyCondition) + return ctrl.Result{RequeueAfter: 15 * time.Second}, nil + } + + if err := r.reconcileBastionNodeSSHAccess(ctx, cloudClient, s, server); err != nil { + return util.CloudFailureResult( + &sm.Status.Conditions, + sm.Generation, + "BastionSSHAccessError", + err, + retryableErrorRequeueAfter, + true, + infrav1.MachineReadyCondition, + ) + } + + if err := r.reconcileAPIServerLoadBalancerTarget(ctx, cloudClient, s, server); err != nil { + return util.CloudFailureResult( + &sm.Status.Conditions, + sm.Generation, + "LoadBalancerTargetError", + err, + retryableErrorRequeueAfter, + true, + infrav1.MachineReadyCondition, + ) + } + + s.SetReady() + log.V(1).Info("StackitMachine ready", "providerID", providerID) + return ctrl.Result{}, nil +} + +func validateMachineAvailabilityZone(s *scope.MachineScope) error { + availabilityZone := s.StackitMachine.Spec.AvailabilityZone + if availabilityZone == "" || len(s.StackitCluster.Status.FailureDomains) == 0 { + return nil + } + for _, failureDomain := range s.StackitCluster.Status.FailureDomains { + if failureDomain.Name == availabilityZone { + return nil + } + } + return fmt.Errorf("availabilityZone %q is not published in StackitCluster status.failureDomains", availabilityZone) +} + +func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, s *scope.MachineScope) error { + sm := s.StackitMachine + needsLoadBalancerCleanup := isControlPlaneMachine(s.Machine) && + s.StackitCluster.Spec.APIServerLoadBalancer.Enabled && + s.StackitCluster.Status.APIServerLoadBalancerID != "" + if sm.Status.InstanceID == "" && !needsLoadBalancerCleanup { + controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) + if r.Recorder != nil { + r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceDeleted", "Deleted instance") + } + return nil + } + cloudClient, err := util.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, s.StackitCluster) + if err != nil { + _, resultErr := util.CredentialFailureResult( + &sm.Status.Conditions, + sm.Generation, + err, + infrav1.MachineCredentialsReadyCondition, + ) + return resultErr + } + if err := r.deleteAPIServerLoadBalancerTarget(ctx, cloudClient, s); err != nil { + return err + } + if sm.Status.InstanceID == "" { + controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) + if r.Recorder != nil { + r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceDeleted", "Deleted instance") + } + return nil + } + instanceID := sm.Status.InstanceID + if err := cloudClient.DeleteServer(ctx, instanceID); err != nil && !cloud.IsNotFound(err) { + return err + } + s.ClearInstance() + controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) + if r.Recorder != nil { + r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceDeleted", "Deleted instance %s", instanceID) + } + return nil +} + +func (r *StackitMachineReconciler) fetchBootstrapData(ctx context.Context, machine *clusterv1.Machine) ([]byte, metav1.ConditionStatus, string, string) { + if machine.Spec.Bootstrap.DataSecretName == nil || *machine.Spec.Bootstrap.DataSecretName == "" { + return nil, metav1.ConditionFalse, "BootstrapDataSecretMissing", "Machine.spec.bootstrap.dataSecretName is empty" + } + secret := &corev1.Secret{} + key := types.NamespacedName{Namespace: machine.Namespace, Name: *machine.Spec.Bootstrap.DataSecretName} + if err := r.Get(ctx, key, secret); err != nil { + if apierrors.IsNotFound(err) { + return nil, metav1.ConditionFalse, "BootstrapDataSecretNotFound", fmt.Sprintf("bootstrap secret %s not found", key) + } + return nil, metav1.ConditionFalse, "BootstrapDataSecretError", err.Error() + } + data, err := util.ExtractBootstrapData(secret) + if err != nil { + return nil, metav1.ConditionFalse, util.BootstrapReasonInvalid, err.Error() + } + return data, metav1.ConditionTrue, "Available", "" +} + +func (r *StackitMachineReconciler) ensureServer( + ctx context.Context, + c cloud.Client, + s *scope.MachineScope, + userData []byte, +) (*cloud.Server, bool, error) { + sm := s.StackitMachine + tags := s.Tags() + if sm.Status.InstanceID != "" { + server, err := c.GetServer(ctx, sm.Status.InstanceID) + if err == nil { + return server, false, nil + } + if !cloud.IsNotFound(err) { + return nil, false, err + } + } + if server, err := c.FindServerByTags(ctx, tags); err == nil { + return server, false, nil + } else if !cloud.IsNotFound(err) { + return nil, false, err + } + deleteOnTermination := true + if sm.Spec.RootVolume.DeleteOnTermination != nil { + deleteOnTermination = *sm.Spec.RootVolume.DeleteOnTermination + } + server, err := c.CreateServer(ctx, cloud.CreateServerInput{ + Name: sm.Name, + ProjectID: s.StackitCluster.Spec.ProjectID, + Region: s.StackitCluster.Spec.Region, + ImageID: sm.Spec.ImageID, + MachineType: sm.Spec.MachineType, + AvailabilityZone: sm.Spec.AvailabilityZone, + SSHKeyName: sm.Spec.SSHKeyName, + NetworkID: sm.Spec.Network.ID, + SecurityGroups: sm.Spec.SecurityGroups, + UserData: userData, + Tags: tags, + RootVolume: cloud.RootVolumeInput{ + SizeGiB: sm.Spec.RootVolume.SizeGiB, + PerformanceClass: sm.Spec.RootVolume.PerformanceClass, + DeleteOnTermination: deleteOnTermination, + }, + }) + return server, true, err +} + +func (r *StackitMachineReconciler) reconcileBastionNodeSSHAccess( + ctx context.Context, + c cloud.Client, + s *scope.MachineScope, + server *cloud.Server, +) error { + if !s.StackitCluster.Spec.Bastion.Enabled { + return nil + } + if s.StackitCluster.Status.Bastion.SecurityGroupID == "" { + return fmt.Errorf("%w: bastion security group ID is empty", cloud.ErrTransient) + } + if server == nil || server.ID == "" { + return fmt.Errorf("%w: server ID is empty", cloud.ErrTransient) + } + _, err := c.EnsureNodeSSHAccess(ctx, cloud.NodeSSHAccessInput{ + Name: s.StackitCluster.Name + "-node-ssh", + ServerID: server.ID, + BastionSecurityGroupID: s.StackitCluster.Status.Bastion.SecurityGroupID, + Tags: bastionservice.NodeSSHAccessTags(s.StackitCluster), + }) + return err +} + +func (r *StackitMachineReconciler) reconcileAPIServerLoadBalancerTarget( + ctx context.Context, + c cloud.Client, + s *scope.MachineScope, + server *cloud.Server, +) error { + if !isControlPlaneMachine(s.Machine) || !s.StackitCluster.Spec.APIServerLoadBalancer.Enabled { + return nil + } + loadBalancerID, err := loadbalancerservice.EnsureForMachine( + ctx, + c, + s.StackitCluster, + s.Machine.Name, + server.Addresses, + ) + if err != nil { + return err + } + + target, err := loadbalancerservice.TargetForMachine(s.Machine.Name, server.Addresses) + if err != nil { + return err + } + target.LoadBalancerID = loadBalancerID + return c.EnsureAPIServerLoadBalancerTarget(ctx, target) +} + +func (r *StackitMachineReconciler) deleteAPIServerLoadBalancerTarget(ctx context.Context, c cloud.Client, s *scope.MachineScope) error { + if !isControlPlaneMachine(s.Machine) || !s.StackitCluster.Spec.APIServerLoadBalancer.Enabled { + return nil + } + loadBalancerID, err := loadbalancerservice.ResolveID(ctx, c, s.StackitCluster) + if err != nil { + return err + } + if loadBalancerID == "" { + return nil + } + err = c.DeleteAPIServerLoadBalancerTarget(ctx, cloud.LoadBalancerTargetInput{ + LoadBalancerID: loadBalancerID, + Name: s.Machine.Name, + Port: defaultAPIServerPort, + }) + if cloud.IsNotFound(err) { + return nil + } + return err +} + +func (r *StackitMachineReconciler) getStackitCluster(ctx context.Context, cluster *clusterv1.Cluster) (*infrav1.StackitCluster, error) { + if cluster.Spec.InfrastructureRef.Name == "" { + return nil, nil + } + stackitCluster := &infrav1.StackitCluster{} + key := types.NamespacedName{Namespace: cluster.Namespace, Name: cluster.Spec.InfrastructureRef.Name} + if err := r.Get(ctx, key, stackitCluster); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get StackitCluster %s: %w", key, err) + } + return stackitCluster, nil +} + +func machineAddressesFromCloud(in []cloud.Address) []clusterv1.MachineAddress { + if len(in) == 0 { + return nil + } + + out := make([]clusterv1.MachineAddress, len(in)) + for i, address := range in { + out[i] = clusterv1.MachineAddress{ + Type: clusterv1.MachineAddressType(address.Type), + Address: address.Address, + } + } + return out +} + +func isControlPlaneMachine(machine *clusterv1.Machine) bool { + if machine == nil { + return false + } + _, ok := machine.Labels[clusterv1.MachineControlPlaneLabel] + return ok +} diff --git a/controller/stackitmachine_watches.go b/controller/stackitmachine_watches.go new file mode 100644 index 0000000..266adfc --- /dev/null +++ b/controller/stackitmachine_watches.go @@ -0,0 +1,101 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package controller + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +func (r *StackitMachineReconciler) stackitMachineRequestsForMachine(_ context.Context, obj client.Object) []reconcile.Request { + machine, ok := obj.(*clusterv1.Machine) + if !ok { + return nil + } + return stackitMachineRequestForMachine(machine) +} + +func (r *StackitMachineReconciler) stackitMachineRequestsForStackitCluster(ctx context.Context, obj client.Object) []reconcile.Request { + stackitCluster, ok := obj.(*infrav1.StackitCluster) + if !ok { + return nil + } + + machines := &clusterv1.MachineList{} + if err := r.List(ctx, machines, client.InNamespace(stackitCluster.Namespace)); err != nil { + logf.FromContext(ctx).Error(err, "Failed to list Machines for StackitCluster watch", "stackitCluster", client.ObjectKeyFromObject(stackitCluster)) + return nil + } + + return stackitMachineRequestsForMachines(machines.Items, func(machine clusterv1.Machine) bool { + return machine.Spec.ClusterName == stackitCluster.Name + }) +} + +func (r *StackitMachineReconciler) stackitMachineRequestsForBootstrapSecret(ctx context.Context, obj client.Object) []reconcile.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + + machines := &clusterv1.MachineList{} + if err := r.List(ctx, machines, client.InNamespace(secret.Namespace)); err != nil { + logf.FromContext(ctx).Error(err, "Failed to list Machines for bootstrap Secret watch", "secret", client.ObjectKeyFromObject(secret)) + return nil + } + + return stackitMachineRequestsForMachines(machines.Items, func(machine clusterv1.Machine) bool { + return machine.Spec.Bootstrap.DataSecretName != nil && + *machine.Spec.Bootstrap.DataSecretName == secret.Name + }) +} + +func stackitMachineRequestsForMachines(machines []clusterv1.Machine, matches func(clusterv1.Machine) bool) []reconcile.Request { + requests := make([]reconcile.Request, 0, len(machines)) + seen := map[types.NamespacedName]struct{}{} + for _, machine := range machines { + if !matches(machine) { + continue + } + for _, request := range stackitMachineRequestForMachine(&machine) { + if _, ok := seen[request.NamespacedName]; ok { + continue + } + seen[request.NamespacedName] = struct{}{} + requests = append(requests, request) + } + } + return requests +} + +func stackitMachineRequestForMachine(machine *clusterv1.Machine) []reconcile.Request { + if machine == nil { + return nil + } + ref := machine.Spec.InfrastructureRef + if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitMachine" || ref.Name == "" { + return nil + } + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: machine.Namespace, + Name: ref.Name, + }, + }} +} diff --git a/internal/controller/suite_test.go b/controller/suite_test.go similarity index 97% rename from internal/controller/suite_test.go rename to controller/suite_test.go index 943aac4..6644b98 100644 --- a/internal/controller/suite_test.go +++ b/controller/suite_test.go @@ -71,7 +71,7 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{ - filepath.Join("..", "..", "config", "crd", "bases"), + filepath.Join("..", "config", "crd", "bases"), filepath.Join(goModCache(), "sigs.k8s.io", "cluster-api@v1.13.2", "config", "crd", "bases"), }, ErrorIfCRDPathMissing: true, @@ -109,7 +109,7 @@ var _ = AfterSuite(func() { // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are // properly set up, run 'make setup-envtest' beforehand. func getFirstFoundEnvTestBinaryDir() string { - basePath := filepath.Join("..", "..", "bin", "k8s") + basePath := filepath.Join("..", "bin", "k8s") entries, err := os.ReadDir(basePath) if err != nil { logf.Log.Error(err, "Failed to read directory", "path", basePath) diff --git a/docs/src/development/architecture.md b/docs/src/development/architecture.md index 3bb50b6..381ffe8 100644 --- a/docs/src/development/architecture.md +++ b/docs/src/development/architecture.md @@ -13,10 +13,10 @@ Key packages: ```text api/v1alpha1/ Provider API types -internal/controller/ Reconciliation logic -pkg/cloud/ Cloud client interface and SDK implementation -pkg/cloud/fake/ In-memory fake for tests -pkg/util/ Shared helpers +controller/ Reconciliation logic +cloud/ Cloud client interface and SDK implementation +cloud/fake/ In-memory fake for tests +util/ Shared helpers templates/ clusterctl templates config/ Kubebuilder manifests ``` diff --git a/internal/controller/contract_helpers.go b/internal/controller/contract_helpers.go deleted file mode 100644 index 656af89..0000000 --- a/internal/controller/contract_helpers.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 -*/ - -package controller - -import ( - "strings" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" - "sigs.k8s.io/cluster-api/util/annotations" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" -) - -func reconciliationPaused(cluster *clusterv1.Cluster, obj client.Object) (bool, string) { - if cluster == nil { - if annotations.HasPaused(obj) { - return true, "object has the cluster.x-k8s.io/paused annotation" - } - return false, "" - } - - var reasons []string - if ptr.Deref(cluster.Spec.Paused, false) { - reasons = append(reasons, "Cluster spec.paused is set to true") - } - if annotations.HasPaused(obj) { - reasons = append(reasons, "object has the cluster.x-k8s.io/paused annotation") - } - if len(reasons) == 0 { - return false, "" - } - return true, strings.Join(reasons, ", ") -} - -func setPausedCondition(conditions *[]metav1.Condition, generation int64, paused bool, message string) { - if paused { - util.SetCondition(conditions, clusterv1.PausedCondition, metav1.ConditionTrue, clusterv1.PausedReason, message, generation) - return - } - util.SetCondition(conditions, clusterv1.PausedCondition, metav1.ConditionFalse, clusterv1.NotPausedReason, "", generation) -} diff --git a/internal/controller/helpers/conditions.go b/internal/controller/helpers/conditions.go deleted file mode 100644 index a9fda9b..0000000 --- a/internal/controller/helpers/conditions.go +++ /dev/null @@ -1,66 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 -*/ - -package helpers - -import ( - "errors" - "time" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" - - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" -) - -func SetConditions( - conditions *[]metav1.Condition, - generation int64, - status metav1.ConditionStatus, - reason, message string, - conditionTypes ...string, -) { - for _, conditionType := range conditionTypes { - util.SetCondition(conditions, conditionType, status, reason, message, generation) - } -} - -func CredentialFailureResult( - conditions *[]metav1.Condition, - generation int64, - err error, - conditionTypes ...string, -) (ctrl.Result, error) { - SetConditions(conditions, generation, metav1.ConditionFalse, "CredentialsInvalid", err.Error(), conditionTypes...) - if cloud.IsUnauthorized(err) || cloud.IsInvalidInput(err) || errors.Is(err, util.ErrCredentialsInvalid) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err -} - -func CloudFailureResult( - conditions *[]metav1.Condition, - generation int64, - reason string, - err error, - requeueAfter time.Duration, - returnError bool, - conditionTypes ...string, -) (ctrl.Result, error) { - SetConditions(conditions, generation, metav1.ConditionFalse, reason, err.Error(), conditionTypes...) - if cloud.IsRetryable(err) { - return ctrl.Result{RequeueAfter: requeueAfter}, nil - } - if returnError { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil -} diff --git a/internal/controller/stackitcluster_controller.go b/internal/controller/stackitcluster_controller.go deleted file mode 100644 index a519937..0000000 --- a/internal/controller/stackitcluster_controller.go +++ /dev/null @@ -1,602 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "crypto/sha256" - "fmt" - "net/netip" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" - clusterutil "sigs.k8s.io/cluster-api/util" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - ctrlhlp "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller/helpers" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/scope" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" -) - -const ( - // defaultAPIServerPort is used when an LB is created without an explicit port. - defaultAPIServerPort int32 = 6443 - - cloudInitRefKindSecret = "Secret" - - retryableErrorRequeueAfter = 5 * time.Second -) - -// StackitClusterReconciler reconciles a StackitCluster object. -type StackitClusterReconciler struct { - client.Client - Scheme *runtime.Scheme - - // CloudClientFactory builds a cloud.Client from parsed credentials. It is - // injected so tests can swap in the in-memory fake. - CloudClientFactory cloud.Factory -} - -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters/finalizers,verbs=update -// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch - -// Reconcile implements the spec section 18 flow. -func (r *StackitClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { - log := logf.FromContext(ctx) - - stackitCluster := &infrav1.StackitCluster{} - if err := r.Get(ctx, req.NamespacedName, stackitCluster); err != nil { - if apierrors.IsNotFound(err) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - - cluster, err := clusterutil.GetOwnerCluster(ctx, r.Client, stackitCluster.ObjectMeta) - if err != nil { - return ctrl.Result{}, fmt.Errorf("get owner cluster: %w", err) - } - if cluster == nil { - log.Info("StackitCluster has no owning Cluster yet, requeueing") - return ctrl.Result{}, nil - } - - clusterScope, err := scope.NewClusterScope(r.Client, cluster, stackitCluster) - if err != nil { - return ctrl.Result{}, fmt.Errorf("create cluster scope: %w", err) - } - defer func() { - if patchErr := clusterScope.PatchObject(ctx); patchErr != nil && err == nil { - err = patchErr - } - }() - - if paused, message := reconciliationPaused(cluster, stackitCluster); paused { - setPausedCondition(&stackitCluster.Status.Conditions, stackitCluster.Generation, true, message) - return ctrl.Result{}, nil - } - setPausedCondition(&stackitCluster.Status.Conditions, stackitCluster.Generation, false, "") - - if !stackitCluster.DeletionTimestamp.IsZero() { - return ctrl.Result{}, r.reconcileDelete(ctx, clusterScope) - } - return r.reconcileNormal(ctx, clusterScope) -} - -func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, s *scope.ClusterScope) (ctrl.Result, error) { - log := logf.FromContext(ctx) - sc := s.StackitCluster - - if !controllerutil.ContainsFinalizer(sc, infrav1.ClusterFinalizer) { - controllerutil.AddFinalizer(sc, infrav1.ClusterFinalizer) - } - sc.Status.FailureDomains = stackitFailureDomains(sc.Spec.Region) - - cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, sc) - if err != nil { - sc.Status.Ready = false - return ctrlhlp.CredentialFailureResult( - &sc.Status.Conditions, - sc.Generation, - err, - infrav1.ClusterCredentialsReadyCondition, - infrav1.ClusterReadyCondition, - ) - } - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionTrue, - "Available", - "", - infrav1.ClusterCredentialsReadyCondition, - ) - - network, err := cloudClient.GetNetwork(ctx, sc.Spec.Network.ID) - if err != nil { - sc.Status.Ready = false - return ctrlhlp.CloudFailureResult( - &sc.Status.Conditions, - sc.Generation, - "NetworkNotFound", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterNetworkReadyCondition, - infrav1.ClusterReadyCondition, - ) - } - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionTrue, - "Available", - "", - infrav1.ClusterNetworkReadyCondition, - ) - - if sc.Spec.APIServerLoadBalancer.Enabled { - lb, err := cloudClient.EnsureAPIServerLoadBalancer( - ctx, - ctrlhlp.APIServerLoadBalancerInput( - sc, - []cloud.LoadBalancerTargetInput{ctrlhlp.BootstrapAPIServerLoadBalancerTarget(bootstrapTargetIP(network))}, - ), - ) - if err != nil { - sc.Status.Ready = false - return ctrlhlp.CloudFailureResult( - &sc.Status.Conditions, - sc.Generation, - "LoadBalancerError", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterLoadBalancerReadyCondition, - infrav1.ClusterReadyCondition, - ) - } - if lb != nil { - sc.Status.APIServerLoadBalancerID = lb.ID - } - if lb == nil || lb.IP == "" { - sc.Status.Ready = false - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionFalse, - "Provisioning", - "waiting for API server load balancer IP address", - infrav1.ClusterLoadBalancerReadyCondition, - infrav1.ClusterReadyCondition, - ) - return ctrl.Result{RequeueAfter: 10 * time.Second}, nil - } - if lb == nil { - return ctrl.Result{}, fmt.Errorf("%w: API server load balancer is nil", cloud.ErrTransient) - } - endpoint := clusterv1.APIEndpoint{ - Host: lb.IP, - Port: defaultAPIServerPort, - } - sc.Spec.ControlPlaneEndpoint = endpoint - sc.Status.APIServerEndpoint = endpoint - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionTrue, - "Available", - "", - infrav1.ClusterLoadBalancerReadyCondition, - ) - } else if sc.Spec.ControlPlaneEndpoint.Host != "" { - sc.Status.APIServerEndpoint = sc.Spec.ControlPlaneEndpoint - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionTrue, - "Skipped", - "external endpoint provided", - infrav1.ClusterLoadBalancerReadyCondition, - ) - } else { - sc.Status.Ready = false - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionFalse, - "EndpointMissing", - "apiServerLoadBalancer.enabled is false and controlPlaneEndpoint is empty", - infrav1.ClusterLoadBalancerReadyCondition, - infrav1.ClusterReadyCondition, - ) - return ctrl.Result{}, nil - } - - if result, ready, err := r.reconcileBastion(ctx, cloudClient, sc); err != nil { - sc.Status.Ready = false - return ctrlhlp.CloudFailureResult( - &sc.Status.Conditions, - sc.Generation, - "BastionError", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterBastionReadyCondition, - infrav1.ClusterReadyCondition, - ) - } else if !ready { - return result, nil - } - - sc.Status.Ready = true - sc.Status.Initialization.Provisioned = true - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionTrue, - "Available", - "", - infrav1.ClusterReadyCondition, - ) - log.V(1).Info("StackitCluster ready", "endpoint", sc.Status.APIServerEndpoint) - return ctrl.Result{}, nil -} - -func stackitFailureDomains(region string) []clusterv1.FailureDomain { - controlPlane := true - return []clusterv1.FailureDomain{ - { - Name: region + "-1", - ControlPlane: &controlPlane, - Attributes: map[string]string{ - "region": region, - }, - }, - { - Name: region + "-2", - ControlPlane: &controlPlane, - Attributes: map[string]string{ - "region": region, - }, - }, - { - Name: region + "-3", - ControlPlane: &controlPlane, - Attributes: map[string]string{ - "region": region, - }, - }, - } -} - -func bootstrapTargetIP(network *cloud.Network) string { - if network == nil { - return "10.0.0.1" - } - for _, prefixValue := range network.IPv4Prefixes { - prefix, err := netip.ParsePrefix(prefixValue) - if err != nil || !prefix.Addr().Is4() { - continue - } - address := prefix.Masked().Addr() - for range 10 { - address = address.Next() - } - if prefix.Contains(address) { - return address.String() - } - } - return "10.0.0.1" -} - -func (r *StackitClusterReconciler) reconcileBastion( - ctx context.Context, - cloudClient cloud.Client, - sc *infrav1.StackitCluster, -) (ctrl.Result, bool, error) { - input := ctrlhlp.BastionInput(sc, nil) - status := cloud.Bastion{ - ServerID: sc.Status.Bastion.ServerID, - PublicIPID: sc.Status.Bastion.PublicIPID, - PublicIP: sc.Status.Bastion.PublicIP, - SecurityGroupID: sc.Status.Bastion.SecurityGroupID, - } - - if !sc.Spec.Bastion.Enabled { - if hasBastionStatus(sc.Status.Bastion) { - if err := cloudClient.DeleteNodeSSHAccess(ctx, ctrlhlp.NodeSSHAccessTags(sc)); err != nil { - return ctrl.Result{}, false, err - } - if err := cloudClient.DeleteBastion(ctx, input, status); err != nil { - return ctrl.Result{}, false, err - } - sc.Status.Bastion = infrav1.StackitBastionStatus{} - } - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionTrue, "Skipped", "bastion disabled", sc.Generation) - return ctrl.Result{}, true, nil - } - - if err := validateBastionSpec(sc.Spec.Bastion); err != nil { - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionFalse, "InvalidBastionSpec", err.Error(), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "InvalidBastionSpec", err.Error(), sc.Generation) - sc.Status.Ready = false - return ctrl.Result{}, false, nil - } - - cloudInit, err := r.resolveBastionCloudInit(ctx, sc) - if err != nil { - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionFalse, "CloudInitRefError", err.Error(), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "CloudInitRefError", err.Error(), sc.Generation) - sc.Status.Ready = false - return ctrl.Result{}, false, nil - } - input.CloudInit = cloudInit - - if bastionNeedsRecreate(sc, cloudInit) { - if err := cloudClient.DeleteNodeSSHAccess(ctx, ctrlhlp.NodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { - return ctrl.Result{}, false, err - } - if err := cloudClient.DeleteBastion(ctx, input, status); err != nil && !cloud.IsNotFound(err) { - return ctrl.Result{}, false, err - } - sc.Status.Bastion = infrav1.StackitBastionStatus{} - sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionFalse, "Recreating", "recreating bastion because cloudInitRef content changed", sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "Recreating", "recreating bastion because cloudInitRef content changed", sc.Generation) - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, false, nil - } - - bastion, err := cloudClient.EnsureBastion(ctx, input) - if err != nil { - return ctrl.Result{}, false, err - } - sc.Status.Bastion = infrav1.StackitBastionStatus{ - ServerID: bastion.ServerID, - PublicIPID: bastion.PublicIPID, - PublicIP: bastion.PublicIP, - SecurityGroupID: bastion.SecurityGroupID, - CloudInitHash: bastionCloudInitHash(cloudInit), - } - if bastion.ServerState != "" && bastion.ServerState != "ACTIVE" { - sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionFalse, "Provisioning", fmt.Sprintf("bastion server state is %s", bastion.ServerState), sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "Provisioning", fmt.Sprintf("bastion server state is %s", bastion.ServerState), sc.Generation) - return ctrl.Result{RequeueAfter: 15 * time.Second}, false, nil - } - if bastion.PublicIP == "" { - sc.Status.Ready = false - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionFalse, "Provisioning", "waiting for bastion public IP address", sc.Generation) - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterReadyCondition, - metav1.ConditionFalse, "Provisioning", "waiting for bastion public IP address", sc.Generation) - return ctrl.Result{RequeueAfter: 10 * time.Second}, false, nil - } - util.SetCondition(&sc.Status.Conditions, infrav1.ClusterBastionReadyCondition, - metav1.ConditionTrue, "Available", "", sc.Generation) - return ctrl.Result{}, true, nil -} - -func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, s *scope.ClusterScope) error { - sc := s.StackitCluster - if sc.Status.APIServerLoadBalancerID != "" || hasBastionStatus(sc.Status.Bastion) || sc.Spec.APIServerLoadBalancer.Enabled { - cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, sc) - if err != nil { - // If we cannot reach the cloud during delete, surface the condition - // but do not block forever; finalizer removal is gated on the LB - // deletion succeeding (or being already absent). - ctrlhlp.SetConditions( - &sc.Status.Conditions, - sc.Generation, - metav1.ConditionFalse, - "CredentialsInvalid", - err.Error(), - infrav1.ClusterCredentialsReadyCondition, - ) - return err - } - loadBalancerID, err := ctrlhlp.ResolveAPIServerLoadBalancerID(ctx, cloudClient, sc) - if err != nil { - return err - } - if loadBalancerID != "" { - if err := cloudClient.DeleteAPIServerLoadBalancer(ctx, loadBalancerID); err != nil && !cloud.IsNotFound(err) { - return err - } - sc.Status.APIServerLoadBalancerID = "" - } - if hasBastionStatus(sc.Status.Bastion) { - if err := cloudClient.DeleteNodeSSHAccess(ctx, ctrlhlp.NodeSSHAccessTags(sc)); err != nil && !cloud.IsNotFound(err) { - return err - } - if err := cloudClient.DeleteBastion(ctx, ctrlhlp.BastionInput(sc, nil), cloud.Bastion{ - ServerID: sc.Status.Bastion.ServerID, - PublicIPID: sc.Status.Bastion.PublicIPID, - PublicIP: sc.Status.Bastion.PublicIP, - SecurityGroupID: sc.Status.Bastion.SecurityGroupID, - }); err != nil && !cloud.IsNotFound(err) { - return err - } - sc.Status.Bastion = infrav1.StackitBastionStatus{} - } - } - controllerutil.RemoveFinalizer(sc, infrav1.ClusterFinalizer) - return nil -} - -func validateBastionSpec(spec infrav1.StackitBastionSpec) error { - if spec.ImageID == "" { - return fmt.Errorf("%w: bastion.imageID is required", cloud.ErrInvalidInput) - } - if spec.MachineType == "" { - return fmt.Errorf("%w: bastion.machineType is required", cloud.ErrInvalidInput) - } - if spec.SSHKeyName == "" { - return fmt.Errorf("%w: bastion.sshKeyName is required", cloud.ErrInvalidInput) - } - if len(spec.AllowedCIDRs) == 0 { - return fmt.Errorf("%w: bastion.allowedCIDRs is required", cloud.ErrInvalidInput) - } - for _, cidr := range spec.AllowedCIDRs { - if _, err := netip.ParsePrefix(cidr); err != nil { - return fmt.Errorf("%w: bastion.allowedCIDRs contains invalid CIDR %q", cloud.ErrInvalidInput, cidr) - } - } - return nil -} - -func hasBastionStatus(status infrav1.StackitBastionStatus) bool { - return status.ServerID != "" || status.PublicIPID != "" || status.PublicIP != "" || status.SecurityGroupID != "" -} - -func bastionNeedsRecreate(sc *infrav1.StackitCluster, cloudInit []byte) bool { - if !hasBastionStatus(sc.Status.Bastion) { - return false - } - return sc.Status.Bastion.CloudInitHash != bastionCloudInitHash(cloudInit) -} - -func bastionCloudInitHash(cloudInit []byte) string { - if len(cloudInit) == 0 { - return "" - } - return fmt.Sprintf("%x", sha256.Sum256(cloudInit)) -} - -func (r *StackitClusterReconciler) resolveBastionCloudInit(ctx context.Context, sc *infrav1.StackitCluster) ([]byte, error) { - ref := sc.Spec.Bastion.CloudInitRef - if ref == nil { - return nil, nil - } - key := types.NamespacedName{Namespace: sc.Namespace, Name: ref.Name} - switch ref.Kind { - case "ConfigMap": - configMap := &corev1.ConfigMap{} - if err := r.Get(ctx, key, configMap); err != nil { - if apierrors.IsNotFound(err) { - return nil, fmt.Errorf("bastion.cloudInitRef ConfigMap %s not found", key) - } - return nil, err - } - value, ok := configMap.Data[ref.Key] - if !ok { - return nil, fmt.Errorf("bastion.cloudInitRef key %q not found in ConfigMap %s", ref.Key, key) - } - return []byte(value), nil - case cloudInitRefKindSecret: - secret := &corev1.Secret{} - if err := r.Get(ctx, key, secret); err != nil { - if apierrors.IsNotFound(err) { - return nil, fmt.Errorf("bastion.cloudInitRef Secret %s not found", key) - } - return nil, err - } - value, ok := secret.Data[ref.Key] - if !ok { - return nil, fmt.Errorf("bastion.cloudInitRef key %q not found in Secret %s", ref.Key, key) - } - return append([]byte(nil), value...), nil - default: - return nil, fmt.Errorf("bastion.cloudInitRef.kind must be ConfigMap or Secret") - } -} - -func (r *StackitClusterReconciler) stackitClusterRequestsForCluster(_ context.Context, obj client.Object) []reconcile.Request { - cluster, ok := obj.(*clusterv1.Cluster) - if !ok { - return nil - } - ref := cluster.Spec.InfrastructureRef - if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitCluster" || ref.Name == "" { - return nil - } - return []reconcile.Request{{ - NamespacedName: types.NamespacedName{ - Namespace: cluster.Namespace, - Name: ref.Name, - }, - }} -} - -func (r *StackitClusterReconciler) stackitClusterRequestsForCloudInitRef(ctx context.Context, obj client.Object) []reconcile.Request { - kind := "" - switch obj.(type) { - case *corev1.ConfigMap: - kind = "ConfigMap" - case *corev1.Secret: - kind = "Secret" - default: - return nil - } - - clusters := &infrav1.StackitClusterList{} - if err := r.List(ctx, clusters, client.InNamespace(obj.GetNamespace())); err != nil { - logf.FromContext(ctx).Error(err, "Failed to list StackitClusters for bastion cloud-init watch", "object", client.ObjectKeyFromObject(obj)) - return nil - } - - requests := make([]reconcile.Request, 0, len(clusters.Items)) - for _, cluster := range clusters.Items { - ref := cluster.Spec.Bastion.CloudInitRef - if ref == nil || ref.Kind != kind || ref.Name != obj.GetName() { - continue - } - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Namespace: cluster.Namespace, - Name: cluster.Name, - }, - }) - } - return requests -} - -// SetupWithManager registers the controller with the manager. -func (r *StackitClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&infrav1.StackitCluster{}). - Watches(&clusterv1.Cluster{}, handler.EnqueueRequestsFromMapFunc(r.stackitClusterRequestsForCluster)). - Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.stackitClusterRequestsForCloudInitRef)). - Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.stackitClusterRequestsForCloudInitRef)). - Named("stackitcluster"). - Complete(r) -} diff --git a/internal/controller/stackitmachine_controller.go b/internal/controller/stackitmachine_controller.go deleted file mode 100644 index c5052d9..0000000 --- a/internal/controller/stackitmachine_controller.go +++ /dev/null @@ -1,581 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "fmt" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" - clusterutil "sigs.k8s.io/cluster-api/util" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - ctrlhlp "github.com/stackitcloud/cluster-api-provider-stackit/internal/controller/helpers" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/scope" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" -) - -// StackitMachineReconciler reconciles a StackitMachine object. -type StackitMachineReconciler struct { - client.Client - Scheme *runtime.Scheme - - // CloudClientFactory builds a cloud.Client from parsed credentials. - CloudClientFactory cloud.Factory -} - -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines/finalizers,verbs=update -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters,verbs=get;list;watch -// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch -// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machines,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch - -// Reconcile implements the spec section 19 flow. -func (r *StackitMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, err error) { - log := logf.FromContext(ctx) - - stackitMachine := &infrav1.StackitMachine{} - if err := r.Get(ctx, req.NamespacedName, stackitMachine); err != nil { - if apierrors.IsNotFound(err) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - - machine, err := clusterutil.GetOwnerMachine(ctx, r.Client, stackitMachine.ObjectMeta) - if err != nil { - return ctrl.Result{}, fmt.Errorf("get owner machine: %w", err) - } - if machine == nil { - log.Info("StackitMachine has no owning Machine yet, requeueing") - return ctrl.Result{}, nil - } - - cluster, err := clusterutil.GetClusterFromMetadata(ctx, r.Client, machine.ObjectMeta) - if err != nil { - return ctrl.Result{}, fmt.Errorf("get cluster from machine metadata: %w", err) - } - if cluster == nil { - log.Info("Machine has no owning Cluster yet, requeueing") - return ctrl.Result{}, nil - } - - stackitCluster, err := r.getStackitCluster(ctx, cluster) - if err != nil { - return ctrl.Result{}, err - } - if stackitCluster == nil { - log.Info("StackitCluster not found, requeueing") - return ctrl.Result{}, nil - } - - machineScope, err := scope.NewMachineScope(r.Client, cluster, machine, stackitCluster, stackitMachine) - if err != nil { - return ctrl.Result{}, fmt.Errorf("create machine scope: %w", err) - } - defer func() { - if patchErr := machineScope.PatchObject(ctx); patchErr != nil && err == nil { - err = patchErr - } - }() - - if paused, message := reconciliationPaused(cluster, stackitMachine); paused { - setPausedCondition(&stackitMachine.Status.Conditions, stackitMachine.Generation, true, message) - return ctrl.Result{}, nil - } - setPausedCondition(&stackitMachine.Status.Conditions, stackitMachine.Generation, false, "") - - if !stackitMachine.DeletionTimestamp.IsZero() { - return ctrl.Result{}, r.reconcileDelete(ctx, machineScope) - } - return r.reconcileNormal(ctx, machineScope) -} - -func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, s *scope.MachineScope) (ctrl.Result, error) { - log := logf.FromContext(ctx) - sm := s.StackitMachine - - if !controllerutil.ContainsFinalizer(sm, infrav1.MachineFinalizer) { - controllerutil.AddFinalizer(sm, infrav1.MachineFinalizer) - } - - if !s.StackitCluster.Status.Ready { - ctrlhlp.SetConditions( - &sm.Status.Conditions, - sm.Generation, - metav1.ConditionFalse, - "InfrastructureNotReady", - "waiting for StackitCluster to be ready", - infrav1.MachineReadyCondition, - ) - return ctrl.Result{}, nil - } - if err := validateMachineAvailabilityZone(s); err != nil { - ctrlhlp.SetConditions( - &sm.Status.Conditions, - sm.Generation, - metav1.ConditionFalse, - "InvalidFailureDomain", - err.Error(), - infrav1.MachineInstanceReadyCondition, - infrav1.MachineReadyCondition, - ) - return ctrl.Result{}, nil - } - - bootstrapData, condStatus, reason, msg := r.fetchBootstrapData(ctx, s.Machine) - ctrlhlp.SetConditions(&sm.Status.Conditions, sm.Generation, condStatus, reason, msg, infrav1.MachineBootstrapReadyCondition) - if condStatus != metav1.ConditionTrue { - ctrlhlp.SetConditions(&sm.Status.Conditions, sm.Generation, metav1.ConditionFalse, reason, msg, infrav1.MachineReadyCondition) - // Per spec 13.3, missing/not-found cases requeue; invalid does not. - if reason == util.BootstrapReasonInvalid { - return ctrl.Result{}, nil - } - return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, nil - } - - cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, s.StackitCluster) - if err != nil { - return ctrlhlp.CredentialFailureResult( - &sm.Status.Conditions, - sm.Generation, - err, - infrav1.MachineCredentialsReadyCondition, - infrav1.MachineReadyCondition, - ) - } - ctrlhlp.SetConditions( - &sm.Status.Conditions, - sm.Generation, - metav1.ConditionTrue, - "Available", - "", - infrav1.MachineCredentialsReadyCondition, - ) - - tags := util.MachineTags( - s.Cluster.Name, - s.Cluster.Namespace, - s.Machine.Name, - string(s.Machine.UID), - s.StackitMachine.Spec.AdditionalLabels, - ) - - server, err := r.ensureServer(ctx, cloudClient, s, bootstrapData, tags) - if err != nil { - return ctrlhlp.CloudFailureResult( - &sm.Status.Conditions, - sm.Generation, - "InstanceError", - err, - retryableErrorRequeueAfter, - true, - infrav1.MachineInstanceReadyCondition, - infrav1.MachineReadyCondition, - ) - } - - sm.Status.InstanceID = server.ID - sm.Status.InstanceState = server.State - sm.Status.Addresses = machineAddressesFromCloud(server.Addresses) - - providerID := cloud.NewProviderID(s.StackitCluster.Spec.ProjectID, s.StackitCluster.Spec.Region, server.ID) - sm.Spec.ProviderID = &providerID - sm.Status.ProviderID = providerID - sm.Status.Initialization.Provisioned = true - - if server.State != "" && server.State != "ACTIVE" { - sm.Status.Ready = false - ctrlhlp.SetConditions( - &sm.Status.Conditions, - sm.Generation, - metav1.ConditionFalse, - "Provisioning", - fmt.Sprintf("server state is %s", server.State), - infrav1.MachineInstanceReadyCondition, - infrav1.MachineReadyCondition, - ) - return ctrl.Result{RequeueAfter: 15 * time.Second}, nil - } - - if err := r.reconcileBastionNodeSSHAccess(ctx, cloudClient, s, server); err != nil { - return ctrlhlp.CloudFailureResult( - &sm.Status.Conditions, - sm.Generation, - "BastionSSHAccessError", - err, - retryableErrorRequeueAfter, - true, - infrav1.MachineReadyCondition, - ) - } - - if err := r.reconcileAPIServerLoadBalancerTarget(ctx, cloudClient, s, server); err != nil { - return ctrlhlp.CloudFailureResult( - &sm.Status.Conditions, - sm.Generation, - "LoadBalancerTargetError", - err, - retryableErrorRequeueAfter, - true, - infrav1.MachineReadyCondition, - ) - } - - sm.Status.Ready = true - - ctrlhlp.SetConditions( - &sm.Status.Conditions, - sm.Generation, - metav1.ConditionTrue, - "Available", - "", - infrav1.MachineInstanceReadyCondition, - infrav1.MachineReadyCondition, - ) - log.V(1).Info("StackitMachine ready", "providerID", providerID) - return ctrl.Result{}, nil -} - -func validateMachineAvailabilityZone(s *scope.MachineScope) error { - availabilityZone := s.StackitMachine.Spec.AvailabilityZone - if availabilityZone == "" || len(s.StackitCluster.Status.FailureDomains) == 0 { - return nil - } - for _, failureDomain := range s.StackitCluster.Status.FailureDomains { - if failureDomain.Name == availabilityZone { - return nil - } - } - return fmt.Errorf("availabilityZone %q is not published in StackitCluster status.failureDomains", availabilityZone) -} - -func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, s *scope.MachineScope) error { - sm := s.StackitMachine - needsLoadBalancerCleanup := isControlPlaneMachine(s.Machine) && - s.StackitCluster.Spec.APIServerLoadBalancer.Enabled && - s.StackitCluster.Status.APIServerLoadBalancerID != "" - if sm.Status.InstanceID == "" && !needsLoadBalancerCleanup { - controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) - return nil - } - cloudClient, err := ctrlhlp.BuildCloudClient(ctx, r.Client, r.CloudClientFactory, s.StackitCluster) - if err != nil { - _, resultErr := ctrlhlp.CredentialFailureResult( - &sm.Status.Conditions, - sm.Generation, - err, - infrav1.MachineCredentialsReadyCondition, - ) - return resultErr - } - if err := r.deleteAPIServerLoadBalancerTarget(ctx, cloudClient, s); err != nil { - return err - } - if sm.Status.InstanceID == "" { - controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) - return nil - } - if err := cloudClient.DeleteServer(ctx, sm.Status.InstanceID); err != nil && !cloud.IsNotFound(err) { - return err - } - sm.Status.InstanceID = "" - sm.Status.InstanceState = "" - controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) - return nil -} - -func (r *StackitMachineReconciler) fetchBootstrapData(ctx context.Context, machine *clusterv1.Machine) ([]byte, metav1.ConditionStatus, string, string) { - if machine.Spec.Bootstrap.DataSecretName == nil || *machine.Spec.Bootstrap.DataSecretName == "" { - return nil, metav1.ConditionFalse, "BootstrapDataSecretMissing", "Machine.spec.bootstrap.dataSecretName is empty" - } - secret := &corev1.Secret{} - key := types.NamespacedName{Namespace: machine.Namespace, Name: *machine.Spec.Bootstrap.DataSecretName} - if err := r.Get(ctx, key, secret); err != nil { - if apierrors.IsNotFound(err) { - return nil, metav1.ConditionFalse, "BootstrapDataSecretNotFound", fmt.Sprintf("bootstrap secret %s not found", key) - } - return nil, metav1.ConditionFalse, "BootstrapDataSecretError", err.Error() - } - data, err := util.ExtractBootstrapData(secret) - if err != nil { - return nil, metav1.ConditionFalse, util.BootstrapReasonInvalid, err.Error() - } - return data, metav1.ConditionTrue, "Available", "" -} - -func (r *StackitMachineReconciler) ensureServer( - ctx context.Context, - c cloud.Client, - s *scope.MachineScope, - userData []byte, - tags map[string]string, -) (*cloud.Server, error) { - sm := s.StackitMachine - if sm.Status.InstanceID != "" { - server, err := c.GetServer(ctx, sm.Status.InstanceID) - if err == nil { - return server, nil - } - if !cloud.IsNotFound(err) { - return nil, err - } - // fall through to lookup-by-tags / re-create. - } - if server, err := c.FindServerByTags(ctx, tags); err == nil { - return server, nil - } else if !cloud.IsNotFound(err) { - return nil, err - } - deleteOnTermination := true - if sm.Spec.RootVolume.DeleteOnTermination != nil { - deleteOnTermination = *sm.Spec.RootVolume.DeleteOnTermination - } - return c.CreateServer(ctx, cloud.CreateServerInput{ - Name: sm.Name, - ProjectID: s.StackitCluster.Spec.ProjectID, - Region: s.StackitCluster.Spec.Region, - ImageID: sm.Spec.ImageID, - MachineType: sm.Spec.MachineType, - AvailabilityZone: sm.Spec.AvailabilityZone, - SSHKeyName: sm.Spec.SSHKeyName, - NetworkID: sm.Spec.Network.ID, - SecurityGroups: sm.Spec.SecurityGroups, - UserData: userData, - Tags: tags, - RootVolume: cloud.RootVolumeInput{ - SizeGiB: sm.Spec.RootVolume.SizeGiB, - PerformanceClass: sm.Spec.RootVolume.PerformanceClass, - DeleteOnTermination: deleteOnTermination, - }, - }) -} - -func (r *StackitMachineReconciler) reconcileBastionNodeSSHAccess( - ctx context.Context, - c cloud.Client, - s *scope.MachineScope, - server *cloud.Server, -) error { - if !s.StackitCluster.Spec.Bastion.Enabled { - return nil - } - if s.StackitCluster.Status.Bastion.SecurityGroupID == "" { - return fmt.Errorf("%w: bastion security group ID is empty", cloud.ErrTransient) - } - if server == nil || server.ID == "" { - return fmt.Errorf("%w: server ID is empty", cloud.ErrTransient) - } - _, err := c.EnsureNodeSSHAccess(ctx, cloud.NodeSSHAccessInput{ - Name: s.StackitCluster.Name + "-node-ssh", - ServerID: server.ID, - BastionSecurityGroupID: s.StackitCluster.Status.Bastion.SecurityGroupID, - Tags: ctrlhlp.NodeSSHAccessTags(s.StackitCluster), - }) - return err -} - -func (r *StackitMachineReconciler) reconcileAPIServerLoadBalancerTarget( - ctx context.Context, - c cloud.Client, - s *scope.MachineScope, - server *cloud.Server, -) error { - if !isControlPlaneMachine(s.Machine) || !s.StackitCluster.Spec.APIServerLoadBalancer.Enabled { - return nil - } - loadBalancerID, err := ctrlhlp.EnsureAPIServerLoadBalancerForMachine( - ctx, - c, - s.StackitCluster, - s.Machine.Name, - server.Addresses, - ) - if err != nil { - return err - } - - target, err := ctrlhlp.APIServerLoadBalancerTargetForMachine(s.Machine.Name, server.Addresses) - if err != nil { - return err - } - target.LoadBalancerID = loadBalancerID - return c.EnsureAPIServerLoadBalancerTarget(ctx, target) -} - -func (r *StackitMachineReconciler) deleteAPIServerLoadBalancerTarget(ctx context.Context, c cloud.Client, s *scope.MachineScope) error { - if !isControlPlaneMachine(s.Machine) || !s.StackitCluster.Spec.APIServerLoadBalancer.Enabled { - return nil - } - loadBalancerID, err := ctrlhlp.ResolveAPIServerLoadBalancerID(ctx, c, s.StackitCluster) - if err != nil { - return err - } - if loadBalancerID == "" { - return nil - } - err = c.DeleteAPIServerLoadBalancerTarget(ctx, cloud.LoadBalancerTargetInput{ - LoadBalancerID: loadBalancerID, - Name: s.Machine.Name, - Port: defaultAPIServerPort, - }) - if cloud.IsNotFound(err) { - return nil - } - return err -} - -func (r *StackitMachineReconciler) getStackitCluster(ctx context.Context, cluster *clusterv1.Cluster) (*infrav1.StackitCluster, error) { - if cluster.Spec.InfrastructureRef.Name == "" { - return nil, nil - } - stackitCluster := &infrav1.StackitCluster{} - key := types.NamespacedName{Namespace: cluster.Namespace, Name: cluster.Spec.InfrastructureRef.Name} - if err := r.Get(ctx, key, stackitCluster); err != nil { - if apierrors.IsNotFound(err) { - return nil, nil - } - return nil, fmt.Errorf("get StackitCluster %s: %w", key, err) - } - return stackitCluster, nil -} - -func machineAddressesFromCloud(in []cloud.Address) []clusterv1.MachineAddress { - if len(in) == 0 { - return nil - } - - out := make([]clusterv1.MachineAddress, len(in)) - for i, address := range in { - out[i] = clusterv1.MachineAddress{ - Type: clusterv1.MachineAddressType(address.Type), - Address: address.Address, - } - } - return out -} - -func isControlPlaneMachine(machine *clusterv1.Machine) bool { - if machine == nil { - return false - } - _, ok := machine.Labels[clusterv1.MachineControlPlaneLabel] - return ok -} - -func (r *StackitMachineReconciler) stackitMachineRequestsForMachine(_ context.Context, obj client.Object) []reconcile.Request { - machine, ok := obj.(*clusterv1.Machine) - if !ok { - return nil - } - return stackitMachineRequestForMachine(machine) -} - -func (r *StackitMachineReconciler) stackitMachineRequestsForStackitCluster(ctx context.Context, obj client.Object) []reconcile.Request { - stackitCluster, ok := obj.(*infrav1.StackitCluster) - if !ok { - return nil - } - - machines := &clusterv1.MachineList{} - if err := r.List(ctx, machines, client.InNamespace(stackitCluster.Namespace)); err != nil { - logf.FromContext(ctx).Error(err, "Failed to list Machines for StackitCluster watch", "stackitCluster", client.ObjectKeyFromObject(stackitCluster)) - return nil - } - - return stackitMachineRequestsForMachines(machines.Items, func(machine clusterv1.Machine) bool { - return machine.Spec.ClusterName == stackitCluster.Name - }) -} - -func (r *StackitMachineReconciler) stackitMachineRequestsForBootstrapSecret(ctx context.Context, obj client.Object) []reconcile.Request { - secret, ok := obj.(*corev1.Secret) - if !ok { - return nil - } - - machines := &clusterv1.MachineList{} - if err := r.List(ctx, machines, client.InNamespace(secret.Namespace)); err != nil { - logf.FromContext(ctx).Error(err, "Failed to list Machines for bootstrap Secret watch", "secret", client.ObjectKeyFromObject(secret)) - return nil - } - - return stackitMachineRequestsForMachines(machines.Items, func(machine clusterv1.Machine) bool { - return machine.Spec.Bootstrap.DataSecretName != nil && - *machine.Spec.Bootstrap.DataSecretName == secret.Name - }) -} - -func stackitMachineRequestsForMachines(machines []clusterv1.Machine, matches func(clusterv1.Machine) bool) []reconcile.Request { - requests := make([]reconcile.Request, 0, len(machines)) - seen := map[types.NamespacedName]struct{}{} - for _, machine := range machines { - if !matches(machine) { - continue - } - for _, request := range stackitMachineRequestForMachine(&machine) { - if _, ok := seen[request.NamespacedName]; ok { - continue - } - seen[request.NamespacedName] = struct{}{} - requests = append(requests, request) - } - } - return requests -} - -func stackitMachineRequestForMachine(machine *clusterv1.Machine) []reconcile.Request { - if machine == nil { - return nil - } - ref := machine.Spec.InfrastructureRef - if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitMachine" || ref.Name == "" { - return nil - } - return []reconcile.Request{{ - NamespacedName: types.NamespacedName{ - Namespace: machine.Namespace, - Name: ref.Name, - }, - }} -} - -// SetupWithManager registers the controller with the manager. -func (r *StackitMachineReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&infrav1.StackitMachine{}). - Watches(&clusterv1.Machine{}, handler.EnqueueRequestsFromMapFunc(r.stackitMachineRequestsForMachine)). - Watches(&infrav1.StackitCluster{}, handler.EnqueueRequestsFromMapFunc(r.stackitMachineRequestsForStackitCluster)). - Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.stackitMachineRequestsForBootstrapSecret)). - Named("stackitmachine"). - Complete(r) -} diff --git a/pkg/util/conditions.go b/pkg/util/conditions.go deleted file mode 100644 index 6b7c695..0000000 --- a/pkg/util/conditions.go +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 -*/ - -package util - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// SetCondition adds or updates a condition on conditions. -// -// The LastTransitionTime is only refreshed when the status actually changes, -// so callers can call this unconditionally on every reconcile. -func SetCondition( - conditions *[]metav1.Condition, - condType string, - status metav1.ConditionStatus, - reason, message string, - observedGeneration ...int64, -) { - now := metav1.Now() - var generation int64 - if len(observedGeneration) > 0 { - generation = observedGeneration[0] - } - for i, c := range *conditions { - if c.Type != condType { - continue - } - if c.Status == status && c.Reason == reason && c.Message == message && c.ObservedGeneration == generation { - return - } - (*conditions)[i].Status = status - (*conditions)[i].Reason = reason - (*conditions)[i].Message = message - (*conditions)[i].ObservedGeneration = generation - if c.Status != status { - (*conditions)[i].LastTransitionTime = now - } - return - } - *conditions = append(*conditions, metav1.Condition{ - Type: condType, - Status: status, - Reason: reason, - Message: message, - ObservedGeneration: generation, - LastTransitionTime: now, - }) -} diff --git a/pkg/scope/cluster_scope.go b/scope/cluster_scope.go similarity index 54% rename from pkg/scope/cluster_scope.go rename to scope/cluster_scope.go index 74f7b1f..772499a 100644 --- a/pkg/scope/cluster_scope.go +++ b/scope/cluster_scope.go @@ -16,12 +16,15 @@ package scope import ( "context" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" "sigs.k8s.io/cluster-api/util/patch" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) // ClusterScope holds the per-reconcile state for a StackitCluster. @@ -62,3 +65,37 @@ func (s *ClusterScope) PatchObject(ctx context.Context) error { clusterv1.PausedCondition, }}) } + +func (s *ClusterScope) SetConditions(status metav1.ConditionStatus, reason, message string, conditionTypes ...string) { + util.SetConditions(&s.StackitCluster.Status.Conditions, s.StackitCluster.Generation, status, reason, message, conditionTypes...) +} + +func (s *ClusterScope) SetReady() { + s.StackitCluster.Status.Ready = true + s.StackitCluster.Status.Initialization.Provisioned = true + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ClusterReadyCondition) +} + +func (s *ClusterScope) SetNotReady(reason, message string, conditionTypes ...string) { + s.StackitCluster.Status.Ready = false + s.SetConditions(metav1.ConditionFalse, reason, message, conditionTypes...) +} + +func (s *ClusterScope) SetAPIServerEndpoint(endpoint clusterv1.APIEndpoint) { + s.StackitCluster.Spec.ControlPlaneEndpoint = endpoint + s.StackitCluster.Status.APIServerEndpoint = endpoint +} + +func (s *ClusterScope) SetBastionStatus(bastion *cloud.Bastion, cloudInitHash string) { + s.StackitCluster.Status.Bastion = infrav1.StackitBastionStatus{ + ServerID: bastion.ServerID, + PublicIPID: bastion.PublicIPID, + PublicIP: bastion.PublicIP, + SecurityGroupID: bastion.SecurityGroupID, + CloudInitHash: cloudInitHash, + } +} + +func (s *ClusterScope) ClearBastionStatus() { + s.StackitCluster.Status.Bastion = infrav1.StackitBastionStatus{} +} diff --git a/pkg/scope/machine_scope.go b/scope/machine_scope.go similarity index 53% rename from pkg/scope/machine_scope.go rename to scope/machine_scope.go index 6181867..0e982e7 100644 --- a/pkg/scope/machine_scope.go +++ b/scope/machine_scope.go @@ -13,12 +13,15 @@ package scope import ( "context" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" "sigs.k8s.io/cluster-api/util/patch" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) // MachineScope holds the per-reconcile state for a StackitMachine. @@ -65,3 +68,42 @@ func (s *MachineScope) PatchObject(ctx context.Context) error { clusterv1.PausedCondition, }}) } + +func (s *MachineScope) SetConditions(status metav1.ConditionStatus, reason, message string, conditionTypes ...string) { + util.SetConditions(&s.StackitMachine.Status.Conditions, s.StackitMachine.Generation, status, reason, message, conditionTypes...) +} + +func (s *MachineScope) SetReady() { + s.StackitMachine.Status.Ready = true + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.MachineInstanceReadyCondition, infrav1.MachineReadyCondition) +} + +func (s *MachineScope) SetNotReady(reason, message string, conditionTypes ...string) { + s.StackitMachine.Status.Ready = false + s.SetConditions(metav1.ConditionFalse, reason, message, conditionTypes...) +} + +func (s *MachineScope) Tags() map[string]string { + return util.MachineTags( + s.Cluster.Name, + s.Cluster.Namespace, + s.Machine.Name, + string(s.Machine.UID), + s.StackitMachine.Spec.AdditionalLabels, + ) +} + +func (s *MachineScope) SetInstance(server *cloud.Server) string { + providerID := cloud.NewProviderID(s.StackitCluster.Spec.ProjectID, s.StackitCluster.Spec.Region, server.ID) + s.StackitMachine.Status.InstanceID = server.ID + s.StackitMachine.Status.InstanceState = server.State + s.StackitMachine.Spec.ProviderID = &providerID + s.StackitMachine.Status.ProviderID = providerID + s.StackitMachine.Status.Initialization.Provisioned = true + return providerID +} + +func (s *MachineScope) ClearInstance() { + s.StackitMachine.Status.InstanceID = "" + s.StackitMachine.Status.InstanceState = "" +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index d4fc2df..1c1c326 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -33,9 +33,9 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" "github.com/stackitcloud/cluster-api-provider-stackit/test/utils" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) // namespace where the project is deployed in diff --git a/tilt-provider.yaml b/tilt-provider.yaml index 9fcf9e4..0b2ce75 100644 --- a/tilt-provider.yaml +++ b/tilt-provider.yaml @@ -7,7 +7,9 @@ config: - go.mod - go.sum - api + - cloud - cmd - - internal - - pkg + - controller + - util + - webhook go_main: cmd/manager/main.go diff --git a/pkg/util/bootstrap.go b/util/bootstrap.go similarity index 100% rename from pkg/util/bootstrap.go rename to util/bootstrap.go diff --git a/pkg/util/bootstrap_test.go b/util/bootstrap_test.go similarity index 100% rename from pkg/util/bootstrap_test.go rename to util/bootstrap_test.go diff --git a/util/conditions.go b/util/conditions.go new file mode 100644 index 0000000..858e4f4 --- /dev/null +++ b/util/conditions.go @@ -0,0 +1,114 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package util + +import ( + "errors" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" +) + +// SetCondition adds or updates a condition on conditions. +// +// The LastTransitionTime is only refreshed when the status actually changes, +// so callers can call this unconditionally on every reconcile. +func SetCondition( + conditions *[]metav1.Condition, + condType string, + status metav1.ConditionStatus, + reason, message string, + observedGeneration ...int64, +) { + now := metav1.Now() + var generation int64 + if len(observedGeneration) > 0 { + generation = observedGeneration[0] + } + for i, c := range *conditions { + if c.Type != condType { + continue + } + if c.Status == status && c.Reason == reason && c.Message == message && c.ObservedGeneration == generation { + return + } + (*conditions)[i].Status = status + (*conditions)[i].Reason = reason + (*conditions)[i].Message = message + (*conditions)[i].ObservedGeneration = generation + if c.Status != status { + (*conditions)[i].LastTransitionTime = now + } + return + } + *conditions = append(*conditions, metav1.Condition{ + Type: condType, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: generation, + LastTransitionTime: now, + }) +} + +func SetConditions( + conditions *[]metav1.Condition, + generation int64, + status metav1.ConditionStatus, + reason, message string, + conditionTypes ...string, +) { + for _, conditionType := range conditionTypes { + meta.SetStatusCondition(conditions, metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: generation, + }) + } +} + +func CredentialFailureResult( + conditions *[]metav1.Condition, + generation int64, + err error, + conditionTypes ...string, +) (ctrl.Result, error) { + SetConditions(conditions, generation, metav1.ConditionFalse, "CredentialsInvalid", err.Error(), conditionTypes...) + if cloud.IsUnauthorized(err) || cloud.IsInvalidInput(err) || errors.Is(err, ErrCredentialsInvalid) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err +} + +func CloudFailureResult( + conditions *[]metav1.Condition, + generation int64, + reason string, + err error, + requeueAfter time.Duration, + returnError bool, + conditionTypes ...string, +) (ctrl.Result, error) { + SetConditions(conditions, generation, metav1.ConditionFalse, reason, err.Error(), conditionTypes...) + if cloud.IsRetryable(err) { + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + if returnError { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} diff --git a/pkg/util/credentials.go b/util/credentials.go similarity index 96% rename from pkg/util/credentials.go rename to util/credentials.go index f29af67..87be3d7 100644 --- a/pkg/util/credentials.go +++ b/util/credentials.go @@ -16,7 +16,7 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" ) // Keys expected in the credentials Secret. The format mirrors the existing diff --git a/pkg/util/credentials_test.go b/util/credentials_test.go similarity index 100% rename from pkg/util/credentials_test.go rename to util/credentials_test.go diff --git a/internal/controller/helpers/cloud_client.go b/util/reconcile.go similarity index 50% rename from internal/controller/helpers/cloud_client.go rename to util/reconcile.go index af5e35b..6a8b4b4 100644 --- a/internal/controller/helpers/cloud_client.go +++ b/util/reconcile.go @@ -8,22 +8,55 @@ You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ -package helpers +package util import ( "context" "errors" "fmt" + "strings" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/util/annotations" "sigs.k8s.io/controller-runtime/pkg/client" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/util" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" ) +func ReconciliationPaused(cluster *clusterv1.Cluster, obj client.Object) (bool, string) { + if cluster == nil { + if annotations.HasPaused(obj) { + return true, "object has the cluster.x-k8s.io/paused annotation" + } + return false, "" + } + + var reasons []string + if ptr.Deref(cluster.Spec.Paused, false) { + reasons = append(reasons, "Cluster spec.paused is set to true") + } + if annotations.HasPaused(obj) { + reasons = append(reasons, "object has the cluster.x-k8s.io/paused annotation") + } + if len(reasons) == 0 { + return false, "" + } + return true, strings.Join(reasons, ", ") +} + +func SetPausedCondition(conditions *[]metav1.Condition, generation int64, paused bool, message string) { + if paused { + SetConditions(conditions, generation, metav1.ConditionTrue, clusterv1.PausedReason, message, clusterv1.PausedCondition) + return + } + SetConditions(conditions, generation, metav1.ConditionFalse, clusterv1.NotPausedReason, "", clusterv1.PausedCondition) +} + func BuildCloudClient( ctx context.Context, k8sClient client.Client, @@ -40,7 +73,7 @@ func BuildCloudClient( return nil, fmt.Errorf("get credentials secret %s: %w", key, err) } - creds, err := util.ParseCredentialsSecret(secret, stackitCluster.Spec.ProjectID, stackitCluster.Spec.Region) + creds, err := ParseCredentialsSecret(secret, stackitCluster.Spec.ProjectID, stackitCluster.Spec.Region) if err != nil { return nil, err } diff --git a/pkg/util/tags.go b/util/tags.go similarity index 100% rename from pkg/util/tags.go rename to util/tags.go diff --git a/pkg/util/tags_test.go b/util/tags_test.go similarity index 100% rename from pkg/util/tags_test.go rename to util/tags_test.go diff --git a/internal/webhook/v1alpha1/stackitcluster_webhook.go b/webhook/v1alpha1/stackitcluster_webhook.go similarity index 100% rename from internal/webhook/v1alpha1/stackitcluster_webhook.go rename to webhook/v1alpha1/stackitcluster_webhook.go diff --git a/internal/webhook/v1alpha1/stackitclustertemplate_webhook.go b/webhook/v1alpha1/stackitclustertemplate_webhook.go similarity index 100% rename from internal/webhook/v1alpha1/stackitclustertemplate_webhook.go rename to webhook/v1alpha1/stackitclustertemplate_webhook.go diff --git a/internal/webhook/v1alpha1/stackitmachine_webhook.go b/webhook/v1alpha1/stackitmachine_webhook.go similarity index 100% rename from internal/webhook/v1alpha1/stackitmachine_webhook.go rename to webhook/v1alpha1/stackitmachine_webhook.go diff --git a/internal/webhook/v1alpha1/stackitmachinetemplate_webhook.go b/webhook/v1alpha1/stackitmachinetemplate_webhook.go similarity index 100% rename from internal/webhook/v1alpha1/stackitmachinetemplate_webhook.go rename to webhook/v1alpha1/stackitmachinetemplate_webhook.go diff --git a/internal/webhook/v1alpha1/validation_helpers.go b/webhook/v1alpha1/validation_helpers.go similarity index 99% rename from internal/webhook/v1alpha1/validation_helpers.go rename to webhook/v1alpha1/validation_helpers.go index 3d7eb75..d44d3f8 100644 --- a/internal/webhook/v1alpha1/validation_helpers.go +++ b/webhook/v1alpha1/validation_helpers.go @@ -31,7 +31,7 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" - "github.com/stackitcloud/cluster-api-provider-stackit/pkg/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" ) const ( diff --git a/internal/webhook/v1alpha1/webhook_suite_test.go b/webhook/v1alpha1/webhook_suite_test.go similarity index 95% rename from internal/webhook/v1alpha1/webhook_suite_test.go rename to webhook/v1alpha1/webhook_suite_test.go index 5d431fa..fe2d6cc 100644 --- a/internal/webhook/v1alpha1/webhook_suite_test.go +++ b/webhook/v1alpha1/webhook_suite_test.go @@ -73,11 +73,11 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: false, WebhookInstallOptions: envtest.WebhookInstallOptions{ - Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, + Paths: []string{filepath.Join("..", "..", "config", "webhook")}, }, } @@ -159,7 +159,7 @@ var _ = AfterSuite(func() { // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are // properly set up, run 'make setup-envtest' beforehand. func getFirstFoundEnvTestBinaryDir() string { - basePath := filepath.Join("..", "..", "..", "bin", "k8s") + basePath := filepath.Join("..", "..", "bin", "k8s") entries, err := os.ReadDir(basePath) if err != nil { logf.Log.Error(err, "Failed to read directory", "path", basePath) diff --git a/internal/webhook/v1alpha1/webhook_validation_test.go b/webhook/v1alpha1/webhook_validation_test.go similarity index 100% rename from internal/webhook/v1alpha1/webhook_validation_test.go rename to webhook/v1alpha1/webhook_validation_test.go From 2d3b4dc7421702c748c5c5df07c146de25e4737d Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Thu, 16 Jul 2026 10:34:41 +0200 Subject: [PATCH 4/9] ci: run on push only for main Signed-off-by: Jan Larwig --- .github/workflows/lint.yml | 2 ++ .github/workflows/test.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d3f5b7b..06b19df 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,8 @@ name: Lint on: push: + branches: + - main pull_request: permissions: {} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27e82c8..7ff5508 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,8 @@ name: Tests on: push: + branches: + - main pull_request: permissions: {} From ba2a21fe960f3520e1b6e60ae04d84890887c38a Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Thu, 16 Jul 2026 10:39:34 +0200 Subject: [PATCH 5/9] docs: fix image reference Signed-off-by: Jan Larwig --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2192e8d..6effa5c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Kubernetes Cluster API Provider STACKIT (CAPSTK)

-STACKIT - A Brand By Schwarz Digits +STACKIT - A Brand By Schwarz Digits

From 40f2bed990e3740b7d16f43aba0012390d17b0fa Mon Sep 17 00:00:00 2001 From: Alexander Mai Date: Tue, 4 Aug 2026 15:24:52 +0000 Subject: [PATCH 6/9] Add clusterctl/stackit CLI to devcontainer, persist dev state, update quick-start docs - .devcontainer/devcontainer.json: add named volumes for bash history (/commandhistory) and docker-in-docker state (/var/lib/docker) so both survive container rebuilds; set HISTFILE accordingly - .devcontainer/post-install.sh: install clusterctl and the stackit CLI (with bash completions), flush bash history after every command via PROMPT_COMMAND, verify both tools at the end of setup - .gitignore: ignore local dev/debug artifacts (.DS_Store, cluster.yaml, kind-config.yaml, devcontainer-lock.json) - docs/src/quick-start.md: document the .stackit/ service-account key convention, add the STACKIT_SSH_KEY_NAME env var (used by the bastion cluster template), add an optional kind-config.yaml section for enterprise TLS-intercepting proxies (e.g. Zscaler), and write the generated cluster manifest to a file before applying it --- .devcontainer/devcontainer.json | 7 ++++- .devcontainer/post-install.sh | 49 +++++++++++++++++++++++++++++++++ .gitignore | 7 +++++ docs/src/quick-start.md | 41 +++++++++++++++++++++++---- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a96838b..cec62a7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,6 +13,10 @@ }, "runArgs": ["--privileged", "--init"], + "mounts": [ + "source=${localWorkspaceFolderBasename}-bashhistory,target=/commandhistory,type=volume", + "source=${localWorkspaceFolderBasename}-docker,target=/var/lib/docker,type=volume" + ], "customizations": { "vscode": { @@ -27,7 +31,8 @@ }, "remoteEnv": { - "GO111MODULE": "on" + "GO111MODULE": "on", + "HISTFILE": "/commandhistory/.bash_history" }, "onCreateCommand": "bash .devcontainer/post-install.sh" diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh index 6d75a49..44d3619 100644 --- a/.devcontainer/post-install.sh +++ b/.devcontainer/post-install.sh @@ -42,6 +42,15 @@ if ! grep -q "source /usr/share/bash-completion/bash_completion" ~/.bashrc 2>/de echo "Added bash-completion to .bashrc" fi +# Persist bash history on the /commandhistory volume (HISTFILE is set via +# devcontainer.json remoteEnv) and flush it after every command instead of +# only on a clean shell exit. +mkdir -p /commandhistory +if ! grep -q "PROMPT_COMMAND='history -a'" ~/.bashrc 2>/dev/null; then + echo "PROMPT_COMMAND='history -a'" >> ~/.bashrc + echo "Added persistent bash history to .bashrc" +fi + echo "" echo "------------------------------------" echo "Installing development tools..." @@ -99,6 +108,44 @@ if command -v kubectl &> /dev/null; then fi fi +# Install clusterctl +if ! command -v clusterctl &> /dev/null; then + echo "Installing clusterctl..." + curl -Lo /usr/local/bin/clusterctl "https://github.com/kubernetes-sigs/cluster-api/releases/latest/download/clusterctl-linux-${ARCH}" + chmod +x /usr/local/bin/clusterctl + echo "clusterctl installed successfully" +fi + +# Generate clusterctl bash completion +if command -v clusterctl &> /dev/null; then + if clusterctl completion bash > "${BASH_COMPLETIONS_DIR}/clusterctl" 2>/dev/null; then + echo "clusterctl completion installed" + else + echo "WARNING: Failed to generate clusterctl completion" + fi +fi + +# Install stackit CLI +if ! command -v stackit &> /dev/null; then + echo "Installing stackit CLI..." + STACKIT_CLI_VERSION=$(curl -Ls https://api.github.com/repos/stackitcloud/stackit-cli/releases/latest | grep '"tag_name"' | cut -d '"' -f4 | sed 's/^v//') + curl -Lo /tmp/stackit-cli.tar.gz "https://github.com/stackitcloud/stackit-cli/releases/download/v${STACKIT_CLI_VERSION}/stackit-cli_${STACKIT_CLI_VERSION}_linux_${ARCH}.tar.gz" + tar -xzf /tmp/stackit-cli.tar.gz -C /tmp stackit + mv /tmp/stackit /usr/local/bin/stackit + chmod +x /usr/local/bin/stackit + rm -f /tmp/stackit-cli.tar.gz + echo "stackit CLI installed successfully" +fi + +# Generate stackit CLI bash completion +if command -v stackit &> /dev/null; then + if stackit completion bash > "${BASH_COMPLETIONS_DIR}/stackit" 2>/dev/null; then + echo "stackit completion installed" + else + echo "WARNING: Failed to generate stackit completion" + fi +fi + # Generate Docker bash completion if command -v docker &> /dev/null; then if docker completion bash > "${BASH_COMPLETIONS_DIR}/docker" 2>/dev/null; then @@ -142,6 +189,8 @@ echo "------------------------------------" kind version kubebuilder version kubectl version --client +clusterctl version +stackit --version docker --version go version diff --git a/.gitignore b/.gitignore index 8b3c953..e32a895 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,13 @@ .env .stackit +# Local dev/debug artifacts +.DS_Store +/cluster*.yaml +/kind-config.yaml +.devcontainer/devcontainer-lock.json +.ssh/ + # Binaries for programs and plugins *.exe *.exe~ diff --git a/docs/src/quick-start.md b/docs/src/quick-start.md index 5ab6892..9cf6e43 100644 --- a/docs/src/quick-start.md +++ b/docs/src/quick-start.md @@ -8,23 +8,53 @@ image, and machine type. Furthermore, make sure that the provided service-account has [an appropriate set of permissions](./topics/iam-permissions.md). +Place the downloaded service-account JSON key at `.stackit/cluster-api-provider-stackit.json` +inside the repo (create the `.stackit/` directory if it does not exist yet). It is listed in +`.gitignore`, so the key is never committed, and the path works identically for every +contributor regardless of where the repo is checked out. + ```sh export STACKIT_PROJECT_ID= export STACKIT_REGION=eu01 export STACKIT_NETWORK_ID= export STACKIT_IMAGE_ID= -export STACKIT_MACHINE_TYPE=c2i.2 +export STACKIT_MACHINE_TYPE=c2i.4 +export STACKIT_SSH_KEY_NAME="" export STACKIT_SERVICE_ACCOUNT_JSON_FILE=./.stackit/cluster-api-provider-stackit.json export STACKIT_SERVICE_ACCOUNT_JSON_B64="$(base64 < "${STACKIT_SERVICE_ACCOUNT_JSON_FILE}" | tr -d '\n')" export STACKIT_CLOUD_CONTROLLER_MANAGER_IMAGE=ghcr.io/stackitcloud/cloud-provider-stackit/cloud-controller-manager:v1.35.3 ``` -The default template does not configure SSH access. To use an SSH key, add -`sshKeyName: ` to the `StackitMachineTemplate` specs before creating -the workload cluster. +The default template (`templates/cluster-template.yaml`) does not configure SSH access. To use +an SSH key, add `sshKeyName: ` to the `StackitMachineTemplate` specs before creating +the workload cluster. If you use the bastion variant instead +(`templates/cluster-template-bastion.yaml`), set `STACKIT_SSH_KEY_NAME` above to your key name, +it is substituted into that template directly. ## Create the management cluster +### Optional: `kind-config.yaml` for enterprise proxies (e.g. Zscaler) + +If your machine sits behind a TLS-intercepting enterprise proxy such as Zscaler, outbound HTTPS +calls made from inside the `kind` node container (pulling images, talking to the STACKIT API, +etc.) will fail certificate validation. The node runs as its own container with its own OS +certificate store, which does not include the proxy's root CA even though your host already +trusts it. Mount the host's CA bundle into the node at the same path it expects: + +```yaml +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + extraMounts: + - hostPath: /etc/ssl/certs/ca-certificates.crt + containerPath: /etc/ssl/certs/ca-certificates.crt + readOnly: true +``` + +Save this as `kind-config.yaml` in the repo root before running `kind create cluster` below. If +you are not behind such a proxy, drop the `--config kind-config.yaml` flag from the command. + ```sh kind create cluster --name capi-stackit kubectl config use-context kind-capi-stackit @@ -68,7 +98,8 @@ clusterctl init \ clusterctl generate cluster "${CLUSTER_NAME}" \ --from templates/cluster-template.yaml \ --target-namespace "${NAMESPACE}" \ - | kubectl apply -f - + > cluster.yaml + kubectl apply -f cluster.yaml ``` Watch progress: From 4c4495eae43ec2214157855d31cff97f8dff9089 Mon Sep 17 00:00:00 2001 From: Alexander Mai Date: Thu, 6 Aug 2026 06:59:39 +0000 Subject: [PATCH 7/9] install Cilium CLI to DevContainer --- .devcontainer/post-install.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.devcontainer/post-install.sh b/.devcontainer/post-install.sh index 44d3619..3b02d31 100644 --- a/.devcontainer/post-install.sh +++ b/.devcontainer/post-install.sh @@ -125,6 +125,25 @@ if command -v clusterctl &> /dev/null; then fi fi +# Install Cilium CLI +if ! command -v cilium &> /dev/null; then + echo "Installing Cilium CLI..." + CILIUM_CLI_VERSION=$(curl -Ls https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt) + curl -Lo /tmp/cilium-linux-${ARCH}.tar.gz "https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-${ARCH}.tar.gz" + tar xzfC /tmp/cilium-linux-${ARCH}.tar.gz /usr/local/bin + rm -f /tmp/cilium-linux-${ARCH}.tar.gz + echo "Cilium CLI installed successfully" +fi + +# Generate Cilium CLI bash completion +if command -v cilium &> /dev/null; then + if cilium completion bash > "${BASH_COMPLETIONS_DIR}/cilium" 2>/dev/null; then + echo "Cilium CLI completion installed" + else + echo "WARNING: Failed to generate Cilium CLI completion" + fi +fi + # Install stackit CLI if ! command -v stackit &> /dev/null; then echo "Installing stackit CLI..." @@ -190,6 +209,7 @@ kind version kubebuilder version kubectl version --client clusterctl version +cilium version --client stackit --version docker --version go version From 5595b3194facee85cf9da17d87fd4486ed9c0269 Mon Sep 17 00:00:00 2001 From: Alexander Mai Date: Thu, 13 Aug 2026 12:20:21 +0200 Subject: [PATCH 8/9] fix bastion security-group handling and guard machine recreate --- cloud/sdk_client.go | 57 +++++-- cloud/sdk_client_test.go | 171 +++++++++++++++++++ controller/stackitmachine_controller_test.go | 34 ++++ controller/stackitmachine_infrastructure.go | 15 ++ 4 files changed, 266 insertions(+), 11 deletions(-) diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 53d7460..01c35c8 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -260,9 +260,11 @@ func (c *SDKClient) EnsureBastion(ctx context.Context, input BastionInput) (*Bas if err != nil { return nil, err } - if err := c.addSecurityGroupToServer(ctx, server.ID, securityGroup.ID); err != nil { - return nil, err - } + // The security group is already part of the CreateServer payload above. + // Attaching it again here used to fail with 404 while the server had no + // port yet, and with 400 "Duplicate items in the list" once it had one — + // and because the error aborted EnsureBastion, the public IP below was + // only assigned a reconcile later. publicIP, err := c.ensurePublicIP(ctx, input.Tags) if err != nil { @@ -698,10 +700,12 @@ func (c *SDKClient) ensureBastionSecurityGroupRules(ctx context.Context, securit return classifySDKError("list security group rules", err) } existingRules := resp.GetItems() + desired := make(map[string]struct{}, len(cidrs)) for _, cidr := range cidrs { if cidr == "" { return fmt.Errorf("%w: empty bastion allowed CIDR", ErrInvalidInput) } + desired[cidr] = struct{}{} if hasSSHRule(existingRules, cidr) { continue } @@ -716,6 +720,30 @@ func (c *SDKClient) ensureBastionSecurityGroupRules(ctx context.Context, securit return classifySDKError("create security group rule", err) } } + + // Revoke SSH rules for CIDRs that are no longer desired. Without this the + // rule set only ever grows, so narrowing allowedCIDRs would not actually + // take access away from the previously allowed range. + for _, rule := range existingRules { + if !isSSHRule(rule) { + continue + } + if _, keep := desired[rule.GetIpRange()]; keep { + continue + } + ruleID := rule.GetId() + if ruleID == "" { + continue + } + if err := c.iaasClient.DefaultAPI. + DeleteSecurityGroupRule(ctx, c.projectID, c.region, securityGroupID, ruleID). + Execute(); err != nil { + err := classifySDKError("delete security group rule", err) + if !IsNotFound(err) { + return err + } + } + } return nil } @@ -839,20 +867,27 @@ func (c *SDKClient) deleteSecurityGroupRules(ctx context.Context, securityGroupI func hasSSHRule(rules []iaas.SecurityGroupRule, cidr string) bool { for _, rule := range rules { - if rule.GetDirection() != "ingress" || rule.GetIpRange() != cidr { - continue - } - portRange := rule.GetPortRange() - if portRange.GetMin() != sshPort || portRange.GetMax() != sshPort { - continue - } - if protocol, ok := rule.GetProtocolOk(); ok && protocol.GetName() == tcpProtocol { + if rule.GetIpRange() == cidr && isSSHRule(rule) { return true } } return false } +// isSSHRule reports whether the rule is an ingress TCP/22 rule, regardless of +// which CIDR it allows. +func isSSHRule(rule iaas.SecurityGroupRule) bool { + if rule.GetDirection() != "ingress" { + return false + } + portRange := rule.GetPortRange() + if portRange.GetMin() != sshPort || portRange.GetMax() != sshPort { + return false + } + protocol, ok := rule.GetProtocolOk() + return ok && protocol.GetName() == tcpProtocol +} + func hasRemoteSecurityGroupSSHRule(rules []iaas.SecurityGroupRule, remoteSecurityGroupID string) bool { for _, rule := range rules { if rule.GetDirection() != "ingress" || rule.GetRemoteSecurityGroupId() != remoteSecurityGroupID { diff --git a/cloud/sdk_client_test.go b/cloud/sdk_client_test.go index 808141a..13045b7 100644 --- a/cloud/sdk_client_test.go +++ b/cloud/sdk_client_test.go @@ -431,3 +431,174 @@ func lookup(m map[string]any, key string) any { } return nil } + +// TestSDKClientEnsureBastionAttachesSecurityGroupOnlyOnce guards against the +// redundant security-group attach that used to follow CreateServer. The group +// is already part of the create payload; attaching it again failed with 404 +// while the server had no port yet and with 400 "Duplicate items in the list" +// once it had one — and the error aborted EnsureBastion before the public IP +// was assigned. +func TestSDKClientEnsureBastionAttachesSecurityGroupOnlyOnce(t *testing.T) { + var ( + createPayload map[string]any + attachCallCount int + publicIPAssigned bool + ) + server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasSuffix(path, "/security-groups"): + writeJSON(t, w, map[string]any{"items": []any{ + map[string]any{ + "id": testSDKSecurityGroup, "name": "bastion-ssh", + "labels": map[string]any{"cluster": "test"}, + }, + }}) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/rules"): + writeJSON(t, w, map[string]any{"items": []any{}}) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/rules"): + writeJSON(t, w, map[string]any{"id": "77777777-7777-4777-8777-777777777777", "direction": "ingress"}) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/servers"): + writeJSON(t, w, map[string]any{"items": []any{}}) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/servers"): + createPayload = readJSON(t, r) + writeJSON(t, w, map[string]any{ + "id": testSDKServerID, "name": "bastion", "status": "ACTIVE", "machineType": "c2i.1", + }) + case strings.Contains(path, "/security-groups/") && strings.Contains(path, "/servers/"): + // PUT /servers/{id}/security-groups/{id} or the inverse ordering: + // any call here is the redundant attach this test guards against. + attachCallCount++ + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/public-ips"): + writeJSON(t, w, map[string]any{"items": []any{}}) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/public-ips"): + writeJSON(t, w, map[string]any{"id": "66666666-6666-4666-8666-666666666666", "ip": "203.0.113.10"}) + case strings.Contains(path, "/public-ips/"): + publicIPAssigned = true + writeJSON(t, w, map[string]any{"id": "66666666-6666-4666-8666-666666666666", "ip": "203.0.113.10"}) + case r.Method == http.MethodGet && strings.Contains(path, "/servers/"): + writeJSON(t, w, map[string]any{ + "id": testSDKServerID, "name": "bastion", "status": "ACTIVE", "machineType": "c2i.1", + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + + client := newTestSDKClient(t, server.URL) + bastion, err := client.EnsureBastion(context.Background(), BastionInput{ + Name: "bastion", + ProjectID: testSDKProjectID, + Region: testSDKRegion, + NetworkID: testSDKNetworkID, + ImageID: testSDKImageID, + MachineType: "c2i.1", + SSHKeyName: "default", + AllowedCIDRs: []string{"203.0.113.0/24"}, + Tags: map[string]string{"cluster": "test"}, + }) + if err != nil { + t.Fatalf("EnsureBastion() error = %v", err) + } + + if attachCallCount != 0 { + t.Fatalf("security group attached %d extra time(s) after CreateServer, want 0", attachCallCount) + } + groups, _ := createPayload["securityGroups"].([]any) + if len(groups) != 1 || groups[0] != testSDKSecurityGroup { + t.Fatalf("create payload securityGroups = %v, want [%s]", createPayload["securityGroups"], testSDKSecurityGroup) + } + if !publicIPAssigned { + t.Fatal("public IP was never assigned to the bastion server") + } + if bastion.PublicIP != "203.0.113.10" { + t.Fatalf("bastion.PublicIP = %q, want 203.0.113.10", bastion.PublicIP) + } +} + +// TestSDKClientEnsureBastionRevokesRemovedCIDR guards against security-group +// rules only ever being added. Narrowing allowedCIDRs must actually take +// access away from the previously allowed range. +func TestSDKClientEnsureBastionRevokesRemovedCIDR(t *testing.T) { + const staleRuleID = "55555555-5555-4555-8555-555555555555" + var ( + createdRuleCIDRs []string + deletedRuleIDs []string + ) + server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasSuffix(path, "/security-groups"): + writeJSON(t, w, map[string]any{"items": []any{ + map[string]any{ + "id": testSDKSecurityGroup, "name": "bastion-ssh", + "labels": map[string]any{"cluster": "test"}, + }, + }}) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/rules"): + // One pre-existing SSH rule for the CIDR that is being revoked. + writeJSON(t, w, map[string]any{"items": []any{ + map[string]any{ + "id": staleRuleID, + "direction": "ingress", + "ipRange": "198.51.100.0/24", + "portRange": map[string]any{"min": 22, "max": 22}, + "protocol": map[string]any{"name": "tcp"}, + }, + }}) + case r.Method == http.MethodPost && strings.HasSuffix(path, "/rules"): + payload := readJSON(t, r) + if cidr, ok := payload["ipRange"].(string); ok { + createdRuleCIDRs = append(createdRuleCIDRs, cidr) + } + writeJSON(t, w, map[string]any{"id": "77777777-7777-4777-8777-777777777777", "direction": "ingress"}) + case r.Method == http.MethodDelete && strings.Contains(path, "/rules/"): + parts := strings.Split(strings.TrimSuffix(path, "/"), "/") + deletedRuleIDs = append(deletedRuleIDs, parts[len(parts)-1]) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/servers"): + writeJSON(t, w, map[string]any{"items": []any{ + map[string]any{ + "id": testSDKServerID, "name": "bastion", "status": "ACTIVE", "machineType": "c2i.1", + "labels": map[string]any{"cluster": "test"}, + }, + }}) + case r.Method == http.MethodGet && strings.HasSuffix(path, "/public-ips"): + writeJSON(t, w, map[string]any{"items": []any{ + map[string]any{ + "id": "66666666-6666-4666-8666-666666666666", "ip": "203.0.113.10", "networkInterface": "nic-1", + "labels": map[string]any{"cluster": "test"}, + }, + }}) + case r.Method == http.MethodGet && strings.Contains(path, "/servers/"): + writeJSON(t, w, map[string]any{ + "id": testSDKServerID, "name": "bastion", "status": "ACTIVE", "machineType": "c2i.1", + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + })) + + client := newTestSDKClient(t, server.URL) + if _, err := client.EnsureBastion(context.Background(), BastionInput{ + Name: "bastion", + ProjectID: testSDKProjectID, + Region: testSDKRegion, + NetworkID: testSDKNetworkID, + ImageID: testSDKImageID, + MachineType: "c2i.1", + SSHKeyName: "default", + AllowedCIDRs: []string{"203.0.113.0/24"}, + Tags: map[string]string{"cluster": "test"}, + }); err != nil { + t.Fatalf("EnsureBastion() error = %v", err) + } + + if len(createdRuleCIDRs) != 1 || createdRuleCIDRs[0] != "203.0.113.0/24" { + t.Fatalf("created rules for %v, want [203.0.113.0/24]", createdRuleCIDRs) + } + if len(deletedRuleIDs) != 1 || deletedRuleIDs[0] != staleRuleID { + t.Fatalf("deleted rules %v, want [%s] — the revoked CIDR keeps its SSH access", deletedRuleIDs, staleRuleID) + } +} diff --git a/controller/stackitmachine_controller_test.go b/controller/stackitmachine_controller_test.go index b3b4d7b..4b2a682 100644 --- a/controller/stackitmachine_controller_test.go +++ b/controller/stackitmachine_controller_test.go @@ -132,6 +132,40 @@ var _ = Describe("StackitMachine Controller", func() { expectCondition(got.Status.Conditions, infrav1.MachineInstanceReadyCondition, metav1.ConditionTrue, "Available") }) + It("does not silently recreate the server of an already-provisioned machine", func() { + // Regression test for debug/machine-recreate-bug.md: when the backing + // server disappears out-of-band, ensureServer used to call CreateServer + // again, replaying the original bootstrap data. The replacement either + // never rejoins (different IP) or rejoins while Machine and Node keep + // pointing at the deleted server (same IP) — neither restores the + // cluster, and both consume another VM unnoticed. + updateMachineBootstrapSecret(ctx, machineName, bootstrapName) + createBootstrapSecret(ctx, bootstrapName) + + _, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeCloud.ServerCount()).To(Equal(1)) + Expect(fakeCloud.CreateServerCalls).To(Equal(1)) + + provisioned := &infrav1.StackitMachine{} + Expect(k8sClient.Get(ctx, stackitKey, provisioned)).To(Succeed()) + Expect(provisioned.Status.Initialization.Provisioned).To(BeTrue()) + instanceID := provisioned.Status.InstanceID + Expect(instanceID).NotTo(BeEmpty()) + + By("removing the server behind the provider's back") + Expect(fakeCloud.DeleteServer(ctx, instanceID)).To(Succeed()) + Expect(fakeCloud.ServerCount()).To(Equal(0)) + + By("reconciling again") + _, err = reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred(), "reconcile must surface the missing server instead of papering over it") + + Expect(fakeCloud.CreateServerCalls).To(Equal(1), + "a replacement server was created for an already-provisioned machine") + Expect(fakeCloud.ServerCount()).To(Equal(0)) + }) + It("attaches provider-managed node SSH access when bastion is enabled", func() { updateMachineBootstrapSecret(ctx, machineName, bootstrapName) createBootstrapSecret(ctx, bootstrapName) diff --git a/controller/stackitmachine_infrastructure.go b/controller/stackitmachine_infrastructure.go index c472edf..a241ed6 100644 --- a/controller/stackitmachine_infrastructure.go +++ b/controller/stackitmachine_infrastructure.go @@ -224,6 +224,21 @@ func (r *StackitMachineReconciler) ensureServer( } else if !cloud.IsNotFound(err) { return nil, false, err } + + // The machine had already been provisioned and its server has since + // disappeared. Recreating it here would replay the original bootstrap data, + // which is pinned to the previous identity: the replacement either never + // rejoins (different IP) or rejoins while Machine/Node keep pointing at the + // deleted server (same IP). Neither restores the cluster, and both consume + // another VM silently. Surface it instead and let Cluster API decide to + // replace the Machine. + if sm.Status.Initialization.Provisioned { + return nil, false, fmt.Errorf( + "%w: server %s for already-provisioned machine no longer exists; the Machine must be replaced", + cloud.ErrNotFound, sm.Status.InstanceID, + ) + } + deleteOnTermination := true if sm.Spec.RootVolume.DeleteOnTermination != nil { deleteOnTermination = *sm.Spec.RootVolume.DeleteOnTermination From 72793a8860228ca2b678b5915edbf23a72b4f260 Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Thu, 13 Aug 2026 12:44:30 +0200 Subject: [PATCH 9/9] chore: fix all linting issues Signed-off-by: Jan Larwig --- .golangci.yml | 5 ++ cloud/sdk_client_test.go | 3 +- cmd/manager/main.go | 4 +- controller/stackitcluster_bastion.go | 11 ++- controller/stackitcluster_controller.go | 4 +- controller/stackitcluster_controller_test.go | 91 ++++++++++---------- controller/stackitcluster_infrastructure.go | 17 +++- controller/stackitmachine_controller.go | 4 +- controller/stackitmachine_controller_test.go | 3 +- controller/stackitmachine_infrastructure.go | 12 ++- 10 files changed, 87 insertions(+), 67 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f966bb1..268aa86 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,6 +28,8 @@ linters: logcheck: type: "module" description: Checks Go logging calls for Kubernetes logging conventions. + lll: + line-length: 200 revive: rules: - name: comment-spacings @@ -45,6 +47,9 @@ linters: - dupl - lll path: internal/* + - linters: + - lll + path: webhook/* paths: - third_party$ - builtin$ diff --git a/cloud/sdk_client_test.go b/cloud/sdk_client_test.go index 13045b7..b22ee46 100644 --- a/cloud/sdk_client_test.go +++ b/cloud/sdk_client_test.go @@ -28,6 +28,7 @@ const ( testSDKImageID = "22222222-2222-4222-8222-222222222222" testSDKSecurityGroup = "44444444-4444-4444-8444-444444444444" testBoolTrue = "true" + testSDKPublicIP = "203.0.113.10" ) func TestSDKClientCreateServerUsesExpectedPayload(t *testing.T) { @@ -146,7 +147,7 @@ func TestSDKClientEnsureAPIServerLoadBalancerCreatesExpectedPayload(t *testing.T if err != nil { t.Fatalf("EnsureAPIServerLoadBalancer() error = %v", err) } - if loadBalancer.ID != "apiserver-test" || loadBalancer.IP != "203.0.113.10" || loadBalancer.Port != 6443 { + if loadBalancer.ID != "apiserver-test" || loadBalancer.IP != testSDKPublicIP || loadBalancer.Port != 6443 { t.Fatalf("EnsureAPIServerLoadBalancer() = %#v", loadBalancer) } diff --git a/cmd/manager/main.go b/cmd/manager/main.go index abb536d..ca4905e 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -186,7 +186,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), CloudClientFactory: cloud.NewClient, - Recorder: mgr.GetEventRecorderFor("stackitcluster-controller"), + Recorder: mgr.GetEventRecorder("stackitcluster-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "stackitcluster") os.Exit(1) @@ -195,7 +195,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), CloudClientFactory: cloud.NewClient, - Recorder: mgr.GetEventRecorderFor("stackitmachine-controller"), + Recorder: mgr.GetEventRecorder("stackitmachine-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "stackitmachine") os.Exit(1) diff --git a/controller/stackitcluster_bastion.go b/controller/stackitcluster_bastion.go index 2acf293..508f4d6 100644 --- a/controller/stackitcluster_bastion.go +++ b/controller/stackitcluster_bastion.go @@ -53,7 +53,7 @@ func (r *StackitClusterReconciler) reconcileBastion( } s.ClearBastionStatus() if r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionDeleted", "Deleted bastion") + r.Recorder.Eventf(sc, nil, corev1.EventTypeNormal, "BastionDeleted", "Delete", "Deleted bastion") } } s.SetConditions(metav1.ConditionTrue, "Skipped", "bastion disabled", infrav1.ClusterBastionReadyCondition) @@ -82,7 +82,10 @@ func (r *StackitClusterReconciler) reconcileBastion( s.ClearBastionStatus() s.SetNotReady("Recreating", "recreating bastion because cloudInitRef content changed", infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) if r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionRecreating", "Recreating bastion because cloudInitRef content changed") + r.Recorder.Eventf( + sc, nil, corev1.EventTypeNormal, "BastionRecreating", "Recreate", + "Recreating bastion because cloudInitRef content changed", + ) } return ctrl.Result{RequeueAfter: retryableErrorRequeueAfter}, false, nil } @@ -94,7 +97,9 @@ func (r *StackitClusterReconciler) reconcileBastion( } s.SetBastionStatus(bastion, bastionCloudInitHash(cloudInit)) if !hadBastionStatus && r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionCreated", "Created bastion %s", bastion.ServerID) + r.Recorder.Eventf( + sc, nil, corev1.EventTypeNormal, "BastionCreated", "Create", "Created bastion %s", bastion.ServerID, + ) } if bastion.ServerState != "" && bastion.ServerState != "ACTIVE" { s.SetNotReady("Provisioning", fmt.Sprintf("bastion server state is %s", bastion.ServerState), infrav1.ClusterBastionReadyCondition, infrav1.ClusterReadyCondition) diff --git a/controller/stackitcluster_controller.go b/controller/stackitcluster_controller.go index 1b42c34..47ddd09 100644 --- a/controller/stackitcluster_controller.go +++ b/controller/stackitcluster_controller.go @@ -24,7 +24,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/record" + "k8s.io/client-go/tools/events" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" clusterutil "sigs.k8s.io/cluster-api/util" ctrl "sigs.k8s.io/controller-runtime" @@ -47,7 +47,7 @@ type StackitClusterReconciler struct { // CloudClientFactory builds a cloud.Client from parsed credentials. It is // injected so tests can swap in the in-memory fake. CloudClientFactory cloud.Factory - Recorder record.EventRecorder + Recorder events.EventRecorder } // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitclusters,verbs=get;list;watch;create;update;patch;delete diff --git a/controller/stackitcluster_controller_test.go b/controller/stackitcluster_controller_test.go index ade34ee..a853bcc 100644 --- a/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -21,7 +21,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -89,9 +88,9 @@ var _ = Describe("StackitCluster Controller", func() { Expect(got.Status.APIServerEndpoint).To(Equal(got.Spec.ControlPlaneEndpoint)) Expect(got.Status.APIServerLoadBalancerID).NotTo(BeEmpty()) Expect(got.Status.FailureDomains).To(ConsistOf( - clusterv1.FailureDomain{Name: "eu01-1", ControlPlane: ptr.To(true), Attributes: map[string]string{"region": "eu01"}}, - clusterv1.FailureDomain{Name: "eu01-2", ControlPlane: ptr.To(true), Attributes: map[string]string{"region": "eu01"}}, - clusterv1.FailureDomain{Name: "eu01-3", ControlPlane: ptr.To(true), Attributes: map[string]string{"region": "eu01"}}, + clusterv1.FailureDomain{Name: "eu01-1", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}}, + clusterv1.FailureDomain{Name: "eu01-2", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}}, + clusterv1.FailureDomain{Name: "eu01-3", ControlPlane: new(true), Attributes: map[string]string{"region": "eu01"}}, )) Expect(fakeCloud.LoadBalancerCount()).To(Equal(1)) expectCondition(got.Status.Conditions, infrav1.ClusterReadyCondition, metav1.ConditionTrue, "Available") @@ -212,51 +211,19 @@ var _ = Describe("StackitCluster Controller", func() { }) It("creates the bastion with cloud-init user data from a ConfigMap", func() { - got := &infrav1.StackitCluster{} - Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) - cloudInitName := "bastion-cloud-init-" + clusterName - cloudInit := "#cloud-config\npackages:\n- htop\n" - createCloudInitConfigMap(ctx, cloudInitName, namespace, "userData", cloudInit) - got.Spec.Bastion = validBastionSpec() - got.Spec.Bastion.CloudInitRef = &infrav1.StackitBastionCloudInitRef{ - Kind: "ConfigMap", - Name: cloudInitName, - Key: "userData", - } - Expect(k8sClient.Update(ctx, got)).To(Succeed()) - - result, err := reconciler.Reconcile(ctx, request) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal(reconcile.Result{})) - - Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) - Expect(got.Status.Bastion.ServerID).NotTo(BeEmpty()) - Expect(got.Status.Bastion.CloudInitHash).To(Equal(bastionCloudInitHash([]byte(cloudInit)))) - Expect(string(fakeCloud.ServerUserData(got.Status.Bastion.ServerID))).To(Equal(cloudInit)) + expectBastionCloudInit( + ctx, reconciler, request, stackitKey, fakeCloud, + "ConfigMap", "bastion-cloud-init-"+clusterName, + "#cloud-config\npackages:\n- htop\n", createCloudInitConfigMap, + ) }) It("creates the bastion with cloud-init user data from a Secret", func() { - got := &infrav1.StackitCluster{} - Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) - cloudInitName := "bastion-cloud-init-secret-" + clusterName - cloudInit := "#cloud-config\npackages:\n- jq\n" - createCloudInitSecret(ctx, cloudInitName, namespace, "userData", cloudInit) - got.Spec.Bastion = validBastionSpec() - got.Spec.Bastion.CloudInitRef = &infrav1.StackitBastionCloudInitRef{ - Kind: "Secret", - Name: cloudInitName, - Key: "userData", - } - Expect(k8sClient.Update(ctx, got)).To(Succeed()) - - result, err := reconciler.Reconcile(ctx, request) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal(reconcile.Result{})) - - Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) - Expect(got.Status.Bastion.ServerID).NotTo(BeEmpty()) - Expect(got.Status.Bastion.CloudInitHash).To(Equal(bastionCloudInitHash([]byte(cloudInit)))) - Expect(string(fakeCloud.ServerUserData(got.Status.Bastion.ServerID))).To(Equal(cloudInit)) + expectBastionCloudInit( + ctx, reconciler, request, stackitKey, fakeCloud, + "Secret", "bastion-cloud-init-secret-"+clusterName, + "#cloud-config\npackages:\n- jq\n", createCloudInitSecret, + ) }) It("marks the bastion not ready when cloud-init ref is missing", func() { @@ -379,7 +346,7 @@ var _ = Describe("StackitCluster Controller", func() { It("does not call the cloud API when the owning Cluster is paused", func() { cluster := &clusterv1.Cluster{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: namespace}, cluster)).To(Succeed()) - cluster.Spec.Paused = ptr.To(true) + cluster.Spec.Paused = new(true) Expect(k8sClient.Update(ctx, cluster)).To(Succeed()) cloudClientFactoryCalls := 0 @@ -517,6 +484,36 @@ var _ = Describe("StackitCluster Controller", func() { }) }) +func expectBastionCloudInit( + ctx context.Context, + reconciler *StackitClusterReconciler, + request reconcile.Request, + stackitKey types.NamespacedName, + fakeCloud *cloudfake.Client, + kind, cloudInitName, cloudInit string, + createCloudInit func(context.Context, string, string, string, string), +) { + got := &infrav1.StackitCluster{} + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + createCloudInit(ctx, cloudInitName, got.Namespace, "userData", cloudInit) + got.Spec.Bastion = validBastionSpec() + got.Spec.Bastion.CloudInitRef = &infrav1.StackitBastionCloudInitRef{ + Kind: kind, + Name: cloudInitName, + Key: "userData", + } + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + + result, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) + Expect(got.Status.Bastion.ServerID).NotTo(BeEmpty()) + Expect(got.Status.Bastion.CloudInitHash).To(Equal(bastionCloudInitHash([]byte(cloudInit)))) + Expect(string(fakeCloud.ServerUserData(got.Status.Bastion.ServerID))).To(Equal(cloudInit)) +} + func newStackitCluster(name, namespace string, lbEnabled bool) *infrav1.StackitCluster { return &infrav1.StackitCluster{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index fb23031..25cf061 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -93,7 +93,10 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, s *scope if lb != nil { sc.Status.APIServerLoadBalancerID = lb.ID if !hadLoadBalancerID && lb.ID != "" && r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "LoadBalancerCreated", "Created API server load balancer %s", lb.ID) + r.Recorder.Eventf( + sc, nil, corev1.EventTypeNormal, "LoadBalancerCreated", "Create", + "Created API server load balancer %s", lb.ID, + ) } } if lb == nil || lb.IP == "" { @@ -107,7 +110,10 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, s *scope s.SetAPIServerEndpoint(endpoint) s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ClusterLoadBalancerReadyCondition) if r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "LoadBalancerReady", "API server load balancer is ready at %s", lb.IP) + r.Recorder.Eventf( + sc, nil, corev1.EventTypeNormal, "LoadBalancerReady", "SetReady", + "API server load balancer is ready at %s", lb.IP, + ) } } else if sc.Spec.ControlPlaneEndpoint.Host != "" { sc.Status.APIServerEndpoint = sc.Spec.ControlPlaneEndpoint @@ -210,7 +216,10 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, s *scope } sc.Status.APIServerLoadBalancerID = "" if r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "LoadBalancerDeleted", "Deleted API server load balancer %s", loadBalancerID) + r.Recorder.Eventf( + sc, nil, corev1.EventTypeNormal, "LoadBalancerDeleted", "Delete", + "Deleted API server load balancer %s", loadBalancerID, + ) } } if hasBastionStatus(sc.Status.Bastion) { @@ -227,7 +236,7 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, s *scope } s.ClearBastionStatus() if r.Recorder != nil { - r.Recorder.Eventf(sc, corev1.EventTypeNormal, "BastionDeleted", "Deleted bastion") + r.Recorder.Eventf(sc, nil, corev1.EventTypeNormal, "BastionDeleted", "Delete", "Deleted bastion") } } } diff --git a/controller/stackitmachine_controller.go b/controller/stackitmachine_controller.go index 47b1b54..d1aa1d1 100644 --- a/controller/stackitmachine_controller.go +++ b/controller/stackitmachine_controller.go @@ -23,7 +23,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" + "k8s.io/client-go/tools/events" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" clusterutil "sigs.k8s.io/cluster-api/util" ctrl "sigs.k8s.io/controller-runtime" @@ -44,7 +44,7 @@ type StackitMachineReconciler struct { // CloudClientFactory builds a cloud.Client from parsed credentials. CloudClientFactory cloud.Factory - Recorder record.EventRecorder + Recorder events.EventRecorder } // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmachines,verbs=get;list;watch;create;update;patch;delete diff --git a/controller/stackitmachine_controller_test.go b/controller/stackitmachine_controller_test.go index 4b2a682..3cee372 100644 --- a/controller/stackitmachine_controller_test.go +++ b/controller/stackitmachine_controller_test.go @@ -21,7 +21,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -237,7 +236,7 @@ var _ = Describe("StackitMachine Controller", func() { createBootstrapSecret(ctx, bootstrapName) cluster := &clusterv1.Cluster{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: namespace}, cluster)).To(Succeed()) - cluster.Spec.Paused = ptr.To(true) + cluster.Spec.Paused = new(true) Expect(k8sClient.Update(ctx, cluster)).To(Succeed()) cloudClientFactoryCalls := 0 diff --git a/controller/stackitmachine_infrastructure.go b/controller/stackitmachine_infrastructure.go index a241ed6..901a376 100644 --- a/controller/stackitmachine_infrastructure.go +++ b/controller/stackitmachine_infrastructure.go @@ -85,7 +85,9 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, s *scope ) } if created && r.Recorder != nil { - r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceCreated", "Created instance %s", server.ID) + r.Recorder.Eventf( + sm, nil, corev1.EventTypeNormal, "InstanceCreated", "Create", "Created instance %s", server.ID, + ) } sm.Status.InstanceState = server.State @@ -147,7 +149,7 @@ func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, s *scope if sm.Status.InstanceID == "" && !needsLoadBalancerCleanup { controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) if r.Recorder != nil { - r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceDeleted", "Deleted instance") + r.Recorder.Eventf(sm, nil, corev1.EventTypeNormal, "InstanceDeleted", "Delete", "Deleted instance") } return nil } @@ -167,7 +169,7 @@ func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, s *scope if sm.Status.InstanceID == "" { controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) if r.Recorder != nil { - r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceDeleted", "Deleted instance") + r.Recorder.Eventf(sm, nil, corev1.EventTypeNormal, "InstanceDeleted", "Delete", "Deleted instance") } return nil } @@ -178,7 +180,9 @@ func (r *StackitMachineReconciler) reconcileDelete(ctx context.Context, s *scope s.ClearInstance() controllerutil.RemoveFinalizer(sm, infrav1.MachineFinalizer) if r.Recorder != nil { - r.Recorder.Eventf(sm, corev1.EventTypeNormal, "InstanceDeleted", "Deleted instance %s", instanceID) + r.Recorder.Eventf( + sm, nil, corev1.EventTypeNormal, "InstanceDeleted", "Delete", "Deleted instance %s", instanceID, + ) } return nil }