From 60e0f008d716cc90181ea4abd89e090b77aa2db5 Mon Sep 17 00:00:00 2001 From: Rana Ashar Mehmood Date: Wed, 28 Oct 2026 04:10:21 +0100 Subject: [PATCH 1/3] WIP: phase0 reference implementation --- PROJECT | 9 ++ api/v1alpha1/stackitmanagedcluster_types.go | 79 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 97 +++++++++++ cmd/manager/main.go | 7 + ...uster.x-k8s.io_stackitmanagedclusters.yaml | 138 ++++++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/kustomization.yaml | 5 + config/rbac/role.yaml | 3 + .../stackitmanagedcluster_admin_role.yaml | 27 ++++ .../stackitmanagedcluster_editor_role.yaml | 33 ++++ .../stackitmanagedcluster_viewer_role.yaml | 29 ++++ ...ucture_v1alpha1_stackitmanagedcluster.yaml | 8 + config/samples/kustomization.yaml | 1 + .../stackitmanagedcluster_controller.go | 91 +++++++++++ .../stackitmanagedcluster_controller_test.go | 150 ++++++++++++++++++ 15 files changed, 678 insertions(+) create mode 100644 api/v1alpha1/stackitmanagedcluster_types.go create mode 100644 config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml create mode 100644 config/rbac/stackitmanagedcluster_admin_role.yaml create mode 100644 config/rbac/stackitmanagedcluster_editor_role.yaml create mode 100644 config/rbac/stackitmanagedcluster_viewer_role.yaml create mode 100644 config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml create mode 100644 controller/stackitmanagedcluster_controller.go create mode 100644 controller/stackitmanagedcluster_controller_test.go diff --git a/PROJECT b/PROJECT index 19bf554..e12ba8a 100644 --- a/PROJECT +++ b/PROJECT @@ -59,4 +59,13 @@ resources: defaulting: true validation: true webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cluster.x-k8s.io + group: infrastructure + kind: StackitManagedCluster + path: github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/stackitmanagedcluster_types.go b/api/v1alpha1/stackitmanagedcluster_types.go new file mode 100644 index 0000000..8a675fb --- /dev/null +++ b/api/v1alpha1/stackitmanagedcluster_types.go @@ -0,0 +1,79 @@ +/* +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StackitManagedClusterSpec defines the desired state of StackitManagedCluster. +type StackitManagedClusterSpec struct { +} + +// StackitManagedClusterStatus defines the observed state of StackitManagedCluster. +type StackitManagedClusterStatus struct { + // ready indicates that the infrastructure required for the cluster is ready. + // +optional + Ready bool `json:"ready,omitempty"` + + // initialization provides Cluster API v1beta2 contract state. + // +optional + Initialization StackitClusterInitializationStatus `json:"initialization,omitempty"` + + // conditions represent the current state of the StackitManagedCluster resource. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=stackitmanagedclusters,shortName=stmc,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=".status.ready" +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" +// +kubebuilder:storageversion + +// StackitManagedCluster is the Schema for the stackitmanagedclusters API. +type StackitManagedCluster struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of StackitManagedCluster + // +required + Spec StackitManagedClusterSpec `json:"spec"` + + // status defines the observed state of StackitManagedCluster + // +optional + Status StackitManagedClusterStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// StackitManagedClusterList contains a list of StackitManagedCluster. +type StackitManagedClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []StackitManagedCluster `json:"items"` +} + +func init() { + SchemeBuilder.Register(&StackitManagedCluster{}, &StackitManagedClusterList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d5d3fdf..83d9d1a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -578,6 +578,103 @@ func (in *StackitMachineTemplateSpec) DeepCopy() *StackitMachineTemplateSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedCluster) DeepCopyInto(out *StackitManagedCluster) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedCluster. +func (in *StackitManagedCluster) DeepCopy() *StackitManagedCluster { + if in == nil { + return nil + } + out := new(StackitManagedCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StackitManagedCluster) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedClusterList) DeepCopyInto(out *StackitManagedClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]StackitManagedCluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterList. +func (in *StackitManagedClusterList) DeepCopy() *StackitManagedClusterList { + if in == nil { + return nil + } + out := new(StackitManagedClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StackitManagedClusterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedClusterSpec) DeepCopyInto(out *StackitManagedClusterSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterSpec. +func (in *StackitManagedClusterSpec) DeepCopy() *StackitManagedClusterSpec { + if in == nil { + return nil + } + out := new(StackitManagedClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedClusterStatus) DeepCopyInto(out *StackitManagedClusterStatus) { + *out = *in + out.Initialization = in.Initialization + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterStatus. +func (in *StackitManagedClusterStatus) DeepCopy() *StackitManagedClusterStatus { + if in == nil { + return nil + } + out := new(StackitManagedClusterStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StackitRootVolumeSpec) DeepCopyInto(out *StackitRootVolumeSpec) { *out = *in diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 682eb40..a777e2e 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -220,6 +220,13 @@ func main() { os.Exit(1) } } + if err := (&controller.StackitManagedClusterReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "stackitmanagedcluster") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml new file mode 100644 index 0000000..ab26d9a --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml @@ -0,0 +1,138 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: stackitmanagedclusters.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: StackitManagedCluster + listKind: StackitManagedClusterList + plural: stackitmanagedclusters + shortNames: + - stmc + singular: stackitmanagedcluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.ready + name: Ready + type: boolean + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: StackitManagedCluster is the Schema for the stackitmanagedclusters + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of StackitManagedCluster + type: object + status: + description: status defines the observed state of StackitManagedCluster + properties: + conditions: + description: conditions represent the current state of the StackitManagedCluster + resource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + initialization: + description: initialization provides Cluster API v1beta2 contract + state. + properties: + provisioned: + description: provisioned is true when the cluster infrastructure + is provisioned. + type: boolean + type: object + ready: + description: ready indicates that the infrastructure required for + the cluster is ready. + type: boolean + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1bc6510..ed5d88c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -11,6 +11,7 @@ resources: - bases/infrastructure.cluster.x-k8s.io_stackitmachines.yaml - bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml - bases/infrastructure.cluster.x-k8s.io_stackitmachinetemplates.yaml +- bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index e2953c4..6f88528 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -23,6 +23,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the cluster-api-provider-stackit itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- stackitmanagedcluster_admin_role.yaml +- stackitmanagedcluster_editor_role.yaml +- stackitmanagedcluster_viewer_role.yaml - stackitmachinetemplate_admin_role.yaml - stackitmachinetemplate_editor_role.yaml - stackitmachinetemplate_viewer_role.yaml @@ -35,3 +38,5 @@ resources: - stackitcluster_admin_role.yaml - stackitcluster_editor_role.yaml - stackitcluster_viewer_role.yaml + + diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index b29226e..9b4a3a7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -34,6 +34,7 @@ rules: resources: - stackitclusters - stackitmachines + - stackitmanagedclusters verbs: - create - delete @@ -47,6 +48,7 @@ rules: resources: - stackitclusters/finalizers - stackitmachines/finalizers + - stackitmanagedclusters/finalizers verbs: - update - apiGroups: @@ -54,6 +56,7 @@ rules: resources: - stackitclusters/status - stackitmachines/status + - stackitmanagedclusters/status verbs: - get - patch diff --git a/config/rbac/stackitmanagedcluster_admin_role.yaml b/config/rbac/stackitmanagedcluster_admin_role.yaml new file mode 100644 index 0000000..a89225d --- /dev/null +++ b/config/rbac/stackitmanagedcluster_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project cluster-api-provider-stackit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over infrastructure.cluster.x-k8s.io. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagedcluster-admin-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagedclusters + verbs: + - '*' +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagedclusters/status + verbs: + - get diff --git a/config/rbac/stackitmanagedcluster_editor_role.yaml b/config/rbac/stackitmanagedcluster_editor_role.yaml new file mode 100644 index 0000000..458a311 --- /dev/null +++ b/config/rbac/stackitmanagedcluster_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project cluster-api-provider-stackit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the infrastructure.cluster.x-k8s.io. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagedcluster-editor-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagedclusters + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagedclusters/status + verbs: + - get diff --git a/config/rbac/stackitmanagedcluster_viewer_role.yaml b/config/rbac/stackitmanagedcluster_viewer_role.yaml new file mode 100644 index 0000000..89a8085 --- /dev/null +++ b/config/rbac/stackitmanagedcluster_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project cluster-api-provider-stackit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to infrastructure.cluster.x-k8s.io resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagedcluster-viewer-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagedclusters + verbs: + - get + - list + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagedclusters/status + verbs: + - get diff --git a/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml b/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml new file mode 100644 index 0000000..f195d92 --- /dev/null +++ b/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml @@ -0,0 +1,8 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: StackitManagedCluster +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagedcluster-sample +spec: {} diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index ea1b480..0940324 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -4,4 +4,5 @@ resources: - infrastructure_v1alpha1_stackitmachine.yaml - infrastructure_v1alpha1_stackitclustertemplate.yaml - infrastructure_v1alpha1_stackitmachinetemplate.yaml +- infrastructure_v1alpha1_stackitmanagedcluster.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/controller/stackitmanagedcluster_controller.go b/controller/stackitmanagedcluster_controller.go new file mode 100644 index 0000000..569ba6e --- /dev/null +++ b/controller/stackitmanagedcluster_controller.go @@ -0,0 +1,91 @@ +/* +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" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "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/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" +) + +// StackitManagedClusterReconciler reconciles a StackitManagedCluster object. +type StackitManagedClusterReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters/finalizers,verbs=update +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *StackitManagedClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + stackitManagedCluster := &infrav1.StackitManagedCluster{} + if err := r.Get(ctx, req.NamespacedName, stackitManagedCluster); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + log.Info("Reconciling StackitManagedCluster", "namespace", req.Namespace, "name", req.Name) + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *StackitManagedClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&infrav1.StackitManagedCluster{}). + Watches( + &clusterv1.Cluster{}, + handler.EnqueueRequestsFromMapFunc(r.filterForStackitManagedClusterResources), + ). + Named("stackitmanagedcluster"). + Complete(r) +} + +func (r *StackitManagedClusterReconciler) filterForStackitManagedClusterResources(_ 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 != "StackitManagedCluster" || ref.Name == "" { + return nil + } + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: cluster.Namespace, + Name: ref.Name, + }, + }} +} diff --git a/controller/stackitmanagedcluster_controller_test.go b/controller/stackitmanagedcluster_controller_test.go new file mode 100644 index 0000000..233cbba --- /dev/null +++ b/controller/stackitmanagedcluster_controller_test.go @@ -0,0 +1,150 @@ +/* +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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +var _ = Describe("StackitManagedCluster Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + stackitmanagedcluster := &infrav1.StackitManagedCluster{} + + BeforeEach(func() { + By("creating the custom resource for the Kind StackitManagedCluster") + err := k8sClient.Get(ctx, typeNamespacedName, stackitmanagedcluster) + if err != nil && errors.IsNotFound(err) { + resource := &infrav1.StackitManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + // TODO(user): Specify other spec details if needed. + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &infrav1.StackitManagedCluster{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance StackitManagedCluster") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &StackitManagedClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return nil when resource is not found", func() { + controllerReconciler := &StackitManagedClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "non-existent", + }, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("When mapping Cluster events to StackitManagedCluster", func() { + reconciler := &StackitManagedClusterReconciler{} + + It("should map to StackitManagedCluster request when infrastructureRef matches", func() { + cluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "my-namespace", + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedCluster", + Name: "my-managed-cluster", + }, + }, + } + + requests := reconciler.filterForStackitManagedClusterResources(context.Background(), cluster) + Expect(requests).To(HaveLen(1)) + Expect(requests[0].NamespacedName).To(Equal(types.NamespacedName{ + Namespace: "my-namespace", + Name: "my-managed-cluster", + })) + }) + + It("should ignore Clusters with different infrastructureRef Kind", func() { + cluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "my-namespace", + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitCluster", + Name: "my-stackit-cluster", + }, + }, + } + + requests := reconciler.filterForStackitManagedClusterResources(context.Background(), cluster) + Expect(requests).To(BeEmpty()) + }) + + It("should ignore non-Cluster objects", func() { + obj := &infrav1.StackitManagedCluster{} + requests := reconciler.filterForStackitManagedClusterResources(context.Background(), obj) + Expect(requests).To(BeNil()) + }) + }) +}) From 41354e097f2f9e83763014e132a9f74a85e15d48 Mon Sep 17 00:00:00 2001 From: Rana Ashar Mehmood Date: Thu, 29 Oct 2026 11:18:33 +0100 Subject: [PATCH 2/3] feat: add SKE managed cluster and node pool foundation --- PROJECT | 8 + api/v1alpha1/stackitmanagedcluster_types.go | 75 +++- api/v1alpha1/stackitmanagednodepool_types.go | 209 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 286 +++++++++++- cloud/services/ske/ske.go | 166 +++++++ cloud/services/ske/ske_test.go | 116 +++++ cmd/manager/main.go | 21 +- ...uster.x-k8s.io_stackitmanagedclusters.yaml | 98 +++- ...ster.x-k8s.io_stackitmanagednodepools.yaml | 280 ++++++++++++ config/crd/kustomization.yaml | 3 +- .../rbac/capi_manager_aggregation_role.yaml | 2 + config/rbac/kustomization.yaml | 5 +- config/rbac/role.yaml | 3 + .../stackitmanagednodepool_admin_role.yaml | 27 ++ .../stackitmanagednodepool_editor_role.yaml | 33 ++ .../stackitmanagednodepool_viewer_role.yaml | 29 ++ ...ucture_v1alpha1_stackitmanagedcluster.yaml | 11 +- ...cture_v1alpha1_stackitmanagednodepool.yaml | 22 + config/samples/kustomization.yaml | 1 + .../stackitmanagedcluster_controller.go | 39 +- .../stackitmanagedcluster_controller_test.go | 420 +++++++++++++++++- .../stackitmanagednodepool_controller.go | 251 +++++++++++ .../stackitmanagednodepool_controller_test.go | 349 +++++++++++++++ 23 files changed, 2425 insertions(+), 29 deletions(-) create mode 100644 api/v1alpha1/stackitmanagednodepool_types.go create mode 100644 cloud/services/ske/ske.go create mode 100644 cloud/services/ske/ske_test.go create mode 100644 config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml create mode 100644 config/rbac/stackitmanagednodepool_admin_role.yaml create mode 100644 config/rbac/stackitmanagednodepool_editor_role.yaml create mode 100644 config/rbac/stackitmanagednodepool_viewer_role.yaml create mode 100644 config/samples/infrastructure_v1alpha1_stackitmanagednodepool.yaml create mode 100644 controller/stackitmanagednodepool_controller.go create mode 100644 controller/stackitmanagednodepool_controller_test.go diff --git a/PROJECT b/PROJECT index e12ba8a..606b3a0 100644 --- a/PROJECT +++ b/PROJECT @@ -68,4 +68,12 @@ resources: kind: StackitManagedCluster path: github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + domain: cluster.x-k8s.io + group: infrastructure + kind: StackitManagedNodePool + path: github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/stackitmanagedcluster_types.go b/api/v1alpha1/stackitmanagedcluster_types.go index 8a675fb..8fe7e47 100644 --- a/api/v1alpha1/stackitmanagedcluster_types.go +++ b/api/v1alpha1/stackitmanagedcluster_types.go @@ -17,11 +17,74 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" ) // StackitManagedClusterSpec defines the desired state of StackitManagedCluster. +// +kubebuilder:validation:XValidation:rule="has(self.credentialsSecretRef.name) && size(self.credentialsSecretRef.name) > 0",message="credentialsSecretRef.name is required" type StackitManagedClusterSpec struct { + // projectID is the STACKIT project that owns the cluster infrastructure. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` + ProjectID string `json:"projectID"` + + // region is the STACKIT region in which infrastructure is created. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[a-z]{2}[0-9]{2}$` + Region string `json:"region"` + + // clusterName is the SKE cluster name in STACKIT. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=11 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,9}[a-z0-9])?$` + ClusterName string `json:"clusterName"` + + // credentialsSecretRef references the Secret containing STACKIT credentials. + // +required + CredentialsSecretRef corev1.SecretReference `json:"credentialsSecretRef"` + + // kubernetes configures the SKE cluster Kubernetes version. + // +required + Kubernetes StackitManagedClusterKubernetesSpec `json:"kubernetes"` + + // network configures the STACKIT network for the SKE cluster. + // +optional + Network *StackitManagedClusterNetworkSpec `json:"network,omitempty"` + + // controlPlaneEndpoint represents the endpoint used to communicate with the + // control plane. + // +optional + ControlPlaneEndpoint clusterv1.APIEndpoint `json:"controlPlaneEndpoint,omitempty,omitzero"` + + // labels are key-value pairs applied to the SKE cluster object. + // +optional + Labels map[string]string `json:"labels,omitempty"` +} + +// StackitManagedClusterKubernetesSpec configures the Kubernetes version for an +// SKE cluster. +type StackitManagedClusterKubernetesSpec struct { + // version is the full Kubernetes version used for the cluster + // (major.minor.patch). + // +required + // +kubebuilder:validation:Pattern=`^[0-9]+\.[0-9]+\.[0-9]+$` + Version string `json:"version"` +} + +// StackitManagedClusterNetworkSpec configures the STACKIT network for an SKE +// cluster. +type StackitManagedClusterNetworkSpec struct { + // id is the ID of the STACKIT network into which the cluster will be + // deployed. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` + ID string `json:"id"` } // StackitManagedClusterStatus defines the observed state of StackitManagedCluster. @@ -32,7 +95,7 @@ type StackitManagedClusterStatus struct { // initialization provides Cluster API v1beta2 contract state. // +optional - Initialization StackitClusterInitializationStatus `json:"initialization,omitempty"` + Initialization StackitManagedClusterInitializationStatus `json:"initialization,omitempty,omitzero"` // conditions represent the current state of the StackitManagedCluster resource. // +listType=map @@ -41,6 +104,16 @@ type StackitManagedClusterStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` } +// StackitManagedClusterInitializationStatus provides observations of the +// StackitManagedCluster initialization process. +// +kubebuilder:validation:MinProperties=1 +type StackitManagedClusterInitializationStatus struct { + // provisioned is true when the infrastructure provider reports that the + // cluster infrastructure is fully provisioned. + // +optional + Provisioned *bool `json:"provisioned,omitempty"` +} + // +kubebuilder:object:root=true // +kubebuilder:resource:path=stackitmanagedclusters,shortName=stmc,scope=Namespaced,categories=cluster-api // +kubebuilder:subresource:status diff --git a/api/v1alpha1/stackitmanagednodepool_types.go b/api/v1alpha1/stackitmanagednodepool_types.go new file mode 100644 index 0000000..51c46a9 --- /dev/null +++ b/api/v1alpha1/stackitmanagednodepool_types.go @@ -0,0 +1,209 @@ +/* +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 v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StackitManagedNodePoolSpec defines the desired state of StackitManagedNodePool. +// +kubebuilder:validation:XValidation:rule="self.minimum <= self.maximum",message="minimum must be less than or equal to maximum" +type StackitManagedNodePoolSpec struct { + // name is the name of the node pool in SKE. + // +required + // +kubebuilder:validation:MaxLength=15 + Name string `json:"name"` + + // availabilityZones is the list of availability zones for the node pool. + // +required + AvailabilityZones []string `json:"availabilityZones"` + + // machine configures the VM instance type and OS image for the node pool. + // +required + Machine StackitManagedNodePoolMachineSpec `json:"machine"` + + // minimum is the minimum number of nodes in the pool. + // +required + // +kubebuilder:validation:Maximum=1000 + Minimum int32 `json:"minimum"` + + // maximum is the maximum number of nodes in the pool. + // +required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=1000 + Maximum int32 `json:"maximum"` + + // volume configures the disk volume for each node in the pool. + // +required + Volume StackitManagedNodePoolVolumeSpec `json:"volume"` + + // allowSystemComponents allows system components to run on this node pool. + // +optional + AllowSystemComponents *bool `json:"allowSystemComponents,omitempty"` + + // labels is a map of labels attached to all worker nodes of this node pool. + // +optional + // +kubebuilder:validation:MaxProperties=80 + Labels map[string]string `json:"labels,omitempty"` + + // taints is a list of taints for all worker nodes of this node pool. + // +optional + Taints []StackitManagedNodePoolTaint `json:"taints,omitempty"` + + // maxSurge is the maximum number of additional VMs created during an update. + // +optional + MaxSurge *int32 `json:"maxSurge,omitempty"` + + // maxUnavailable is the maximum number of VMs that can be unavailable during + // an update. + // +optional + MaxUnavailable *int32 `json:"maxUnavailable,omitempty"` + + // providerIDList are the identification IDs of machine instances provided by + // the provider. This field must match the provider IDs as seen on the Node + // objects corresponding to a MachinePool's machine instances. + // +optional + // +listType=atomic + // +kubebuilder:validation:MaxItems=10000 + // +kubebuilder:validation:items:MinLength=1 + // +kubebuilder:validation:items:MaxLength=512 + ProviderIDList []string `json:"providerIDList,omitempty"` +} + +// StackitManagedNodePoolMachineSpec configures the machine flavor and OS image +// for an SKE node pool. +type StackitManagedNodePoolMachineSpec struct { + // type selects the STACKIT machine type for the node pool. + // +required + Type string `json:"type"` + + // image configures the OS image for the node pool. + // +required + Image StackitManagedNodePoolImageSpec `json:"image"` +} + +// StackitManagedNodePoolImageSpec configures the OS image name and version for +// an SKE node pool. +type StackitManagedNodePoolImageSpec struct { + // name is the OS image name. + // +required + Name string `json:"name"` + + // version is the OS image version. + // +required + Version string `json:"version"` +} + +// StackitManagedNodePoolVolumeSpec configures the boot disk volume for an SKE +// node pool. +type StackitManagedNodePoolVolumeSpec struct { + // size is the size of the volume in GiB. + // +required + // +kubebuilder:validation:Minimum=20 + // +kubebuilder:validation:Maximum=10240 + Size int32 `json:"size"` + + // type is the volume performance class. + // +optional + Type string `json:"type,omitempty"` +} + +// StackitManagedNodePoolTaint defines a Kubernetes taint applied to nodes in +// an SKE node pool. +type StackitManagedNodePoolTaint struct { + // effect is the taint effect to apply. + // +required + // +kubebuilder:validation:Enum=NoSchedule;PreferNoSchedule;NoExecute + Effect corev1.TaintEffect `json:"effect"` + + // key is the taint key to be applied to a node. + // +required + Key string `json:"key"` + + // value is the taint value corresponding to the taint key. + // +optional + Value string `json:"value,omitempty"` +} + +// StackitManagedNodePoolStatus defines the observed state of StackitManagedNodePool. +type StackitManagedNodePoolStatus struct { + // ready indicates that the node pool infrastructure is ready. + // +optional + Ready bool `json:"ready,omitempty"` + + // initialization provides Cluster API v1beta2 contract state. + // +optional + Initialization StackitManagedNodePoolInitializationStatus `json:"initialization,omitempty,omitzero"` + + // replicas is the most recently observed number of replicas. + // +optional + Replicas int32 `json:"replicas"` + + // conditions represent the current state of the StackitManagedNodePool resource. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// StackitManagedNodePoolInitializationStatus holds Cluster API initialization +// state for a StackitManagedNodePool. +// +kubebuilder:validation:MinProperties=1 +type StackitManagedNodePoolInitializationStatus struct { + // provisioned is true when the node pool infrastructure is provisioned. + // +optional + Provisioned *bool `json:"provisioned,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=stackitmanagednodepools,shortName=stmnp,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=".status.ready" +// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=".status.replicas" +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" +// +kubebuilder:storageversion + +// StackitManagedNodePool is the Schema for the stackitmanagednodepools API. +type StackitManagedNodePool struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of StackitManagedNodePool + // +required + Spec StackitManagedNodePoolSpec `json:"spec"` + + // status defines the observed state of StackitManagedNodePool + // +optional + Status StackitManagedNodePoolStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// StackitManagedNodePoolList contains a list of StackitManagedNodePool. +type StackitManagedNodePoolList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []StackitManagedNodePool `json:"items"` +} + +func init() { + SchemeBuilder.Register(&StackitManagedNodePool{}, &StackitManagedNodePoolList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 83d9d1a..de2955c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -583,7 +583,7 @@ func (in *StackitManagedCluster) DeepCopyInto(out *StackitManagedCluster) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -605,6 +605,41 @@ func (in *StackitManagedCluster) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedClusterInitializationStatus) DeepCopyInto(out *StackitManagedClusterInitializationStatus) { + *out = *in + if in.Provisioned != nil { + in, out := &in.Provisioned, &out.Provisioned + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterInitializationStatus. +func (in *StackitManagedClusterInitializationStatus) DeepCopy() *StackitManagedClusterInitializationStatus { + if in == nil { + return nil + } + out := new(StackitManagedClusterInitializationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedClusterKubernetesSpec) DeepCopyInto(out *StackitManagedClusterKubernetesSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterKubernetesSpec. +func (in *StackitManagedClusterKubernetesSpec) DeepCopy() *StackitManagedClusterKubernetesSpec { + if in == nil { + return nil + } + out := new(StackitManagedClusterKubernetesSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StackitManagedClusterList) DeepCopyInto(out *StackitManagedClusterList) { *out = *in @@ -637,9 +672,39 @@ func (in *StackitManagedClusterList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedClusterNetworkSpec) DeepCopyInto(out *StackitManagedClusterNetworkSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterNetworkSpec. +func (in *StackitManagedClusterNetworkSpec) DeepCopy() *StackitManagedClusterNetworkSpec { + if in == nil { + return nil + } + out := new(StackitManagedClusterNetworkSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StackitManagedClusterSpec) DeepCopyInto(out *StackitManagedClusterSpec) { *out = *in + out.CredentialsSecretRef = in.CredentialsSecretRef + out.Kubernetes = in.Kubernetes + if in.Network != nil { + in, out := &in.Network, &out.Network + *out = new(StackitManagedClusterNetworkSpec) + **out = **in + } + out.ControlPlaneEndpoint = in.ControlPlaneEndpoint + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedClusterSpec. @@ -655,7 +720,7 @@ func (in *StackitManagedClusterSpec) DeepCopy() *StackitManagedClusterSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StackitManagedClusterStatus) DeepCopyInto(out *StackitManagedClusterStatus) { *out = *in - out.Initialization = in.Initialization + in.Initialization.DeepCopyInto(&out.Initialization) if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) @@ -675,6 +740,223 @@ func (in *StackitManagedClusterStatus) DeepCopy() *StackitManagedClusterStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePool) DeepCopyInto(out *StackitManagedNodePool) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePool. +func (in *StackitManagedNodePool) DeepCopy() *StackitManagedNodePool { + if in == nil { + return nil + } + out := new(StackitManagedNodePool) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StackitManagedNodePool) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolImageSpec) DeepCopyInto(out *StackitManagedNodePoolImageSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolImageSpec. +func (in *StackitManagedNodePoolImageSpec) DeepCopy() *StackitManagedNodePoolImageSpec { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolImageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolInitializationStatus) DeepCopyInto(out *StackitManagedNodePoolInitializationStatus) { + *out = *in + if in.Provisioned != nil { + in, out := &in.Provisioned, &out.Provisioned + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolInitializationStatus. +func (in *StackitManagedNodePoolInitializationStatus) DeepCopy() *StackitManagedNodePoolInitializationStatus { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolInitializationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolList) DeepCopyInto(out *StackitManagedNodePoolList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]StackitManagedNodePool, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolList. +func (in *StackitManagedNodePoolList) DeepCopy() *StackitManagedNodePoolList { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StackitManagedNodePoolList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolMachineSpec) DeepCopyInto(out *StackitManagedNodePoolMachineSpec) { + *out = *in + out.Image = in.Image +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolMachineSpec. +func (in *StackitManagedNodePoolMachineSpec) DeepCopy() *StackitManagedNodePoolMachineSpec { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolMachineSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolSpec) DeepCopyInto(out *StackitManagedNodePoolSpec) { + *out = *in + if in.AvailabilityZones != nil { + in, out := &in.AvailabilityZones, &out.AvailabilityZones + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.Machine = in.Machine + out.Volume = in.Volume + if in.AllowSystemComponents != nil { + in, out := &in.AllowSystemComponents, &out.AllowSystemComponents + *out = new(bool) + **out = **in + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Taints != nil { + in, out := &in.Taints, &out.Taints + *out = make([]StackitManagedNodePoolTaint, len(*in)) + copy(*out, *in) + } + if in.MaxSurge != nil { + in, out := &in.MaxSurge, &out.MaxSurge + *out = new(int32) + **out = **in + } + if in.MaxUnavailable != nil { + in, out := &in.MaxUnavailable, &out.MaxUnavailable + *out = new(int32) + **out = **in + } + if in.ProviderIDList != nil { + in, out := &in.ProviderIDList, &out.ProviderIDList + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolSpec. +func (in *StackitManagedNodePoolSpec) DeepCopy() *StackitManagedNodePoolSpec { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolStatus) DeepCopyInto(out *StackitManagedNodePoolStatus) { + *out = *in + in.Initialization.DeepCopyInto(&out.Initialization) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolStatus. +func (in *StackitManagedNodePoolStatus) DeepCopy() *StackitManagedNodePoolStatus { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolTaint) DeepCopyInto(out *StackitManagedNodePoolTaint) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolTaint. +func (in *StackitManagedNodePoolTaint) DeepCopy() *StackitManagedNodePoolTaint { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolTaint) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StackitManagedNodePoolVolumeSpec) DeepCopyInto(out *StackitManagedNodePoolVolumeSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitManagedNodePoolVolumeSpec. +func (in *StackitManagedNodePoolVolumeSpec) DeepCopy() *StackitManagedNodePoolVolumeSpec { + if in == nil { + return nil + } + out := new(StackitManagedNodePoolVolumeSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StackitRootVolumeSpec) DeepCopyInto(out *StackitRootVolumeSpec) { *out = *in diff --git a/cloud/services/ske/ske.go b/cloud/services/ske/ske.go new file mode 100644 index 0000000..183b864 --- /dev/null +++ b/cloud/services/ske/ske.go @@ -0,0 +1,166 @@ +/* +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 ske converts CAPSTK managed-cluster and node-pool resources into a +// provider-neutral SKE desired-state representation. +// +// Managed cluster reconciliation owns mutation of the SKE cluster because SKE +// node pools are submitted as part of the cluster payload. +package ske + +import ( + "maps" + "slices" + "strings" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +// ManagedClusterDesiredState holds the cluster-level fields needed by the SKE +// service layer. +type ManagedClusterDesiredState struct { + ProjectID string + Region string + ClusterName string + KubernetesVersion string + NetworkID string + Labels map[string]string +} + +// ManagedNodePoolDesiredState holds the node-pool-level fields needed by the +// SKE service layer. +type ManagedNodePoolDesiredState struct { + Name string + AvailabilityZones []string + MachineType string + ImageName string + ImageVersion string + Minimum int32 + Maximum int32 + VolumeSize int32 + VolumeType string + AllowSystemComponents *bool + Labels map[string]string + Taints []ManagedNodePoolTaint + MaxSurge *int32 + MaxUnavailable *int32 +} + +// ManagedNodePoolTaint describes a node taint in provider-neutral terms. +type ManagedNodePoolTaint struct { + Effect string + Key string + Value string +} + +// ManagedClusterInput represents the combined SKE cluster payload produced from +// a StackitManagedCluster and its associated StackitManagedNodePools. +type ManagedClusterInput struct { + Cluster ManagedClusterDesiredState + NodePools []ManagedNodePoolDesiredState +} + +// ClusterDesiredState converts a StackitManagedCluster into its SKE desired +// state representation. +func ClusterDesiredState(managedCluster *infrav1.StackitManagedCluster) ManagedClusterDesiredState { + if managedCluster == nil { + return ManagedClusterDesiredState{} + } + var networkID string + if managedCluster.Spec.Network != nil { + networkID = managedCluster.Spec.Network.ID + } + var labels map[string]string + if len(managedCluster.Spec.Labels) > 0 { + labels = maps.Clone(managedCluster.Spec.Labels) + } + return ManagedClusterDesiredState{ + ProjectID: managedCluster.Spec.ProjectID, + Region: managedCluster.Spec.Region, + ClusterName: managedCluster.Spec.ClusterName, + KubernetesVersion: managedCluster.Spec.Kubernetes.Version, + NetworkID: networkID, + Labels: labels, + } +} + +// NodePoolDesiredState converts a StackitManagedNodePool into its SKE desired +// state representation. +func NodePoolDesiredState(nodePool *infrav1.StackitManagedNodePool) ManagedNodePoolDesiredState { + if nodePool == nil { + return ManagedNodePoolDesiredState{} + } + spec := nodePool.Spec + + var labels map[string]string + if len(spec.Labels) > 0 { + labels = maps.Clone(spec.Labels) + } + + var taints []ManagedNodePoolTaint + if len(spec.Taints) > 0 { + taints = make([]ManagedNodePoolTaint, len(spec.Taints)) + for i, taint := range spec.Taints { + taints[i] = ManagedNodePoolTaint{ + Effect: string(taint.Effect), + Key: taint.Key, + Value: taint.Value, + } + } + } + + var allowSystemComponents *bool + if spec.AllowSystemComponents != nil { + allowSystemComponents = new(*spec.AllowSystemComponents) + } + var maxSurge *int32 + if spec.MaxSurge != nil { + maxSurge = new(*spec.MaxSurge) + } + var maxUnavailable *int32 + if spec.MaxUnavailable != nil { + maxUnavailable = new(*spec.MaxUnavailable) + } + + return ManagedNodePoolDesiredState{ + Name: spec.Name, + AvailabilityZones: slices.Clone(spec.AvailabilityZones), + MachineType: spec.Machine.Type, + ImageName: spec.Machine.Image.Name, + ImageVersion: spec.Machine.Image.Version, + Minimum: spec.Minimum, + Maximum: spec.Maximum, + VolumeSize: spec.Volume.Size, + VolumeType: spec.Volume.Type, + AllowSystemComponents: allowSystemComponents, + Labels: labels, + Taints: taints, + MaxSurge: maxSurge, + MaxUnavailable: maxUnavailable, + } +} + +// ComposeManagedCluster combines the cluster desired state and node-pool +// desired states into a single SKE input, ordering node pools deterministically +// by their SKE node-pool name. +func ComposeManagedCluster( + cluster ManagedClusterDesiredState, + nodePools []ManagedNodePoolDesiredState, +) (ManagedClusterInput, error) { + sortedPools := slices.Clone(nodePools) + slices.SortFunc(sortedPools, func(a, b ManagedNodePoolDesiredState) int { + return strings.Compare(a.Name, b.Name) + }) + + return ManagedClusterInput{ + Cluster: cluster, + NodePools: sortedPools, + }, nil +} diff --git a/cloud/services/ske/ske_test.go b/cloud/services/ske/ske_test.go new file mode 100644 index 0000000..f923a98 --- /dev/null +++ b/cloud/services/ske/ske_test.go @@ -0,0 +1,116 @@ +/* +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 ske + +import ( + "reflect" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +func TestComposeManagedClusterDeterministicOrder(t *testing.T) { + clusterObj := &infrav1.StackitManagedCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "capi-cluster", Namespace: "default"}, + Spec: infrav1.StackitManagedClusterSpec{ + ProjectID: "11111111-1111-1111-1111-111111111111", + Region: "eu01", + ClusterName: "ske-cluster", + Kubernetes: infrav1.StackitManagedClusterKubernetesSpec{ + Version: "1.35.3", + }, + Network: &infrav1.StackitManagedClusterNetworkSpec{ + ID: "22222222-2222-2222-2222-222222222222", + }, + Labels: map[string]string{"env": "test"}, + }, + } + + poolB := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{Name: "k8s-pool-a", Namespace: "default"}, + Spec: infrav1.StackitManagedNodePoolSpec{ + Name: "pool-b", + AvailabilityZones: []string{"eu01-2"}, + Machine: infrav1.StackitManagedNodePoolMachineSpec{ + Type: "c2i.4", + Image: infrav1.StackitManagedNodePoolImageSpec{ + Name: "flatcar", + Version: "4459.2.1", + }, + }, + Minimum: 2, + Maximum: 5, + Volume: infrav1.StackitManagedNodePoolVolumeSpec{ + Size: 50, + Type: "storage_premium_perf1", + }, + Taints: []infrav1.StackitManagedNodePoolTaint{ + { + Effect: corev1.TaintEffectNoSchedule, + Key: "dedicated", + Value: "worker", + }, + }, + }, + } + + poolA := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{Name: "k8s-pool-z", Namespace: "default"}, + Spec: infrav1.StackitManagedNodePoolSpec{ + Name: "pool-a", + AvailabilityZones: []string{"eu01-1"}, + Machine: infrav1.StackitManagedNodePoolMachineSpec{ + Type: "c2i.2", + Image: infrav1.StackitManagedNodePoolImageSpec{ + Name: "flatcar", + Version: "4459.2.1", + }, + }, + Minimum: 1, + Maximum: 3, + AllowSystemComponents: new(true), + Volume: infrav1.StackitManagedNodePoolVolumeSpec{ + Size: 30, + }, + }, + } + + clusterState := ClusterDesiredState(clusterObj) + first, err := ComposeManagedCluster(clusterState, []ManagedNodePoolDesiredState{ + NodePoolDesiredState(poolB), + NodePoolDesiredState(poolA), + }) + if err != nil { + t.Fatalf("ComposeManagedCluster() unexpected error: %v", err) + } + + second, err := ComposeManagedCluster(clusterState, []ManagedNodePoolDesiredState{ + NodePoolDesiredState(poolA), + NodePoolDesiredState(poolB), + }) + if err != nil { + t.Fatalf("ComposeManagedCluster() unexpected error: %v", err) + } + + if !reflect.DeepEqual(first, second) { + t.Fatalf("ComposeManagedCluster() not deterministic: %#v != %#v", first, second) + } + if len(first.NodePools) != 2 || first.NodePools[0].Name != "pool-a" || first.NodePools[1].Name != "pool-b" { + t.Fatalf("ComposeManagedCluster() node pool order = [%s, %s], want [pool-a, pool-b]", + first.NodePools[0].Name, first.NodePools[1].Name) + } + if first.Cluster.ClusterName != "ske-cluster" || first.Cluster.NetworkID != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("ComposeManagedCluster() cluster state = %#v", first.Cluster) + } +} diff --git a/cmd/manager/main.go b/cmd/manager/main.go index a777e2e..1698a99 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -202,6 +202,20 @@ func main() { setupLog.Error(err, "Failed to create controller", "controller", "stackitmachine") os.Exit(1) } + if err := (&controller.StackitManagedClusterReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "stackitmanagedcluster") + os.Exit(1) + } + if err := (&controller.StackitManagedNodePoolReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "stackitmanagednodepool") + os.Exit(1) + } if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err := webhookv1alpha1.SetupStackitClusterWebhookWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create webhook", "webhook", "StackitCluster") @@ -220,13 +234,6 @@ func main() { os.Exit(1) } } - if err := (&controller.StackitManagedClusterReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "Failed to create controller", "controller", "stackitmanagedcluster") - os.Exit(1) - } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml index ab26d9a..a465fa0 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml @@ -50,7 +50,99 @@ spec: type: object spec: description: spec defines the desired state of StackitManagedCluster + properties: + clusterName: + description: clusterName is the SKE cluster name in STACKIT. + maxLength: 11 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,9}[a-z0-9])?$ + type: string + controlPlaneEndpoint: + description: |- + controlPlaneEndpoint represents the endpoint used to communicate with the + control plane. + minProperties: 1 + properties: + host: + description: host is the hostname on which the API server is serving. + maxLength: 512 + minLength: 1 + type: string + port: + description: port is the port on which the API server is serving. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + credentialsSecretRef: + description: credentialsSecretRef references the Secret containing + STACKIT credentials. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + kubernetes: + description: kubernetes configures the SKE cluster Kubernetes version. + properties: + version: + description: |- + version is the full Kubernetes version used for the cluster + (major.minor.patch). + pattern: ^[0-9]+\.[0-9]+\.[0-9]+$ + type: string + required: + - version + type: object + labels: + additionalProperties: + type: string + description: labels are key-value pairs applied to the SKE cluster + object. + type: object + network: + description: network configures the STACKIT network for the SKE cluster. + properties: + id: + description: |- + id is the ID of the STACKIT network into which the cluster will be + deployed. + minLength: 1 + pattern: ^[0-9a-fA-F-]{36}$ + type: string + required: + - id + type: object + projectID: + description: projectID is the STACKIT project that owns the cluster + infrastructure. + minLength: 1 + pattern: ^[0-9a-fA-F-]{36}$ + type: string + region: + description: region is the STACKIT region in which infrastructure + is created. + minLength: 1 + pattern: ^[a-z]{2}[0-9]{2}$ + type: string + required: + - clusterName + - credentialsSecretRef + - kubernetes + - projectID + - region type: object + x-kubernetes-validations: + - message: credentialsSecretRef.name is required + rule: has(self.credentialsSecretRef.name) && size(self.credentialsSecretRef.name) + > 0 status: description: status defines the observed state of StackitManagedCluster properties: @@ -118,10 +210,12 @@ spec: initialization: description: initialization provides Cluster API v1beta2 contract state. + minProperties: 1 properties: provisioned: - description: provisioned is true when the cluster infrastructure - is provisioned. + description: |- + provisioned is true when the infrastructure provider reports that the + cluster infrastructure is fully provisioned. type: boolean type: object ready: diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml new file mode 100644 index 0000000..3f3ac39 --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml @@ -0,0 +1,280 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: stackitmanagednodepools.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: StackitManagedNodePool + listKind: StackitManagedNodePoolList + plural: stackitmanagednodepools + shortNames: + - stmnp + singular: stackitmanagednodepool + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.ready + name: Ready + type: boolean + - jsonPath: .status.replicas + name: Replicas + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: StackitManagedNodePool is the Schema for the stackitmanagednodepools + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of StackitManagedNodePool + properties: + allowSystemComponents: + description: allowSystemComponents allows system components to run + on this node pool. + type: boolean + availabilityZones: + description: availabilityZones is the list of availability zones for + the node pool. + items: + type: string + type: array + labels: + additionalProperties: + type: string + description: labels is a map of labels attached to all worker nodes + of this node pool. + maxProperties: 80 + type: object + machine: + description: machine configures the VM instance type and OS image + for the node pool. + properties: + image: + description: image configures the OS image for the node pool. + properties: + name: + description: name is the OS image name. + type: string + version: + description: version is the OS image version. + type: string + required: + - name + - version + type: object + type: + description: type selects the STACKIT machine type for the node + pool. + type: string + required: + - image + - type + type: object + maxSurge: + description: maxSurge is the maximum number of additional VMs created + during an update. + format: int32 + type: integer + maxUnavailable: + description: |- + maxUnavailable is the maximum number of VMs that can be unavailable during + an update. + format: int32 + type: integer + maximum: + description: maximum is the maximum number of nodes in the pool. + format: int32 + maximum: 1000 + minimum: 1 + type: integer + minimum: + description: minimum is the minimum number of nodes in the pool. + format: int32 + maximum: 1000 + type: integer + name: + description: name is the name of the node pool in SKE. + maxLength: 15 + type: string + providerIDList: + description: |- + providerIDList are the identification IDs of machine instances provided by + the provider. This field must match the provider IDs as seen on the Node + objects corresponding to a MachinePool's machine instances. + items: + maxLength: 512 + minLength: 1 + type: string + maxItems: 10000 + type: array + x-kubernetes-list-type: atomic + taints: + description: taints is a list of taints for all worker nodes of this + node pool. + items: + description: |- + StackitManagedNodePoolTaint defines a Kubernetes taint applied to nodes in + an SKE node pool. + properties: + effect: + description: effect is the taint effect to apply. + enum: + - NoSchedule + - PreferNoSchedule + - NoExecute + type: string + key: + description: key is the taint key to be applied to a node. + type: string + value: + description: value is the taint value corresponding to the taint + key. + type: string + required: + - effect + - key + type: object + type: array + volume: + description: volume configures the disk volume for each node in the + pool. + properties: + size: + description: size is the size of the volume in GiB. + format: int32 + maximum: 10240 + minimum: 20 + type: integer + type: + description: type is the volume performance class. + type: string + required: + - size + type: object + required: + - availabilityZones + - machine + - maximum + - minimum + - name + - volume + type: object + x-kubernetes-validations: + - message: minimum must be less than or equal to maximum + rule: self.minimum <= self.maximum + status: + description: status defines the observed state of StackitManagedNodePool + properties: + conditions: + description: conditions represent the current state of the StackitManagedNodePool + resource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + initialization: + description: initialization provides Cluster API v1beta2 contract + state. + minProperties: 1 + properties: + provisioned: + description: provisioned is true when the node pool infrastructure + is provisioned. + type: boolean + type: object + ready: + description: ready indicates that the node pool infrastructure is + ready. + type: boolean + replicas: + description: replicas is the most recently observed number of replicas. + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index ed5d88c..b5fdc61 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -11,7 +11,8 @@ resources: - bases/infrastructure.cluster.x-k8s.io_stackitmachines.yaml - bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml - bases/infrastructure.cluster.x-k8s.io_stackitmachinetemplates.yaml -- bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml + - bases/infrastructure.cluster.x-k8s.io_stackitmanagedclusters.yaml + - bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/capi_manager_aggregation_role.yaml b/config/rbac/capi_manager_aggregation_role.yaml index ce306ce..f8d9fd1 100644 --- a/config/rbac/capi_manager_aggregation_role.yaml +++ b/config/rbac/capi_manager_aggregation_role.yaml @@ -18,6 +18,8 @@ rules: - stackitclustertemplates - stackitmachines - stackitmachinetemplates + - stackitmanagedclusters + - stackitmanagednodepools verbs: - create - delete diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 6f88528..d3646a4 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -23,6 +23,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the cluster-api-provider-stackit itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- stackitmanagednodepool_admin_role.yaml +- stackitmanagednodepool_editor_role.yaml +- stackitmanagednodepool_viewer_role.yaml - stackitmanagedcluster_admin_role.yaml - stackitmanagedcluster_editor_role.yaml - stackitmanagedcluster_viewer_role.yaml @@ -38,5 +41,3 @@ resources: - stackitcluster_admin_role.yaml - stackitcluster_editor_role.yaml - stackitcluster_viewer_role.yaml - - diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9b4a3a7..ce62985 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -17,6 +17,7 @@ rules: - cluster.x-k8s.io resources: - clusters + - machinepools - machines verbs: - get @@ -35,6 +36,7 @@ rules: - stackitclusters - stackitmachines - stackitmanagedclusters + - stackitmanagednodepools verbs: - create - delete @@ -57,6 +59,7 @@ rules: - stackitclusters/status - stackitmachines/status - stackitmanagedclusters/status + - stackitmanagednodepools/status verbs: - get - patch diff --git a/config/rbac/stackitmanagednodepool_admin_role.yaml b/config/rbac/stackitmanagednodepool_admin_role.yaml new file mode 100644 index 0000000..d923c07 --- /dev/null +++ b/config/rbac/stackitmanagednodepool_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project cluster-api-provider-stackit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over infrastructure.cluster.x-k8s.io. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagednodepool-admin-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagednodepools + verbs: + - '*' +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagednodepools/status + verbs: + - get diff --git a/config/rbac/stackitmanagednodepool_editor_role.yaml b/config/rbac/stackitmanagednodepool_editor_role.yaml new file mode 100644 index 0000000..1eef3b7 --- /dev/null +++ b/config/rbac/stackitmanagednodepool_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project cluster-api-provider-stackit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the infrastructure.cluster.x-k8s.io. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagednodepool-editor-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagednodepools + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagednodepools/status + verbs: + - get diff --git a/config/rbac/stackitmanagednodepool_viewer_role.yaml b/config/rbac/stackitmanagednodepool_viewer_role.yaml new file mode 100644 index 0000000..b852ae6 --- /dev/null +++ b/config/rbac/stackitmanagednodepool_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project cluster-api-provider-stackit itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to infrastructure.cluster.x-k8s.io resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagednodepool-viewer-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagednodepools + verbs: + - get + - list + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - stackitmanagednodepools/status + verbs: + - get diff --git a/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml b/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml index f195d92..97ccd1b 100644 --- a/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml +++ b/config/samples/infrastructure_v1alpha1_stackitmanagedcluster.yaml @@ -5,4 +5,13 @@ metadata: app.kubernetes.io/name: cluster-api-provider-stackit app.kubernetes.io/managed-by: kustomize name: stackitmanagedcluster-sample -spec: {} +spec: + projectID: 00000000-0000-0000-0000-000000000000 + region: eu01 + clusterName: ske-sample + credentialsSecretRef: + name: stackit-credentials + kubernetes: + version: 1.35.3 + network: + id: 11111111-1111-1111-1111-111111111111 diff --git a/config/samples/infrastructure_v1alpha1_stackitmanagednodepool.yaml b/config/samples/infrastructure_v1alpha1_stackitmanagednodepool.yaml new file mode 100644 index 0000000..4ea299a --- /dev/null +++ b/config/samples/infrastructure_v1alpha1_stackitmanagednodepool.yaml @@ -0,0 +1,22 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: StackitManagedNodePool +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-stackit + app.kubernetes.io/managed-by: kustomize + name: stackitmanagednodepool-sample +spec: + name: np-default + availabilityZones: + - eu01-1 + machine: + type: c2i.2 + image: + name: flatcar + version: 4459.2.1 + minimum: 1 + maximum: 3 + volume: + size: 50 + type: storage_premium_perf1 + allowSystemComponents: true diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 0940324..6d8c099 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -5,4 +5,5 @@ resources: - infrastructure_v1alpha1_stackitclustertemplate.yaml - infrastructure_v1alpha1_stackitmachinetemplate.yaml - infrastructure_v1alpha1_stackitmanagedcluster.yaml +- infrastructure_v1alpha1_stackitmanagednodepool.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/controller/stackitmanagedcluster_controller.go b/controller/stackitmanagedcluster_controller.go index 569ba6e..6d6794b 100644 --- a/controller/stackitmanagedcluster_controller.go +++ b/controller/stackitmanagedcluster_controller.go @@ -41,10 +41,10 @@ type StackitManagedClusterReconciler struct { // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters/status,verbs=get;update;patch // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters/finalizers,verbs=update +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools,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=machinepools,verbs=get;list;watch -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. func (r *StackitManagedClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) @@ -61,7 +61,7 @@ func (r *StackitManagedClusterReconciler) Reconcile(ctx context.Context, req ctr return ctrl.Result{}, nil } -// SetupWithManager sets up the controller with the Manager. +// SetupWithManager registers the controller with the manager. func (r *StackitManagedClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&infrav1.StackitManagedCluster{}). @@ -69,6 +69,10 @@ func (r *StackitManagedClusterReconciler) SetupWithManager(mgr ctrl.Manager) err &clusterv1.Cluster{}, handler.EnqueueRequestsFromMapFunc(r.filterForStackitManagedClusterResources), ). + Watches( + &infrav1.StackitManagedNodePool{}, + handler.EnqueueRequestsFromMapFunc(r.stackitManagedClusterRequestsForNodePool), + ). Named("stackitmanagedcluster"). Complete(r) } @@ -79,7 +83,7 @@ func (r *StackitManagedClusterReconciler) filterForStackitManagedClusterResource return nil } ref := cluster.Spec.InfrastructureRef - if ref.APIGroup != infrav1.GroupVersion.Group || ref.Kind != "StackitManagedCluster" || ref.Name == "" { + if !isStackitManagedClusterRef(ref) { return nil } return []reconcile.Request{{ @@ -89,3 +93,30 @@ func (r *StackitManagedClusterReconciler) filterForStackitManagedClusterResource }, }} } + +func (r *StackitManagedClusterReconciler) stackitManagedClusterRequestsForNodePool(ctx context.Context, obj client.Object) []reconcile.Request { + nodePool, ok := obj.(*infrav1.StackitManagedNodePool) + if !ok || r.Client == nil { + return nil + } + + machinePool, err := getMachinePoolForNodePool(ctx, r.Client, nodePool) + if err != nil { + logf.FromContext(ctx).Error(err, "Failed to get MachinePool for StackitManagedNodePool watch", "nodePool", client.ObjectKeyFromObject(nodePool)) + return nil + } + if machinePool == nil { + return nil + } + + cluster, err := getClusterForMachinePool(ctx, r.Client, machinePool) + if err != nil { + logf.FromContext(ctx).Error(err, "Failed to get Cluster for StackitManagedNodePool watch", "machinePool", client.ObjectKeyFromObject(machinePool)) + return nil + } + if cluster == nil { + return nil + } + + return r.filterForStackitManagedClusterResources(ctx, cluster) +} diff --git a/controller/stackitmanagedcluster_controller_test.go b/controller/stackitmanagedcluster_controller_test.go index 233cbba..dfef7e8 100644 --- a/controller/stackitmanagedcluster_controller_test.go +++ b/controller/stackitmanagedcluster_controller_test.go @@ -18,19 +18,57 @@ package controller import ( "context" + "fmt" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" ) +func validManagedClusterSpec() infrav1.StackitManagedClusterSpec { + return infrav1.StackitManagedClusterSpec{ + ProjectID: testProjectID, + Region: "eu01", + ClusterName: "ske-test", + CredentialsSecretRef: corev1.SecretReference{ + Name: "stackit-credentials", + }, + Kubernetes: infrav1.StackitManagedClusterKubernetesSpec{ + Version: "1.35.3", + }, + } +} + +func validManagedNodePoolSpec() infrav1.StackitManagedNodePoolSpec { + return infrav1.StackitManagedNodePoolSpec{ + Name: "np-default", + AvailabilityZones: []string{"eu01-1"}, + Machine: infrav1.StackitManagedNodePoolMachineSpec{ + Type: "c2i.2", + Image: infrav1.StackitManagedNodePoolImageSpec{ + Name: "flatcar", + Version: "4459.2.1", + }, + }, + Minimum: 1, + Maximum: 3, + Volume: infrav1.StackitManagedNodePoolVolumeSpec{ + Size: 50, + Type: "storage_premium_perf1", + }, + } +} + var _ = Describe("StackitManagedCluster Controller", func() { Context("When reconciling a resource", func() { const resourceName = "test-resource" @@ -39,32 +77,31 @@ var _ = Describe("StackitManagedCluster Controller", func() { typeNamespacedName := types.NamespacedName{ Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Namespace: "default", } - stackitmanagedcluster := &infrav1.StackitManagedCluster{} + managedCluster := &infrav1.StackitManagedCluster{} BeforeEach(func() { By("creating the custom resource for the Kind StackitManagedCluster") - err := k8sClient.Get(ctx, typeNamespacedName, stackitmanagedcluster) + err := k8sClient.Get(ctx, typeNamespacedName, managedCluster) if err != nil && errors.IsNotFound(err) { resource := &infrav1.StackitManagedCluster{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: "default", }, - // TODO(user): Specify other spec details if needed. + Spec: validManagedClusterSpec(), } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } }) AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. resource := &infrav1.StackitManagedCluster{} err := k8sClient.Get(ctx, typeNamespacedName, resource) Expect(err).NotTo(HaveOccurred()) - By("Cleanup the specific resource instance StackitManagedCluster") + By("cleaning up the test StackitManagedCluster") Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) It("should successfully reconcile the resource", func() { @@ -147,4 +184,369 @@ var _ = Describe("StackitManagedCluster Controller", func() { Expect(requests).To(BeNil()) }) }) + + Context("StackitManagedCluster API validation", func() { + newManagedCluster := func(name string) *infrav1.StackitManagedCluster { + return &infrav1.StackitManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: validManagedClusterSpec(), + } + } + + It("accepts a valid minimal Spec", func() { + cluster := newManagedCluster("valid-minimal-mc") + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + }) + + It("accepts optional network, labels, and controlPlaneEndpoint", func() { + cluster := newManagedCluster("valid-optional-mc") + cluster.Spec.Network = &infrav1.StackitManagedClusterNetworkSpec{ + ID: testNetworkID, + } + cluster.Spec.Labels = map[string]string{"environment": "dev"} + cluster.Spec.ControlPlaneEndpoint = clusterv1.APIEndpoint{ + Host: "api.ske.example.com", + Port: 6443, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + }) + + It("rejects an invalid project ID", func() { + cluster := newManagedCluster("invalid-project-mc") + cluster.Spec.ProjectID = "not-a-uuid" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects an invalid region", func() { + cluster := newManagedCluster("invalid-region-mc") + cluster.Spec.Region = "eu-central-1" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects an invalid network ID when network is specified", func() { + cluster := newManagedCluster("invalid-network-mc") + cluster.Spec.Network = &infrav1.StackitManagedClusterNetworkSpec{ + ID: "invalid-network-id", + } + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects missing credentials secret name", func() { + cluster := newManagedCluster("missing-creds-mc") + cluster.Spec.CredentialsSecretRef.Name = "" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("credentialsSecretRef.name is required")) + }) + + It("accepts a valid 11-character cluster name", func() { + cluster := newManagedCluster("valid-11char-mc") + cluster.Spec.ClusterName = "ske-clust01" + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + }) + + It("accepts a 1-character cluster name", func() { + cluster := newManagedCluster("valid-1char-mc") + cluster.Spec.ClusterName = "a" + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + }) + + It("rejects a cluster name longer than 11 characters", func() { + cluster := newManagedCluster("long-name-mc") + cluster.Spec.ClusterName = "ske-cluster1" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects an uppercase cluster name", func() { + cluster := newManagedCluster("uppercase-name-mc") + cluster.Spec.ClusterName = "SkeCluster" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects a cluster name containing an underscore", func() { + cluster := newManagedCluster("underscore-name-mc") + cluster.Spec.ClusterName = "ske_cluster" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects a cluster name with a leading hyphen", func() { + cluster := newManagedCluster("leading-hyphen-mc") + cluster.Spec.ClusterName = "-skecluster" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects a cluster name with a trailing hyphen", func() { + cluster := newManagedCluster("trailing-hyphen-mc") + cluster.Spec.ClusterName = "skecluster-" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("accepts Kubernetes version 1.35.3", func() { + cluster := newManagedCluster("valid-k8s-ver-mc") + cluster.Spec.Kubernetes.Version = "1.35.3" + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + }) + + It("rejects Kubernetes version 1.35 without patch version", func() { + cluster := newManagedCluster("short-k8s-ver-mc") + cluster.Spec.Kubernetes.Version = "1.35" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects Kubernetes version v1.35.3 with leading v", func() { + cluster := newManagedCluster("v-prefix-k8s-ver-mc") + cluster.Spec.Kubernetes.Version = "v1.35.3" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects Kubernetes version latest", func() { + cluster := newManagedCluster("latest-k8s-ver-mc") + cluster.Spec.Kubernetes.Version = "latest" + err := k8sClient.Create(ctx, cluster) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("validates status.initialization pointer shape and MinProperties=1", func() { + cluster := newManagedCluster("init-status-mc") + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + + cluster.Status.Initialization = infrav1.StackitManagedClusterInitializationStatus{ + Provisioned: new(true), + } + Expect(k8sClient.Status().Update(ctx, cluster)).To(Succeed()) + + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), got)).To(Succeed()) + Expect(got.Status.Initialization.Provisioned).NotTo(BeNil()) + Expect(*got.Status.Initialization.Provisioned).To(BeTrue()) + + raw := &unstructured.Unstructured{} + raw.SetAPIVersion(infrav1.GroupVersion.String()) + raw.SetKind("StackitManagedCluster") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), raw)).To(Succeed()) + Expect(unstructured.SetNestedMap(raw.Object, map[string]any{}, "status", "initialization")).To(Succeed()) + Expect(errors.IsInvalid(k8sClient.Status().Update(ctx, raw))).To(BeTrue()) + }) + }) + + Context("StackitManagedNodePool API validation", func() { + newManagedNodePool := func(name string) *infrav1.StackitManagedNodePool { + return &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: validManagedNodePoolSpec(), + } + } + + newUnstructuredManagedNodePool := func(name string) *unstructured.Unstructured { + u := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": infrav1.GroupVersion.String(), + "kind": "StackitManagedNodePool", + "metadata": map[string]any{ + "name": name, + "namespace": "default", + }, + "spec": map[string]any{ + "name": "np-default", + "availabilityZones": []any{"eu01-1"}, + "machine": map[string]any{ + "type": "c2i.2", + "image": map[string]any{ + "name": "flatcar", + "version": "4459.2.1", + }, + }, + "minimum": int64(1), + "maximum": int64(3), + "volume": map[string]any{ + "size": int64(50), + }, + }, + }, + } + return u + } + + It("accepts a valid minimal required Spec", func() { + pool := newManagedNodePool("valid-minimal-np") + Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, pool) }) + }) + + It("accepts optional node-pool fields and valid taints", func() { + pool := newManagedNodePool("valid-optional-np") + pool.Spec.AllowSystemComponents = new(true) + pool.Spec.Labels = map[string]string{"role": "worker"} + pool.Spec.MaxSurge = new(int32(2)) + pool.Spec.MaxUnavailable = new(int32(1)) + pool.Spec.Taints = []infrav1.StackitManagedNodePoolTaint{ + { + Effect: corev1.TaintEffectNoSchedule, + Key: "dedicated", + Value: "gpu", + }, + } + pool.Spec.ProviderIDList = []string{"stackit://11111111-1111-1111-1111-111111111111"} + Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, pool) }) + }) + + It("rejects a node-pool name over 15 characters", func() { + pool := newManagedNodePool("long-name-np") + pool.Spec.Name = "nodepool-too-long" + err := k8sClient.Create(ctx, pool) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects missing required availabilityZones", func() { + pool := newManagedNodePool("missing-azs-np") + pool.Spec.AvailabilityZones = nil + err := k8sClient.Create(ctx, pool) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects missing machine", func() { + raw := newUnstructuredManagedNodePool("missing-machine-np") + unstructured.RemoveNestedField(raw.Object, "spec", "machine") + err := k8sClient.Create(ctx, raw) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects missing required machine image fields", func() { + rawMissingImage := newUnstructuredManagedNodePool("missing-image-np") + unstructured.RemoveNestedField(rawMissingImage.Object, "spec", "machine", "image") + Expect(errors.IsInvalid(k8sClient.Create(ctx, rawMissingImage))).To(BeTrue()) + + rawMissingImageName := newUnstructuredManagedNodePool("missing-image-name-np") + unstructured.RemoveNestedField(rawMissingImageName.Object, "spec", "machine", "image", "name") + Expect(errors.IsInvalid(k8sClient.Create(ctx, rawMissingImageName))).To(BeTrue()) + + rawMissingImageVersion := newUnstructuredManagedNodePool("missing-image-ver-np") + unstructured.RemoveNestedField(rawMissingImageVersion.Object, "spec", "machine", "image", "version") + Expect(errors.IsInvalid(k8sClient.Create(ctx, rawMissingImageVersion))).To(BeTrue()) + }) + + It("rejects missing volume", func() { + raw := newUnstructuredManagedNodePool("missing-volume-np") + unstructured.RemoveNestedField(raw.Object, "spec", "volume") + err := k8sClient.Create(ctx, raw) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects node-pool labels exceeding MaxProperties=80", func() { + pool := newManagedNodePool("too-many-labels-np") + pool.Spec.Labels = make(map[string]string, 81) + for i := range 81 { + pool.Spec.Labels[fmt.Sprintf("k%d", i)] = "v" + } + Expect(errors.IsInvalid(k8sClient.Create(ctx, pool))).To(BeTrue()) + }) + + It("rejects minimum greater than maximum", func() { + pool := newManagedNodePool("min-gt-max-np") + pool.Spec.Minimum = 5 + pool.Spec.Maximum = 3 + err := k8sClient.Create(ctx, pool) + Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("minimum must be less than or equal to maximum")) + }) + + It("rejects numeric limits outside verified SKE bounds", func() { + poolMaxZero := newManagedNodePool("max-zero-np") + poolMaxZero.Spec.Minimum = 0 + poolMaxZero.Spec.Maximum = 0 + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolMaxZero))).To(BeTrue()) + + poolMaxOver1000 := newManagedNodePool("max-over-1000-np") + poolMaxOver1000.Spec.Maximum = 1001 + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolMaxOver1000))).To(BeTrue()) + + poolMinOver1000 := newManagedNodePool("min-over-1000-np") + poolMinOver1000.Spec.Minimum = 1001 + poolMinOver1000.Spec.Maximum = 1000 + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolMinOver1000))).To(BeTrue()) + + poolVolTooSmall := newManagedNodePool("vol-small-np") + poolVolTooSmall.Spec.Volume.Size = 19 + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolVolTooSmall))).To(BeTrue()) + + poolVolTooLarge := newManagedNodePool("vol-large-np") + poolVolTooLarge.Spec.Volume.Size = 10241 + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolVolTooLarge))).To(BeTrue()) + }) + + It("rejects an invalid taint effect", func() { + pool := newManagedNodePool("invalid-taint-np") + pool.Spec.Taints = []infrav1.StackitManagedNodePoolTaint{ + { + Effect: corev1.TaintEffect("InvalidEffect"), + Key: "dedicated", + }, + } + err := k8sClient.Create(ctx, pool) + Expect(errors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects invalid providerIDList items violating CAPI contract bounds", func() { + poolEmptyItem := newManagedNodePool("empty-providerid-np") + poolEmptyItem.Spec.ProviderIDList = []string{""} + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolEmptyItem))).To(BeTrue()) + + poolTooLongItem := newManagedNodePool("long-providerid-np") + poolTooLongItem.Spec.ProviderIDList = []string{strings.Repeat("a", 513)} + Expect(errors.IsInvalid(k8sClient.Create(ctx, poolTooLongItem))).To(BeTrue()) + }) + + It("validates status.initialization pointer shape, MinProperties=1, and zero replicas serialization", func() { + pool := newManagedNodePool("init-status-np") + Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, pool) }) + + pool.Status.Initialization = infrav1.StackitManagedNodePoolInitializationStatus{ + Provisioned: new(true), + } + pool.Status.Replicas = 0 + Expect(k8sClient.Status().Update(ctx, pool)).To(Succeed()) + + got := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pool), got)).To(Succeed()) + Expect(got.Status.Initialization.Provisioned).NotTo(BeNil()) + Expect(*got.Status.Initialization.Provisioned).To(BeTrue()) + Expect(got.Status.Replicas).To(Equal(int32(0))) + + raw := &unstructured.Unstructured{} + raw.SetAPIVersion(infrav1.GroupVersion.String()) + raw.SetKind("StackitManagedNodePool") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pool), raw)).To(Succeed()) + replicasVal, found, err := unstructured.NestedInt64(raw.Object, "status", "replicas") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(replicasVal).To(Equal(int64(0))) + + Expect(unstructured.SetNestedMap(raw.Object, map[string]any{}, "status", "initialization")).To(Succeed()) + Expect(errors.IsInvalid(k8sClient.Status().Update(ctx, raw))).To(BeTrue()) + }) + }) }) diff --git a/controller/stackitmanagednodepool_controller.go b/controller/stackitmanagednodepool_controller.go new file mode 100644 index 0000000..cb15092 --- /dev/null +++ b/controller/stackitmanagednodepool_controller.go @@ -0,0 +1,251 @@ +/* +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" + "errors" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "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/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" +) + +// StackitManagedNodePoolReconciler reconciles a StackitManagedNodePool object. +// +// Only the managed-cluster reconciliation path performs mutating SKE cluster +// API calls; this reconciler resolves CAPI ownership relationships and leaves +// SKE cluster payload mutation to StackitManagedClusterReconciler. +type StackitManagedNodePoolReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters,verbs=get;list;watch +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machinepools,verbs=get;list;watch +// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch + +func (r *StackitManagedNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + nodePool := &infrav1.StackitManagedNodePool{} + if err := r.Get(ctx, req.NamespacedName, nodePool); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + machinePool, err := getMachinePoolForNodePool(ctx, r.Client, nodePool) + if err != nil { + return ctrl.Result{}, fmt.Errorf("get owning MachinePool: %w", err) + } + if machinePool == nil { + log.Info("StackitManagedNodePool has no owning MachinePool yet, requeueing") + return ctrl.Result{}, nil + } + + cluster, err := getClusterForMachinePool(ctx, r.Client, machinePool) + if err != nil { + return ctrl.Result{}, fmt.Errorf("get Cluster for MachinePool: %w", err) + } + if cluster == nil { + log.Info("MachinePool has no owning Cluster yet, requeueing", "machinePool", machinePool.Name) + return ctrl.Result{}, nil + } + + if !isStackitManagedClusterRef(cluster.Spec.InfrastructureRef) { + log.Info("Cluster does not reference a StackitManagedCluster, skipping", "cluster", cluster.Name) + return ctrl.Result{}, nil + } + + managedCluster, err := getStackitManagedClusterForCluster(ctx, r.Client, cluster) + if err != nil { + return ctrl.Result{}, err + } + if managedCluster == nil { + log.Info("StackitManagedCluster not found, requeueing", "managedCluster", cluster.Spec.InfrastructureRef.Name) + return ctrl.Result{}, nil + } + + if !nodePool.DeletionTimestamp.IsZero() { + log.Info( + "Reconciling deleting StackitManagedNodePool", + "cluster", cluster.Name, + "machinePool", machinePool.Name, + "managedCluster", managedCluster.Name, + ) + return ctrl.Result{}, nil + } + + log.Info( + "Reconciling StackitManagedNodePool", + "cluster", cluster.Name, + "machinePool", machinePool.Name, + "managedCluster", managedCluster.Name, + ) + + return ctrl.Result{}, nil +} + +// SetupWithManager registers the controller with the manager. +func (r *StackitManagedNodePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&infrav1.StackitManagedNodePool{}). + Watches( + &clusterv1.MachinePool{}, + handler.EnqueueRequestsFromMapFunc(r.stackitManagedNodePoolRequestsForMachinePool), + ). + Named("stackitmanagednodepool"). + Complete(r) +} + +func (r *StackitManagedNodePoolReconciler) stackitManagedNodePoolRequestsForMachinePool(_ context.Context, obj client.Object) []reconcile.Request { + machinePool, ok := obj.(*clusterv1.MachinePool) + if !ok { + return nil + } + return stackitManagedNodePoolRequestForMachinePool(machinePool) +} + +func stackitManagedNodePoolRequestForMachinePool(machinePool *clusterv1.MachinePool) []reconcile.Request { + if machinePool == nil { + return nil + } + ref := machinePool.Spec.Template.Spec.InfrastructureRef + if !isStackitManagedNodePoolRef(ref, "") { + return nil + } + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: machinePool.Namespace, + Name: ref.Name, + }, + }} +} + +func isStackitManagedNodePoolRef(ref clusterv1.ContractVersionedObjectReference, expectedName string) bool { + return ref.APIGroup == infrav1.GroupVersion.Group && + ref.Kind == "StackitManagedNodePool" && + ref.Name != "" && + (expectedName == "" || ref.Name == expectedName) +} + +func isStackitManagedClusterRef(ref clusterv1.ContractVersionedObjectReference) bool { + return ref.APIGroup == infrav1.GroupVersion.Group && + ref.Kind == "StackitManagedCluster" && + ref.Name != "" +} + +func getMachinePoolForNodePool( + ctx context.Context, + c client.Client, + nodePool *infrav1.StackitManagedNodePool, +) (*clusterv1.MachinePool, error) { + if nodePool == nil { + return nil, nil + } + + machinePool, err := clusterutil.GetOwnerMachinePool(ctx, c, nodePool.ObjectMeta) + if err != nil && !apierrors.IsNotFound(err) { + return nil, err + } + if machinePool != nil { + if !isStackitManagedNodePoolRef(machinePool.Spec.Template.Spec.InfrastructureRef, nodePool.Name) { + return nil, nil + } + return machinePool, nil + } + + machinePools := &clusterv1.MachinePoolList{} + if err := c.List(ctx, machinePools, client.InNamespace(nodePool.Namespace)); err != nil { + return nil, err + } + for i := range machinePools.Items { + mp := &machinePools.Items[i] + if isStackitManagedNodePoolRef(mp.Spec.Template.Spec.InfrastructureRef, nodePool.Name) { + return mp, nil + } + } + return nil, nil +} + +func getClusterForMachinePool( + ctx context.Context, + c client.Client, + machinePool *clusterv1.MachinePool, +) (*clusterv1.Cluster, error) { + if machinePool == nil { + return nil, nil + } + + if machinePool.Labels[clusterv1.ClusterNameLabel] != "" { + cluster, err := clusterutil.GetClusterFromMetadata(ctx, c, machinePool.ObjectMeta) + switch { + case errors.Is(err, clusterutil.ErrNoCluster) || apierrors.IsNotFound(err): + return nil, nil + case err != nil: + return nil, err + default: + return cluster, nil + } + } + + if machinePool.Spec.ClusterName != "" { + cluster, err := clusterutil.GetClusterByName(ctx, c, machinePool.Namespace, machinePool.Spec.ClusterName) + if apierrors.IsNotFound(err) { + return nil, nil + } + return cluster, err + } + + return nil, nil +} + +func getStackitManagedClusterForCluster( + ctx context.Context, + c client.Client, + cluster *clusterv1.Cluster, +) (*infrav1.StackitManagedCluster, error) { + if cluster == nil || !isStackitManagedClusterRef(cluster.Spec.InfrastructureRef) { + return nil, nil + } + managedCluster := &infrav1.StackitManagedCluster{} + key := types.NamespacedName{ + Namespace: cluster.Namespace, + Name: cluster.Spec.InfrastructureRef.Name, + } + if err := c.Get(ctx, key, managedCluster); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get StackitManagedCluster %s: %w", key, err) + } + return managedCluster, nil +} diff --git a/controller/stackitmanagednodepool_controller_test.go b/controller/stackitmanagednodepool_controller_test.go new file mode 100644 index 0000000..b94af96 --- /dev/null +++ b/controller/stackitmanagednodepool_controller_test.go @@ -0,0 +1,349 @@ +/* +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" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +var _ = Describe("StackitManagedNodePool Controller", func() { + const namespace = "default" + + createManagedClusterChain := func( + clusterName, managedClusterName, machinePoolName, nodePoolName string, + ) (*clusterv1.Cluster, *infrav1.StackitManagedCluster, *clusterv1.MachinePool, *infrav1.StackitManagedNodePool) { + managedCluster := &infrav1.StackitManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: managedClusterName, + Namespace: namespace, + }, + Spec: validManagedClusterSpec(), + } + Expect(k8sClient.Create(ctx, managedCluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, managedCluster) }) + + cluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedCluster", + Name: managedClusterName, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + + machinePool := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: machinePoolName, + Namespace: namespace, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: clusterName, + }, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: clusterName, + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + ClusterName: clusterName, + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: new(""), + }, + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: nodePoolName, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, machinePool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, machinePool) }) + + nodePool := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodePoolName, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: clusterv1.GroupVersion.String(), + Kind: "MachinePool", + Name: machinePool.Name, + UID: machinePool.UID, + }, + }, + }, + Spec: validManagedNodePoolSpec(), + } + Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) + + return cluster, managedCluster, machinePool, nodePool + } + + Context("When reconciling a StackitManagedNodePool", func() { + It("returns nil when the resource is not found", func() { + reconciler := &StackitManagedNodePoolReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := reconciler.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: namespace, + Name: "non-existent-nodepool", + }, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns nil without fabricating status when no owning MachinePool exists", func() { + nodePool := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "orphan-nodepool", + Namespace: namespace, + }, + Spec: validManagedNodePoolSpec(), + } + Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) + + reconciler := &StackitManagedNodePoolReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) + + got := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + Expect(got.Status.Initialization.Provisioned).To(BeNil()) + Expect(got.Status.Replicas).To(BeZero()) + Expect(got.Spec.ProviderIDList).To(BeEmpty()) + Expect(got.Finalizers).To(BeEmpty()) + }) + + It("resolves MachinePool, Cluster, and StackitManagedCluster without mutating status or adding finalizers", func() { + _, _, _, nodePool := createManagedClusterChain( + "np-reconcile-cluster", + "np-reconcile-mc", + "np-reconcile-mp", + "np-reconcile-pool", + ) + + reconciler := &StackitManagedNodePoolReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) + + got := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + Expect(got.Status.Initialization.Provisioned).To(BeNil()) + Expect(got.Status.Replicas).To(BeZero()) + Expect(got.Spec.ProviderIDList).To(BeEmpty()) + Expect(got.Finalizers).To(BeEmpty()) + }) + }) + + Context("When mapping MachinePool events to StackitManagedNodePool", func() { + reconciler := &StackitManagedNodePoolReconciler{} + + It("enqueues the referenced StackitManagedNodePool when infrastructureRef matches", func() { + machinePool := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mp-0", + Namespace: namespace, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: "my-cluster", + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: "my-nodepool", + }, + }, + }, + }, + } + + requests := reconciler.stackitManagedNodePoolRequestsForMachinePool(context.Background(), machinePool) + Expect(requests).To(Equal([]reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: namespace, + Name: "my-nodepool", + }, + }})) + }) + + It("ignores MachinePools with unrelated infrastructureRef Kind", func() { + machinePool := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mp-0", + Namespace: namespace, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: "my-cluster", + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitMachineTemplate", + Name: "unmanaged-template", + }, + }, + }, + }, + } + + requests := reconciler.stackitManagedNodePoolRequestsForMachinePool(context.Background(), machinePool) + Expect(requests).To(BeEmpty()) + }) + }) + + Context("When mapping StackitManagedNodePool events to StackitManagedCluster", func() { + It("enqueues the owning StackitManagedCluster via MachinePool and Cluster", func() { + _, managedCluster, _, nodePool := createManagedClusterChain( + "map-chain-cluster", + "map-chain-mc", + "map-chain-mp", + "map-chain-np", + ) + + clusterReconciler := &StackitManagedClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + requests := clusterReconciler.stackitManagedClusterRequestsForNodePool(ctx, nodePool) + Expect(requests).To(Equal([]reconcile.Request{{ + NamespacedName: client.ObjectKeyFromObject(managedCluster), + }})) + }) + + It("does not enqueue anything when the Cluster references an unmanaged StackitCluster", func() { + unmanagedCluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unmanaged-capi-cluster", + Namespace: namespace, + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitCluster", + Name: "unmanaged-stackit-cluster", + }, + }, + } + Expect(k8sClient.Create(ctx, unmanagedCluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, unmanagedCluster) }) + + machinePool := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unmanaged-mp", + Namespace: namespace, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: unmanagedCluster.Name, + }, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: unmanagedCluster.Name, + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + ClusterName: unmanagedCluster.Name, + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: new(""), + }, + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: "unmanaged-np", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, machinePool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, machinePool) }) + + nodePool := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unmanaged-np", + Namespace: namespace, + }, + Spec: validManagedNodePoolSpec(), + } + Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) + + clusterReconciler := &StackitManagedClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + requests := clusterReconciler.stackitManagedClusterRequestsForNodePool(ctx, nodePool) + Expect(requests).To(BeEmpty()) + }) + + It("does not enqueue anything for an unassociated StackitManagedNodePool", func() { + nodePool := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unlinked-np", + Namespace: namespace, + }, + Spec: validManagedNodePoolSpec(), + } + Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) + + clusterReconciler := &StackitManagedClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + requests := clusterReconciler.stackitManagedClusterRequestsForNodePool(ctx, nodePool) + Expect(requests).To(BeEmpty()) + }) + }) +}) From a8f1556ec685306779ebe7de23e0b502cf06cd29 Mon Sep 17 00:00:00 2001 From: Rana Ashar Mehmood Date: Mon, 9 Nov 2026 02:58:36 +0100 Subject: [PATCH 3/3] feat: implement SKE managed cluster and node pool lifecycle --- api/v1alpha1/stackitmanagedcluster_types.go | 13 + api/v1alpha1/stackitmanagednodepool_types.go | 12 + cloud/client.go | 12 +- cloud/sdk_client.go | 41 +- cloud/services/ske/reconcile.go | 702 +++++++++++ cloud/services/ske/reconcile_test.go | 1108 +++++++++++++++++ cloud/services/ske/service.go | 58 + cloud/services/ske/ske.go | 252 ++++ cmd/manager/main.go | 5 +- ...ster.x-k8s.io_stackitmanagednodepools.yaml | 7 + config/rbac/role.yaml | 1 + .../stackitmanagedcluster_controller.go | 270 +++- .../stackitmanagedcluster_controller_test.go | 1043 +++++++++++++++- .../stackitmanagednodepool_controller.go | 542 +++++++- .../stackitmanagednodepool_controller_test.go | 329 +++-- go.mod | 1 + go.sum | 2 + scope/managed_cluster_scope.go | 239 ++++ scope/managed_nodepool_scope.go | 76 ++ util/reconcile.go | 37 + util/tags.go | 23 + 21 files changed, 4518 insertions(+), 255 deletions(-) create mode 100644 cloud/services/ske/reconcile.go create mode 100644 cloud/services/ske/reconcile_test.go create mode 100644 cloud/services/ske/service.go create mode 100644 scope/managed_cluster_scope.go create mode 100644 scope/managed_nodepool_scope.go diff --git a/api/v1alpha1/stackitmanagedcluster_types.go b/api/v1alpha1/stackitmanagedcluster_types.go index 8fe7e47..5b65c1d 100644 --- a/api/v1alpha1/stackitmanagedcluster_types.go +++ b/api/v1alpha1/stackitmanagedcluster_types.go @@ -22,6 +22,19 @@ import ( clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" ) +// ManagedClusterFinalizer allows StackitManagedClusterReconciler to clean up +// external SKE resources before the StackitManagedCluster is removed from the +// API server. +const ( + ManagedClusterFinalizer = "infrastructure.cluster.x-k8s.io/stackitmanagedcluster" + LegacyManagedClusterFinalizer = "stackitmanagedcluster.infrastructure.cluster.x-k8s.io" +) + +// Condition types maintained by the StackitManagedClusterReconciler. +const ( + ManagedClusterReadyCondition = "Ready" +) + // StackitManagedClusterSpec defines the desired state of StackitManagedCluster. // +kubebuilder:validation:XValidation:rule="has(self.credentialsSecretRef.name) && size(self.credentialsSecretRef.name) > 0",message="credentialsSecretRef.name is required" type StackitManagedClusterSpec struct { diff --git a/api/v1alpha1/stackitmanagednodepool_types.go b/api/v1alpha1/stackitmanagednodepool_types.go index 51c46a9..3ff91b5 100644 --- a/api/v1alpha1/stackitmanagednodepool_types.go +++ b/api/v1alpha1/stackitmanagednodepool_types.go @@ -21,12 +21,24 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// ManagedNodePoolFinalizer allows StackitManagedNodePoolReconciler to coordinate +// node pool lifecycle before the StackitManagedNodePool is removed from the API +// server. +const ( + ManagedNodePoolFinalizer = "infrastructure.cluster.x-k8s.io/stackitmanagednodepool" + ManagedNodePoolReadyCondition = "Ready" + ManagedNodePoolBootstrapName = "capstk-system" +) + // StackitManagedNodePoolSpec defines the desired state of StackitManagedNodePool. // +kubebuilder:validation:XValidation:rule="self.minimum <= self.maximum",message="minimum must be less than or equal to maximum" type StackitManagedNodePoolSpec struct { // name is the name of the node pool in SKE. // +required + // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=15 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="name is immutable" + // +kubebuilder:validation:XValidation:rule="self != 'capstk-system'",message="name 'capstk-system' is reserved for the provider bootstrap node pool" Name string `json:"name"` // availabilityZones is the list of availability zones for the node pool. diff --git a/cloud/client.go b/cloud/client.go index a3ab870..2273cc1 100644 --- a/cloud/client.go +++ b/cloud/client.go @@ -10,7 +10,11 @@ You may obtain a copy of the License at package cloud -import "context" +import ( + "context" + + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) // Client is the abstraction over STACKIT cloud APIs used by the controllers. // @@ -67,6 +71,12 @@ type Client interface { // implementation can be swapped for the fake in tests. type Factory func(ctx context.Context, creds Credentials) (Client, error) +// SKEFactory builds an SKE v2 API client from parsed STACKIT credentials. +// +// Controllers receive an SKEFactory so that the real SDK client can be swapped +// for an in-memory fake in tests. +type SKEFactory func(ctx context.Context, creds Credentials) (skev2.DefaultAPI, error) + // Credentials carries the materials needed to construct a STACKIT API client. // For MVP we only need the service-account JSON and project ID. type Credentials struct { diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 433e22f..391187a 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -25,6 +25,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/oapierror" iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" lb "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer/v2api" + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) const ( @@ -32,6 +33,7 @@ const ( stackitTokenEndpointEnv = "STACKIT_TOKEN_BASEURL" stackitIAASEndpointEnv = "STACKIT_IAAS_ENDPOINT" stackitLBEndpointEnv = "STACKIT_LOADBALANCER_ENDPOINT" + stackitSKEEndpointEnv = "STACKIT_SKE_ENDPOINT" apiserverTargetPoolName = "apiserver" apiserverListenerName = "apiserver" @@ -62,7 +64,7 @@ func NewClient(_ context.Context, creds Credentials) (Client, error) { if creds.Region == "" { return nil, fmt.Errorf("%w: region is empty", ErrInvalidInput) } - if len(creds.ServiceAccountJSON) == 0 && os.Getenv(stackitNoAuthEnv) != "true" { + if len(creds.ServiceAccountJSON) == 0 && !noAuthEnabled() { return nil, fmt.Errorf("%w: service account JSON is empty", ErrInvalidInput) } @@ -83,11 +85,34 @@ func NewClient(_ context.Context, creds Credentials) (Client, error) { }, nil } +// NewSKEClient returns the real STACKIT SKE v2 API client. +func NewSKEClient(_ context.Context, creds Credentials) (skev2.DefaultAPI, error) { + if creds.ProjectID == "" { + return nil, fmt.Errorf("%w: project ID is empty", ErrInvalidInput) + } + if creds.Region == "" { + return nil, fmt.Errorf("%w: region is empty", ErrInvalidInput) + } + if len(creds.ServiceAccountJSON) == 0 && !noAuthEnabled() { + return nil, fmt.Errorf("%w: service account JSON is empty", ErrInvalidInput) + } + + skeClient, err := skev2.NewAPIClient(stackitOptions(creds, os.Getenv(stackitSKEEndpointEnv))...) + if err != nil { + return nil, fmt.Errorf("%w: create SKE SDK client: %v", ErrInvalidInput, err) + } + return skeClient.DefaultAPI, nil +} + +func noAuthEnabled() bool { + return os.Getenv(stackitNoAuthEnv) == "true" +} + func stackitOptions(creds Credentials, endpoint string) []config.ConfigurationOption { opts := []config.ConfigurationOption{ config.WithUserAgent("cluster-api-provider-stackit"), } - if os.Getenv(stackitNoAuthEnv) == "true" { + if noAuthEnabled() { opts = append(opts, config.WithoutAuthentication()) } else { opts = append(opts, config.WithServiceAccountKey(string(creds.ServiceAccountJSON))) @@ -1093,6 +1118,18 @@ func firstNonEmpty(values ...string) string { return "" } +// ClassifySDKError maps STACKIT OpenAPI SDK errors to CAPSTK cloud sentinel +// errors while preserving pre-classified sentinel errors from test fakes. +func ClassifySDKError(op string, err error) error { + if err == nil { + return nil + } + if IsNotFound(err) || IsUnauthorized(err) || IsInvalidInput(err) || IsConflict(err) || IsTransient(err) { + return fmt.Errorf("%s: %w", op, err) + } + return classifySDKError(op, err) +} + func classifySDKError(op string, err error) error { var oapiErr *oapierror.GenericOpenAPIError if errors.As(err, &oapiErr) { diff --git a/cloud/services/ske/reconcile.go b/cloud/services/ske/reconcile.go new file mode 100644 index 0000000..3c35eba --- /dev/null +++ b/cloud/services/ske/reconcile.go @@ -0,0 +1,702 @@ +/* +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 ske + +import ( + "cmp" + "context" + "fmt" + "os" + "slices" + "time" + + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + ctrl "sigs.k8s.io/controller-runtime" + 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" +) + +const ( + requeueAfter = time.Minute + + // BootstrapNodePoolName is the reserved SKE node-pool name used for the + // provider-managed bootstrap/system fallback pool. + BootstrapNodePoolName = infrav1.ManagedNodePoolBootstrapName + defaultNodePoolMachineType = "c2i.2" + defaultNodePoolImageName = "flatcar" + // defaultNodePoolImageVersion is the fallback Flatcar image version used for + // the provider bootstrap node pool when STACKIT_SKE_NODEPOOL_IMAGE_VERSION is + // not set. + // TODO(ske): This fallback version is time-sensitive as SKE rotates supported + // Flatcar images; replace with a durable configuration or provider-options + // selection strategy. + defaultNodePoolImageVersion = "4593.2.5-containerd2.1.9" + defaultNodePoolImageVersionEnv = "STACKIT_SKE_NODEPOOL_IMAGE_VERSION" + defaultNodePoolMinimum = int32(1) + defaultNodePoolMaximum = int32(1) + defaultNodePoolVolumeSize = int32(20) + defaultNodePoolVolumeType = "storage_premium_perf1" + + ReasonAvailable = "Available" + ReasonCreating = "Creating" + ReasonProvisioning = "Provisioning" + ReasonReconciling = "Reconciling" + ReasonDeleting = "Deleting" + ReasonDeleted = "Deleted" + ReasonUnhealthy = "Unhealthy" + ReasonHibernated = "Hibernated" + ReasonUnknownState = "UnknownState" + ReasonDuplicateNodePoolName = "DuplicateNodePoolName" + ReasonInvalidNodePoolConfiguration = "InvalidNodePoolConfiguration" + ReasonClusterNotManaged = "ClusterNotManaged" +) + +// Reconcile ensures the SKE cluster and its desired nodepools[] configuration +// exist, evaluates cluster and node-pool lifecycle state from a single +// GetCluster call, and projects child statuses back into the scope. +func (s *Service) Reconcile(ctx context.Context) (ctrl.Result, error) { + log := logf.FromContext(ctx) + if s.skeClient == nil { + err := fmt.Errorf("%w: SKE client is not configured", cloud.ErrInvalidInput) + s.scope.SetNotReady("SKEClientError", err.Error()) + s.projectAllActiveNodePoolsNotReady("SKEClientError", err.Error()) + return ctrl.Result{}, err + } + + if invalidReason, invalidMessage := s.validateChildNodePools(); invalidReason != "" { + s.scope.SetNotReady(invalidReason, invalidMessage) + return ctrl.Result{}, nil + } + + cluster, err := s.skeClient.GetCluster(ctx, s.scope.ProjectID(), s.scope.Region(), s.scope.ClusterName()).Execute() + if err != nil { + err = cloud.ClassifySDKError("get SKE cluster", err) + if !cloud.IsNotFound(err) { + log.Error(err, "Failed to get SKE cluster", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady("SKEAPIError", err.Error()) + s.projectAllActiveNodePoolsNotReady("SKEAPIError", err.Error()) + return ctrl.Result{}, err + } + + return s.reconcileInitialCreate(ctx) + } + + if cluster == nil { + err := fmt.Errorf("%w: get SKE cluster returned nil cluster without error", cloud.ErrTransient) + log.Error(err, "Unexpected nil SKE cluster response", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady("SKEAPIError", err.Error()) + s.projectAllActiveNodePoolsNotReady("SKEAPIError", err.Error()) + return ctrl.Result{}, err + } + + if !s.hasOwnershipLabels(cluster) { + err := fmt.Errorf( + "%w: existing SKE cluster %q is not managed by this StackitManagedCluster", + cloud.ErrConflict, + s.scope.ClusterName(), + ) + log.Error(err, "Refusing to adopt existing SKE cluster without matching ownership labels", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady(ReasonClusterNotManaged, err.Error()) + s.projectAllNodePoolsNotReady(ReasonClusterNotManaged, err.Error()) + return ctrl.Result{}, err + } + + state, stateOK := clusterAggregatedState(cluster) + observedByName := indexObservedNodePools(cluster.GetNodepools()) + desiredPools := s.computeDesiredNodePools(observedByName, state) + needsUpdate := !NodePoolListMatchesObserved(desiredPools, cluster.GetNodepools()) + + updateTriggered := false + if needsUpdate && state == skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY { + log.Info("Updating SKE cluster node pools", "clusterName", s.scope.ClusterName(), "desiredPoolCount", len(desiredPools)) + payload := s.buildClusterPayload(desiredPools) + if _, updateErr := s.skeClient. + CreateOrUpdateCluster(ctx, s.scope.ProjectID(), s.scope.Region(), s.scope.ClusterName()). + CreateOrUpdateClusterPayload(payload). + Execute(); updateErr != nil { + updateErr = cloud.ClassifySDKError("update SKE cluster", updateErr) + if cloud.IsConflict(updateErr) { + log.V(1).Info("SKE cluster update already in progress", "clusterName", s.scope.ClusterName()) + updateTriggered = true + } else { + log.Error(updateErr, "Failed to update SKE cluster", "clusterName", s.scope.ClusterName()) + reason := "UpdateFailed" + if cloud.IsInvalidInput(updateErr) { + reason = ReasonInvalidNodePoolConfiguration + } + s.scope.SetNotReady(reason, updateErr.Error()) + s.projectNodePoolStatuses(cluster, observedByName, state, stateOK, false, reason, updateErr.Error()) + return ctrl.Result{}, updateErr + } + } else { + updateTriggered = true + } + } + + s.projectNodePoolStatuses(cluster, observedByName, state, stateOK, updateTriggered, "", "") + + if !stateOK { + log.V(1).Info("SKE cluster status is not reported yet", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady(ReasonUnknownState, "SKE cluster status is not reported yet") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + + if updateTriggered { + s.scope.SetNotReady(ReasonReconciling, "SKE cluster reconciliation in progress") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + + switch state { + case skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY: + if needsUpdate { + s.scope.SetNotReady(ReasonReconciling, "SKE cluster node pools reconciliation pending") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + s.scope.SetReady() + return ctrl.Result{}, nil + case skev2.CLUSTERSTATUSSTATE_STATE_CREATING: + s.scope.SetNotReady(ReasonCreating, "SKE cluster creation in progress") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + case skev2.CLUSTERSTATUSSTATE_STATE_RECONCILING, + skev2.CLUSTERSTATUSSTATE_STATE_WAKINGUP, + skev2.CLUSTERSTATUSSTATE_STATE_HIBERNATING: + s.scope.SetNotReady(ReasonReconciling, fmt.Sprintf("SKE cluster is in state %s", state)) + return ctrl.Result{RequeueAfter: requeueAfter}, nil + case skev2.CLUSTERSTATUSSTATE_STATE_HIBERNATED: + s.scope.SetNotReady(ReasonHibernated, "SKE cluster is hibernated") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + case skev2.CLUSTERSTATUSSTATE_STATE_UNHEALTHY: + s.scope.SetNotReady(ReasonUnhealthy, clusterErrorMessage(cluster)) + return ctrl.Result{RequeueAfter: requeueAfter}, nil + case skev2.CLUSTERSTATUSSTATE_STATE_DELETING: + s.scope.SetNotReady(ReasonDeleting, "SKE cluster deletion in progress") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + default: + s.scope.SetNotReady(ReasonUnknownState, fmt.Sprintf("SKE cluster reported unexpected state %q", state)) + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } +} + +func (s *Service) reconcileInitialCreate(ctx context.Context) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + // Any unpaused child pool already marked for deletion while the parent SKE + // cluster is confirmed absent (404) is externally deleted. + for _, pool := range s.scope.ManagedNodePools() { + if pool.Deleting && !pool.Paused { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonDeleted, "external SKE cluster does not exist") + } + } + + initialPools := s.initialNodePools() + log.Info("Creating SKE cluster", "clusterName", s.scope.ClusterName(), "region", s.scope.Region(), "nodePoolCount", len(initialPools)) + payload := s.buildClusterPayload(initialPools) + if _, createErr := s.skeClient. + CreateOrUpdateCluster(ctx, s.scope.ProjectID(), s.scope.Region(), s.scope.ClusterName()). + CreateOrUpdateClusterPayload(payload). + Execute(); createErr != nil { + createErr = cloud.ClassifySDKError("create SKE cluster", createErr) + if cloud.IsConflict(createErr) { + log.V(1).Info("SKE cluster creation already in progress", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady(ReasonCreating, "SKE cluster creation in progress") + s.projectAllActiveNodePoolsNotReady(ReasonProvisioning, "SKE cluster creation in progress") + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + log.Error(createErr, "Failed to create SKE cluster", "clusterName", s.scope.ClusterName()) + reason := "CreateFailed" + if cloud.IsInvalidInput(createErr) { + reason = ReasonInvalidNodePoolConfiguration + } + s.scope.SetNotReady(reason, createErr.Error()) + s.projectAllActiveNodePoolsNotReady(reason, createErr.Error()) + return ctrl.Result{}, createErr + } + + s.scope.SetNotReady(ReasonCreating, "SKE cluster creation in progress") + s.projectAllActiveNodePoolsNotReady(ReasonProvisioning, "SKE cluster creation in progress") + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + +// Delete triggers SKE cluster deletion and requeues until GetCluster confirms +// that the cluster no longer exists (HTTP 404). +func (s *Service) Delete(ctx context.Context) (ctrl.Result, error) { + log := logf.FromContext(ctx) + if s.skeClient == nil { + err := fmt.Errorf("%w: SKE client is not configured", cloud.ErrInvalidInput) + s.scope.SetNotReady("SKEClientError", err.Error()) + return ctrl.Result{}, err + } + + cluster, err := s.skeClient.GetCluster(ctx, s.scope.ProjectID(), s.scope.Region(), s.scope.ClusterName()).Execute() + if err != nil { + err = cloud.ClassifySDKError("get SKE cluster", err) + if cloud.IsNotFound(err) { + log.Info("Confirmed SKE cluster absence", "clusterName", s.scope.ClusterName()) + return ctrl.Result{}, nil + } + log.Error(err, "Failed to get SKE cluster during deletion", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady("DeleteFailed", err.Error()) + return ctrl.Result{}, err + } + + if cluster == nil { + err := fmt.Errorf("%w: get SKE cluster returned nil cluster without error", cloud.ErrTransient) + log.Error(err, "Unexpected nil SKE cluster response during deletion", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady("DeleteFailed", err.Error()) + return ctrl.Result{}, err + } + + if !s.hasOwnershipLabels(cluster) { + err := fmt.Errorf( + "%w: refusing to delete SKE cluster %q not managed by this StackitManagedCluster", + cloud.ErrConflict, + s.scope.ClusterName(), + ) + log.Error(err, "Refusing to delete existing SKE cluster without matching ownership labels", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady(ReasonClusterNotManaged, err.Error()) + return ctrl.Result{}, err + } + + state, _ := clusterAggregatedState(cluster) + if state != skev2.CLUSTERSTATUSSTATE_STATE_DELETING { + log.Info("Deleting SKE cluster", "clusterName", s.scope.ClusterName(), "region", s.scope.Region()) + if _, deleteErr := s.skeClient.DeleteCluster(ctx, s.scope.ProjectID(), s.scope.Region(), s.scope.ClusterName()).Execute(); deleteErr != nil { + deleteErr = cloud.ClassifySDKError("delete SKE cluster", deleteErr) + if !cloud.IsNotFound(deleteErr) && !cloud.IsConflict(deleteErr) { + log.Error(deleteErr, "Failed to delete SKE cluster", "clusterName", s.scope.ClusterName()) + s.scope.SetNotReady("DeleteFailed", deleteErr.Error()) + return ctrl.Result{}, deleteErr + } + } + } + + s.scope.SetNotReady(ReasonDeleting, "SKE cluster deletion in progress") + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + +// validateChildNodePools validates active (non-deleting) child pools for +// duplicate or reserved names while allowing deleting duplicate/reserved CRs to +// progress immediately to Reason="Deleted" without deadlocking finalizers or +// deleting an external pool still claimed by an active CR. +func (s *Service) validateChildNodePools() (string, string) { + pools := s.scope.ManagedNodePools() + if len(pools) == 0 { + return "", "" + } + + activeByName := make(map[string][]ManagedNodePoolState, len(pools)) + var invalidMsg string + for _, pool := range pools { + name := pool.Desired.Name + if pool.Deleting { + if name == BootstrapNodePoolName && !pool.Paused { + s.scope.SetNodePoolNotReady( + pool.ResourceName, + ReasonDeleted, + "reserved bootstrap node pool name is not managed by user StackitManagedNodePool", + ) + } + continue + } + if name == BootstrapNodePoolName { + msg := fmt.Sprintf("node pool name %q is reserved for the provider bootstrap node pool", BootstrapNodePoolName) + if !pool.Paused { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonInvalidNodePoolConfiguration, msg) + } + if invalidMsg == "" { + invalidMsg = msg + } + continue + } + activeByName[name] = append(activeByName[name], pool) + } + + // If a deleting CR shares its SKE node-pool name with at least one remaining + // active CR, release the deleting duplicate CR (Reason=Deleted) without + // removing the external SKE node pool owned by the active CR. + for _, pool := range pools { + if !pool.Deleting || pool.Paused || pool.Desired.Name == BootstrapNodePoolName { + continue + } + if len(activeByName[pool.Desired.Name]) > 0 { + s.scope.SetNodePoolNotReady( + pool.ResourceName, + ReasonDeleted, + fmt.Sprintf("duplicate StackitManagedNodePool for %q released in favor of active resource", pool.Desired.Name), + ) + } + } + + var duplicateMsg string + for name, group := range activeByName { + if len(group) <= 1 { + continue + } + msg := fmt.Sprintf("multiple active StackitManagedNodePools in cluster declare SKE node pool name %q", name) + for _, pool := range group { + if !pool.Paused { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonDuplicateNodePoolName, msg) + } + } + if duplicateMsg == "" { + duplicateMsg = msg + } + } + + if duplicateMsg != "" { + return ReasonDuplicateNodePoolName, duplicateMsg + } + if invalidMsg != "" { + return ReasonInvalidNodePoolConfiguration, invalidMsg + } + return "", "" +} + +func (s *Service) activeNodePoolNames() map[string]bool { + names := make(map[string]bool) + for _, pool := range s.scope.ManagedNodePools() { + if !pool.Deleting && pool.Desired.Name != BootstrapNodePoolName { + names[pool.Desired.Name] = true + } + } + return names +} + +func (s *Service) initialNodePools() []ManagedNodePoolDesiredState { + var active []ManagedNodePoolDesiredState + for _, pool := range s.scope.ManagedNodePools() { + if pool.Deleting || pool.Paused || !pool.Finalized || pool.Desired.Name == BootstrapNodePoolName { + continue + } + active = append(active, pool.Desired) + } + sortDesiredPools(active) + + if len(active) == 0 { + return []ManagedNodePoolDesiredState{s.defaultBootstrapNodePool()} + } + for _, p := range active { + if p.AllowsSystemComponents() { + return active + } + } + withBootstrap := append(slices.Clone(active), s.defaultBootstrapNodePool()) + sortDesiredPools(withBootstrap) + return withBootstrap +} + +func (s *Service) computeDesiredNodePools( + observedByName map[string]skev2.Nodepool, + state skev2.ClusterStatusState, +) []ManagedNodePoolDesiredState { + activeNames := s.activeNodePoolNames() + var active, deleting []ManagedNodePoolDesiredState + + for _, pool := range s.scope.ManagedNodePools() { + name := pool.Desired.Name + if name == BootstrapNodePoolName { + continue + } + if pool.Paused { + // A paused pool that already exists in SKE is preserved verbatim in + // desired state so neither spec mutations nor standalone deletions are + // pushed to SKE while paused. A paused pool that does not yet exist in + // SKE is not created while paused. + if obs, exists := observedByName[name]; exists { + active = append(active, NodePoolDesiredStateFromSDK(obs)) + } + continue + } + if pool.Deleting { + // If an active CR still claims the same SKE pool name, do not treat + // the pool as deleting in SKE. + if !activeNames[name] { + deleting = append(deleting, pool.Desired) + } + continue + } + if pool.Finalized { + active = append(active, pool.Desired) + } + } + sortDesiredPools(active) + sortDesiredPools(deleting) + + bootstrap := s.defaultBootstrapNodePool() + + hasActiveSystemPool := false + allActiveConverged := len(active) > 0 + hasConvergedActiveSystemPool := false + for _, p := range active { + obs, exists := observedByName[p.Name] + converged := exists && NodePoolMatchesObserved(p, obs) + if !converged { + allActiveConverged = false + } + if p.AllowsSystemComponents() { + hasActiveSystemPool = true + if converged && state == skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY { + hasConvergedActiveSystemPool = true + } + } + } + + if hasActiveSystemPool { + result := slices.Clone(active) + // Forward handoff Stage 2: only remove the provider bootstrap pool (or a + // deleting system pool being replaced) once all active user pools are + // converged and at least one system-capable user pool is healthy in SKE. + if !allActiveConverged || !hasConvergedActiveSystemPool { + if _, bootstrapPresent := observedByName[BootstrapNodePoolName]; bootstrapPresent { + result = append(result, bootstrap) + } else { + for _, d := range deleting { + if obs, ok := observedByName[d.Name]; ok && observedAllowsSystemComponents(obs) { + result = append(result, NodePoolDesiredStateFromSDK(obs)) + } + } + } + } + sortDesiredPools(result) + return result + } + + // Reverse handoff / fallback: zero active system-capable user pools remain, + // so the provider bootstrap pool must be present and healthy before the + // last system-capable user pool can be removed from SKE. + result := append(slices.Clone(active), bootstrap) + obsBootstrap, bootstrapPresent := observedByName[BootstrapNodePoolName] + bootstrapHealthy := bootstrapPresent && + NodePoolMatchesObserved(bootstrap, obsBootstrap) && + state == skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY + + if !bootstrapHealthy { + for _, d := range deleting { + if obs, ok := observedByName[d.Name]; ok && observedAllowsSystemComponents(obs) { + result = append(result, NodePoolDesiredStateFromSDK(obs)) + } + } + } + sortDesiredPools(result) + return result +} + +func (s *Service) projectNodePoolStatuses( + cluster *skev2.Cluster, + observedByName map[string]skev2.Nodepool, + state skev2.ClusterStatusState, + stateOK bool, + updateTriggered bool, + updateFailureReason string, + updateFailureMessage string, +) { + activeNames := s.activeNodePoolNames() + for _, pool := range s.scope.ManagedNodePools() { + if pool.Paused { + continue + } + name := pool.Desired.Name + obs, existsInObserved := observedByName[name] + + if pool.Deleting { + if name == BootstrapNodePoolName || activeNames[name] { + s.scope.SetNodePoolNotReady( + pool.ResourceName, + ReasonDeleted, + fmt.Sprintf("duplicate StackitManagedNodePool for %q released in favor of active resource", name), + ) + continue + } + if !existsInObserved && !updateTriggered && state != skev2.CLUSTERSTATUSSTATE_STATE_RECONCILING { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonDeleted, "SKE node pool has been deleted") + } else { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonDeleting, "SKE node pool deletion in progress") + } + continue + } + + if !pool.Finalized { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonProvisioning, "waiting for StackitManagedNodePool finalizer") + continue + } + + if updateFailureReason != "" { + s.scope.SetNodePoolNotReady(pool.ResourceName, updateFailureReason, updateFailureMessage) + continue + } + + if !existsInObserved { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonProvisioning, "SKE node pool provisioning in progress") + continue + } + + if !NodePoolMatchesObserved(pool.Desired, obs) { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonReconciling, "SKE node pool update in progress") + continue + } + + if updateTriggered { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonReconciling, "SKE cluster reconciliation in progress") + continue + } + + if !stateOK { + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonUnknownState, "SKE cluster status is not reported yet") + continue + } + + switch state { + case skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY: + s.scope.SetNodePoolReady(pool.ResourceName) + case skev2.CLUSTERSTATUSSTATE_STATE_CREATING: + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonProvisioning, "SKE cluster creation in progress") + case skev2.CLUSTERSTATUSSTATE_STATE_RECONCILING, + skev2.CLUSTERSTATUSSTATE_STATE_WAKINGUP, + skev2.CLUSTERSTATUSSTATE_STATE_HIBERNATING: + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonReconciling, fmt.Sprintf("SKE cluster is in state %s", state)) + case skev2.CLUSTERSTATUSSTATE_STATE_HIBERNATED: + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonHibernated, "SKE cluster is hibernated") + case skev2.CLUSTERSTATUSSTATE_STATE_UNHEALTHY: + msg := fmt.Sprintf( + "parent SKE cluster is in cluster-wide STATE_UNHEALTHY (SKE does not report per-node-pool health): %s", + clusterErrorMessage(cluster), + ) + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonUnhealthy, msg) + case skev2.CLUSTERSTATUSSTATE_STATE_DELETING: + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonDeleting, "parent SKE cluster is deleting") + default: + s.scope.SetNodePoolNotReady(pool.ResourceName, ReasonUnknownState, fmt.Sprintf("SKE cluster reported unexpected state %q", state)) + } + } +} + +func (s *Service) projectAllActiveNodePoolsNotReady(reason, message string) { + for _, pool := range s.scope.ManagedNodePools() { + if pool.Deleting || pool.Paused { + continue + } + s.scope.SetNodePoolNotReady(pool.ResourceName, reason, message) + } +} + +func (s *Service) projectAllNodePoolsNotReady(reason, message string) { + for _, pool := range s.scope.ManagedNodePools() { + if pool.Paused { + continue + } + s.scope.SetNodePoolNotReady(pool.ResourceName, reason, message) + } +} + +func (s *Service) defaultBootstrapNodePool() ManagedNodePoolDesiredState { + imageVersion := os.Getenv(defaultNodePoolImageVersionEnv) + if imageVersion == "" { + imageVersion = defaultNodePoolImageVersion + } + return ManagedNodePoolDesiredState{ + Name: BootstrapNodePoolName, + AvailabilityZones: []string{s.scope.Region() + "-1"}, + MachineType: defaultNodePoolMachineType, + ImageName: defaultNodePoolImageName, + ImageVersion: imageVersion, + Minimum: defaultNodePoolMinimum, + Maximum: defaultNodePoolMaximum, + VolumeSize: defaultNodePoolVolumeSize, + VolumeType: defaultNodePoolVolumeType, + AllowSystemComponents: new(true), + } +} + +func (s *Service) buildClusterPayload(desiredPools []ManagedNodePoolDesiredState) skev2.CreateOrUpdateClusterPayload { + sdkPools := make([]skev2.Nodepool, len(desiredPools)) + for i, pool := range desiredPools { + sdkPools[i] = pool.ToSKESDK() + } + + payload := skev2.CreateOrUpdateClusterPayload{ + Kubernetes: skev2.Kubernetes{ + Version: s.scope.KubernetesVersion(), + }, + Nodepools: sdkPools, + } + + if networkID := s.scope.NetworkID(); networkID != "" { + payload.Network = &skev2.Network{ + Id: new(networkID), + } + } + + if labels := s.scope.Labels(); len(labels) > 0 { + payload.Labels = &labels + } + + return payload +} + +func indexObservedNodePools(pools []skev2.Nodepool) map[string]skev2.Nodepool { + out := make(map[string]skev2.Nodepool, len(pools)) + for _, np := range pools { + out[np.Name] = np + } + return out +} + +func sortDesiredPools(pools []ManagedNodePoolDesiredState) { + slices.SortFunc(pools, func(a, b ManagedNodePoolDesiredState) int { + return cmp.Compare(a.Name, b.Name) + }) +} + +func (s *Service) hasOwnershipLabels(cluster *skev2.Cluster) bool { + if cluster == nil || !cluster.HasLabels() { + return false + } + expected := s.scope.OwnershipLabels() + if len(expected) == 0 { + return false + } + actual := cluster.GetLabels() + for k, v := range expected { + if v == "" || actual[k] != v { + return false + } + } + return true +} + +func clusterAggregatedState(cluster *skev2.Cluster) (skev2.ClusterStatusState, bool) { + if cluster == nil || cluster.Status == nil || !cluster.Status.HasAggregated() { + return "", false + } + state := cluster.Status.GetAggregated() + if state == "" { + return "", false + } + return state, true +} + +func clusterErrorMessage(cluster *skev2.Cluster) string { + if cluster != nil && cluster.Status != nil { + if len(cluster.Status.Errors) > 0 { + if msg := cluster.Status.Errors[0].GetMessage(); msg != "" { + return msg + } + } + if cluster.Status.Error != nil { + if msg := cluster.Status.Error.GetMessage(); msg != "" { + return msg + } + } + } + return "SKE cluster is unhealthy" +} diff --git a/cloud/services/ske/reconcile_test.go b/cloud/services/ske/reconcile_test.go new file mode 100644 index 0000000..680616b --- /dev/null +++ b/cloud/services/ske/reconcile_test.go @@ -0,0 +1,1108 @@ +/* +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 ske + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +type fakeSKEAPI struct { + skev2.DefaultAPI + + getClusterFn func(r skev2.ApiGetClusterRequest) (*skev2.Cluster, error) + createOrUpdateFn func(r skev2.ApiCreateOrUpdateClusterRequest) (*skev2.Cluster, error) + deleteClusterFn func(r skev2.ApiDeleteClusterRequest) (map[string]any, error) + + getCalls int + createCalls int + deleteCalls int + lastProjectID string + lastRegion string + lastCluster string +} + +func (f *fakeSKEAPI) GetCluster(_ context.Context, projectID, region, clusterName string) skev2.ApiGetClusterRequest { + f.lastProjectID = projectID + f.lastRegion = region + f.lastCluster = clusterName + return skev2.ApiGetClusterRequest{ + ApiService: f, + } +} + +func (f *fakeSKEAPI) GetClusterExecute(r skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + f.getCalls++ + if f.getClusterFn != nil { + return f.getClusterFn(r) + } + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} +} + +func (f *fakeSKEAPI) CreateOrUpdateCluster(_ context.Context, projectID, region, clusterName string) skev2.ApiCreateOrUpdateClusterRequest { + f.lastProjectID = projectID + f.lastRegion = region + f.lastCluster = clusterName + return skev2.ApiCreateOrUpdateClusterRequest{ + ApiService: f, + } +} + +func (f *fakeSKEAPI) CreateOrUpdateClusterExecute(r skev2.ApiCreateOrUpdateClusterRequest) (*skev2.Cluster, error) { + f.createCalls++ + if f.createOrUpdateFn != nil { + return f.createOrUpdateFn(r) + } + return &skev2.Cluster{Name: new(f.lastCluster)}, nil +} + +func (f *fakeSKEAPI) DeleteCluster(_ context.Context, projectID, region, clusterName string) skev2.ApiDeleteClusterRequest { + f.lastProjectID = projectID + f.lastRegion = region + f.lastCluster = clusterName + return skev2.ApiDeleteClusterRequest{ + ApiService: f, + } +} + +func (f *fakeSKEAPI) DeleteClusterExecute(r skev2.ApiDeleteClusterRequest) (map[string]any, error) { + f.deleteCalls++ + if f.deleteClusterFn != nil { + return f.deleteClusterFn(r) + } + return map[string]any{}, nil +} + +type recordedNodePoolStatus struct { + ready bool + provisioned *bool + reason string + message string +} + +type fakeScope struct { + name string + namespace string + uid string + projectID string + region string + clusterName string + kubernetesVersion string + networkID string + additionalLabels map[string]string + skeClient skev2.DefaultAPI + nodePools []ManagedNodePoolState + + ready bool + provisioned *bool + reason string + message string + nodePoolStatuses map[string]recordedNodePoolStatus +} + +func newFakeScope(skeClient skev2.DefaultAPI) *fakeScope { + return &fakeScope{ + name: "capi-mc", + namespace: "default", + uid: "uid-capi-mc-1", + projectID: "11111111-1111-1111-1111-111111111111", + region: "eu01", + clusterName: "ske-poc", + kubernetesVersion: "1.35.3", + networkID: "22222222-2222-2222-2222-222222222222", + additionalLabels: map[string]string{ + "env": "poc", + util.LabelClusterUID: "user-attempted-override", + }, + skeClient: skeClient, + nodePoolStatuses: make(map[string]recordedNodePoolStatus), + } +} + +func (s *fakeScope) ProjectID() string { return s.projectID } +func (s *fakeScope) Region() string { return s.region } +func (s *fakeScope) ClusterName() string { return s.clusterName } +func (s *fakeScope) KubernetesVersion() string { return s.kubernetesVersion } +func (s *fakeScope) NetworkID() string { return s.networkID } +func (s *fakeScope) Labels() map[string]string { + return util.ManagedClusterTags(s.name, s.namespace, s.uid, s.additionalLabels) +} +func (s *fakeScope) OwnershipLabels() map[string]string { + return util.ManagedClusterTags(s.name, s.namespace, s.uid, nil) +} +func (s *fakeScope) SKEClient() skev2.DefaultAPI { return s.skeClient } +func (s *fakeScope) ManagedNodePools() []ManagedNodePoolState { return s.nodePools } +func (s *fakeScope) SetReady() { + s.ready = true + s.provisioned = new(true) + s.reason = ReasonAvailable + s.message = "" +} +func (s *fakeScope) SetNotReady(reason, message string) { + s.ready = false + s.reason = reason + s.message = message +} +func (s *fakeScope) SetNodePoolReady(resourceName string) { + prev := s.nodePoolStatuses[resourceName] + prev.ready = true + prev.provisioned = new(true) + prev.reason = ReasonAvailable + prev.message = "" + s.nodePoolStatuses[resourceName] = prev +} +func (s *fakeScope) SetNodePoolNotReady(resourceName, reason, message string) { + prev := s.nodePoolStatuses[resourceName] + prev.ready = false + prev.reason = reason + prev.message = message + s.nodePoolStatuses[resourceName] = prev +} + +func defaultBootstrapSDKPool() skev2.Nodepool { + return ManagedNodePoolDesiredState{ + Name: BootstrapNodePoolName, + AvailabilityZones: []string{"eu01-1"}, + MachineType: defaultNodePoolMachineType, + ImageName: defaultNodePoolImageName, + ImageVersion: defaultNodePoolImageVersion, + Minimum: defaultNodePoolMinimum, + Maximum: defaultNodePoolMaximum, + VolumeSize: defaultNodePoolVolumeSize, + VolumeType: defaultNodePoolVolumeType, + AllowSystemComponents: new(true), + }.ToSKESDK() +} + +func clusterWithState(state skev2.ClusterStatusState, pools ...skev2.Nodepool) *skev2.Cluster { + labels := util.ManagedClusterTags("capi-mc", "default", "uid-capi-mc-1", map[string]string{"env": "poc"}) + if len(pools) == 0 { + pools = []skev2.Nodepool{defaultBootstrapSDKPool()} + } + return &skev2.Cluster{ + Name: new("ske-poc"), + Labels: &labels, + Nodepools: pools, + Status: &skev2.ClusterStatus{ + Aggregated: &state, + }, + } +} + +func desiredPool(name string, allowSystem *bool) ManagedNodePoolDesiredState { + return ManagedNodePoolDesiredState{ + Name: name, + AvailabilityZones: []string{"eu01-1"}, + MachineType: "c2i.2", + ImageName: "flatcar", + ImageVersion: "4459.2.4", + Minimum: 1, + Maximum: 3, + VolumeSize: 30, + VolumeType: "storage_premium_perf1", + AllowSystemComponents: allowSystem, + } +} + +func newCapturedCreateSDKClient(t *testing.T, captured *skev2.CreateOrUpdateClusterPayload, getCalls, createCalls *int) skev2.DefaultAPI { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/v2/projects/11111111-1111-1111-1111-111111111111/regions/eu01/clusters/ske-poc" + if r.URL.Path != expectedPath { + t.Fatalf("unexpected request path = %q, want %q", r.URL.Path, expectedPath) + } + switch r.Method { + case http.MethodGet: + *getCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + case http.MethodPut: + *createCalls++ + if err := json.NewDecoder(r.Body).Decode(captured); err != nil { + t.Fatalf("decode CreateOrUpdateClusterPayload: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"name":"ske-poc","kubernetes":{"version":"1.35.3"},"nodepools":[]}`)) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + t.Cleanup(server.Close) + + client, err := skev2.NewAPIClient( + config.WithoutAuthentication(), + config.WithEndpoint(server.URL), + ) + if err != nil { + t.Fatalf("NewAPIClient() error: %v", err) + } + return client.DefaultAPI +} + +func TestServiceReconcileCreatesClusterWhenNotFound(t *testing.T) { + var ( + captured skev2.CreateOrUpdateClusterPayload + getCalls int + createCalls int + ) + sdkClient := newCapturedCreateSDKClient(t, &captured, &getCalls, &createCalls) + scope := newFakeScope(sdkClient) + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile() unexpected error: %v", err) + } + if res.RequeueAfter != time.Minute { + t.Fatalf("Reconcile() RequeueAfter = %v, want %v", res.RequeueAfter, time.Minute) + } + if getCalls != 1 || createCalls != 1 { + t.Fatalf("calls: get=%d create=%d, want get=1 create=1", getCalls, createCalls) + } + if scope.ready { + t.Fatalf("scope.ready = true, want false while creating") + } + if scope.provisioned != nil { + t.Fatalf("scope.provisioned = %v, want nil while creating", *scope.provisioned) + } + if scope.reason != "Creating" { + t.Fatalf("scope.reason = %q, want Creating", scope.reason) + } + + if captured.Kubernetes.Version != "1.35.3" { + t.Fatalf("captured.Kubernetes.Version = %q, want 1.35.3", captured.Kubernetes.Version) + } + if captured.Network.GetId() != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("captured.Network.Id = %q, want 22222222-2222-2222-2222-222222222222", captured.Network.GetId()) + } + wantLabels := map[string]string{ + util.LabelClusterName: "capi-mc", + util.LabelClusterNamespace: "default", + util.LabelClusterUID: "uid-capi-mc-1", + util.LabelManagedBy: util.ManagedByValue, + "env": "poc", + } + if !reflect.DeepEqual(captured.GetLabels(), wantLabels) { + t.Fatalf("captured.Labels = %#v, want %#v", captured.GetLabels(), wantLabels) + } + if len(captured.Nodepools) != 1 { + t.Fatalf("len(captured.Nodepools) = %d, want 1", len(captured.Nodepools)) + } + np := captured.Nodepools[0] + if np.Name != BootstrapNodePoolName || + !reflect.DeepEqual(np.AvailabilityZones, []string{"eu01-1"}) || + np.Machine.Type != "c2i.2" || + np.Machine.Image.Name != "flatcar" || + np.Machine.Image.Version != "4593.2.5-containerd2.1.9" || + np.Minimum != 1 || + np.Maximum != 1 || + np.Volume.Size != 20 || + np.Volume.GetType() != "storage_premium_perf1" || + !np.GetAllowSystemComponents() { + t.Fatalf("unexpected captured bootstrap nodepool: %#v", np) + } +} + +func TestServiceReconcileCreatesClusterWithExistingUserNodePoolsDirectly(t *testing.T) { + var ( + captured skev2.CreateOrUpdateClusterPayload + getCalls int + createCalls int + ) + sdkClient := newCapturedCreateSDKClient(t, &captured, &getCalls, &createCalls) + scope := newFakeScope(sdkClient) + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "np-b", Desired: desiredPool("pool-b", new(false)), Finalized: true}, + {ResourceName: "np-a", Desired: desiredPool("pool-a", new(true)), Finalized: true}, + } + svc := New(scope) + + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile() unexpected error: %v", err) + } + if createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", createCalls) + } + if len(captured.Nodepools) != 2 || captured.Nodepools[0].Name != "pool-a" || captured.Nodepools[1].Name != "pool-b" { + t.Fatalf("captured nodepools = %#v, want [pool-a, pool-b] without bootstrap pool", captured.Nodepools) + } + if scope.nodePoolStatuses["np-a"].reason != "Provisioning" || scope.nodePoolStatuses["np-b"].reason != "Provisioning" { + t.Fatalf("unexpected child statuses: %#v", scope.nodePoolStatuses) + } +} + +func TestServiceReconcileCreatePayloadImageVersionOverride(t *testing.T) { + t.Setenv(defaultNodePoolImageVersionEnv, "4459.2.9") + var ( + captured skev2.CreateOrUpdateClusterPayload + getCalls int + createCalls int + ) + sdkClient := newCapturedCreateSDKClient(t, &captured, &getCalls, &createCalls) + scope := newFakeScope(sdkClient) + svc := New(scope) + + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile() unexpected error: %v", err) + } + if createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", createCalls) + } + if got := captured.Nodepools[0].Machine.Image.Version; got != "4459.2.9" { + t.Fatalf("captured Nodepool image version = %q, want 4459.2.9", got) + } +} + +func TestServiceBootstrapForwardHandoffAndFailureRetention(t *testing.T) { + var ( + capturedPayloads []skev2.CreateOrUpdateClusterPayload + observedCluster *skev2.Cluster + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(observedCluster) + case http.MethodPut: + var body skev2.CreateOrUpdateClusterPayload + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode PUT body: %v", err) + } + capturedPayloads = append(capturedPayloads, body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(observedCluster) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + t.Cleanup(server.Close) + + client, err := skev2.NewAPIClient(config.WithoutAuthentication(), config.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("NewAPIClient() error: %v", err) + } + + scope := newFakeScope(client.DefaultAPI) + workerDesired := desiredPool("workers", new(true)) + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "workers-cr", Desired: workerDesired, Finalized: true}, + } + svc := New(scope) + + // 1. SKE currently has only [capstk-system] and is HEALTHY -> Stage 1 PUTs [capstk-system, workers]. + observedCluster = clusterWithState(skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, defaultBootstrapSDKPool()) + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Stage 1 Reconcile() error: %v", err) + } + if res.RequeueAfter != time.Minute || len(capturedPayloads) != 1 { + t.Fatalf("Stage 1: res=%v len(capturedPayloads)=%d", res, len(capturedPayloads)) + } + if len(capturedPayloads[0].Nodepools) != 2 || + capturedPayloads[0].Nodepools[0].Name != BootstrapNodePoolName || + capturedPayloads[0].Nodepools[1].Name != "workers" { + t.Fatalf("Stage 1 payload nodepools = %#v, want [%s, workers]", capturedPayloads[0].Nodepools, BootstrapNodePoolName) + } + + // 2. If SKE enters STATE_UNHEALTHY while provisioning workers, capstk-system is retained (no Stage 2 PUT). + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_UNHEALTHY, + defaultBootstrapSDKPool(), + workerDesired.ToSKESDK(), + ) + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Unhealthy Reconcile() error: %v", err) + } + if len(capturedPayloads) != 1 { + t.Fatalf("Unhealthy state should not remove bootstrap pool: len(capturedPayloads)=%d, want 1", len(capturedPayloads)) + } + if scope.nodePoolStatuses["workers-cr"].reason != "Unhealthy" { + t.Fatalf("workers-cr reason = %q, want Unhealthy", scope.nodePoolStatuses["workers-cr"].reason) + } + + // 3. Once SKE is STATE_HEALTHY with [capstk-system, workers], Stage 2 PUTs [workers] (removing capstk-system). + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, + defaultBootstrapSDKPool(), + workerDesired.ToSKESDK(), + ) + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Stage 2 Reconcile() error: %v", err) + } + if res.RequeueAfter != time.Minute || len(capturedPayloads) != 2 { + t.Fatalf("Stage 2: res=%v len(capturedPayloads)=%d", res, len(capturedPayloads)) + } + if len(capturedPayloads[1].Nodepools) != 1 || capturedPayloads[1].Nodepools[0].Name != "workers" { + t.Fatalf("Stage 2 payload nodepools = %#v, want [workers]", capturedPayloads[1].Nodepools) + } + if scope.ready || scope.nodePoolStatuses["workers-cr"].ready { + t.Fatalf("cluster/pool should be Reconciling while Stage 2 PUT is in flight") + } + + // 4. Once SKE is STATE_HEALTHY with [workers], both cluster and workers-cr are Ready=true and Provisioned=true. + observedCluster = clusterWithState(skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, workerDesired.ToSKESDK()) + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Converged Reconcile() error: %v", err) + } + if res.RequeueAfter != 0 || len(capturedPayloads) != 2 { + t.Fatalf("Converged: res=%v len(capturedPayloads)=%d", res, len(capturedPayloads)) + } + if !scope.ready || !scope.nodePoolStatuses["workers-cr"].ready || + scope.nodePoolStatuses["workers-cr"].provisioned == nil || !*scope.nodePoolStatuses["workers-cr"].provisioned { + t.Fatalf("expected cluster and workers-cr Ready=true and Provisioned=true, got cluster=%v pool=%#v", + scope.ready, scope.nodePoolStatuses["workers-cr"]) + } +} + +func TestServiceReverseHandoffOnLastSystemPoolDeletion(t *testing.T) { + var ( + capturedPayloads []skev2.CreateOrUpdateClusterPayload + observedCluster *skev2.Cluster + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(observedCluster) + case http.MethodPut: + var body skev2.CreateOrUpdateClusterPayload + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode PUT body: %v", err) + } + capturedPayloads = append(capturedPayloads, body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(observedCluster) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + t.Cleanup(server.Close) + + client, err := skev2.NewAPIClient(config.WithoutAuthentication(), config.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("NewAPIClient() error: %v", err) + } + + scope := newFakeScope(client.DefaultAPI) + sysPool := desiredPool("sys-pool", new(true)) + nonSysPool := desiredPool("app-pool", new(false)) + // sys-pool is being deleted while non-system app-pool remains active. + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "sys-cr", Desired: sysPool, Finalized: true, Deleting: true}, + {ResourceName: "app-cr", Desired: nonSysPool, Finalized: true, Deleting: false}, + } + svc := New(scope) + + // Stage 1: SKE currently has [app-pool, sys-pool] -> introduces capstk-system while retaining sys-pool. + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, + nonSysPool.ToSKESDK(), + sysPool.ToSKESDK(), + ) + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reverse Stage 1 error: %v", err) + } + if len(capturedPayloads) != 1 || len(capturedPayloads[0].Nodepools) != 3 { + t.Fatalf("Reverse Stage 1 payloads = %#v, want 3 pools [app-pool, capstk-system, sys-pool]", capturedPayloads) + } + if scope.nodePoolStatuses["sys-cr"].reason != ReasonDeleting { + t.Fatalf("sys-cr reason = %q, want Deleting", scope.nodePoolStatuses["sys-cr"].reason) + } + + // Stage 2: Once SKE is HEALTHY with [app-pool, capstk-system, sys-pool], omits sys-pool. + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, + nonSysPool.ToSKESDK(), + defaultBootstrapSDKPool(), + sysPool.ToSKESDK(), + ) + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reverse Stage 2 error: %v", err) + } + if len(capturedPayloads) != 2 || len(capturedPayloads[1].Nodepools) != 2 || + capturedPayloads[1].Nodepools[0].Name != "app-pool" || + capturedPayloads[1].Nodepools[1].Name != BootstrapNodePoolName { + t.Fatalf("Reverse Stage 2 payload = %#v, want [app-pool, %s]", capturedPayloads[1].Nodepools, BootstrapNodePoolName) + } + if scope.nodePoolStatuses["sys-cr"].reason != ReasonDeleting { + t.Fatalf("sys-cr reason during Stage 2 PUT = %q, want Deleting", scope.nodePoolStatuses["sys-cr"].reason) + } + + // Completion: Once SKE is HEALTHY with [app-pool, capstk-system] (sys-pool absent), sys-cr gets Reason="Deleted". + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, + nonSysPool.ToSKESDK(), + defaultBootstrapSDKPool(), + ) + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reverse completion error: %v", err) + } + if scope.nodePoolStatuses["sys-cr"].reason != "Deleted" { + t.Fatalf("sys-cr reason = %q, want Deleted", scope.nodePoolStatuses["sys-cr"].reason) + } + if !scope.nodePoolStatuses["app-cr"].ready || !scope.ready { + t.Fatalf("expected app-cr and cluster Ready=true after reverse handoff") + } +} + +func TestServiceSingleGetClusterAcross1And5And20NodePools(t *testing.T) { + for _, count := range []int{1, 5, 20} { + t.Run(fmt.Sprintf("%d_pools", count), func(t *testing.T) { + sdkPools := make([]skev2.Nodepool, 0, count) + states := make([]ManagedNodePoolState, 0, count) + for i := range count { + name := fmt.Sprintf("pool-%02d", i) + resName := fmt.Sprintf("cr-%02d", i) + d := desiredPool(name, new(true)) + sdkPools = append(sdkPools, d.ToSKESDK()) + states = append(states, ManagedNodePoolState{ + ResourceName: resName, + Desired: d, + Finalized: true, + }) + } + + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return clusterWithState(skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, sdkPools...), nil + }, + } + scope := newFakeScope(api) + scope.nodePools = states + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile() error: %v", err) + } + if res.RequeueAfter != 0 { + t.Fatalf("RequeueAfter = %v, want 0", res.RequeueAfter) + } + if api.getCalls != 1 || api.createCalls != 0 { + t.Fatalf("SKE calls: get=%d create=%d, want get=1 create=0", api.getCalls, api.createCalls) + } + for i := range count { + resName := fmt.Sprintf("cr-%02d", i) + st := scope.nodePoolStatuses[resName] + if !st.ready || st.provisioned == nil || !*st.provisioned || st.reason != ReasonAvailable { + t.Fatalf("pool %s status = %#v, want Ready/Available", resName, st) + } + } + }) + } +} + +func TestServiceRejectsDuplicateAndReservedNodePoolNamesAndUnblocksOnDelete(t *testing.T) { + workerDesired := desiredPool("workers", new(true)) + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return clusterWithState(skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, workerDesired.ToSKESDK()), nil + }, + } + scope := newFakeScope(api) + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "np-1", Desired: workerDesired, Finalized: true}, + {ResourceName: "np-2", Desired: workerDesired, Finalized: true}, + } + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(duplicate) unexpected error: %v", err) + } + if res.RequeueAfter != 0 || api.getCalls != 0 || api.createCalls != 0 { + t.Fatalf("duplicate pools should not poll or call SKE: res=%v get=%d create=%d", res, api.getCalls, api.createCalls) + } + if scope.reason != ReasonDuplicateNodePoolName || + scope.nodePoolStatuses["np-1"].reason != ReasonDuplicateNodePoolName || + scope.nodePoolStatuses["np-2"].reason != ReasonDuplicateNodePoolName { + t.Fatalf("unexpected reasons: cluster=%q np-1=%q np-2=%q", + scope.reason, scope.nodePoolStatuses["np-1"].reason, scope.nodePoolStatuses["np-2"].reason) + } + + // Deleting np-2 resolves the duplicate conflict: np-2 gets Reason=Deleted + // without deleting "workers" in SKE, and np-1 + cluster become Ready=true. + scope.nodePools[1].Deleting = true + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(after deleting duplicate np-2) error: %v", err) + } + if res.RequeueAfter != 0 || api.getCalls != 1 || api.createCalls != 0 { + t.Fatalf("expected 1 GET and 0 PUTs when resolving duplicate: res=%v get=%d create=%d", + res, api.getCalls, api.createCalls) + } + if scope.nodePoolStatuses["np-2"].reason != ReasonDeleted { + t.Fatalf("deleting duplicate np-2 reason = %q, want %q", scope.nodePoolStatuses["np-2"].reason, ReasonDeleted) + } + if !scope.nodePoolStatuses["np-1"].ready || !scope.ready { + t.Fatalf("expected remaining active np-1 and cluster to be Ready=true") + } + + // Reserved bootstrap pool name is rejected when active, and immediately + // marked Deleted when deleting. + api.getCalls = 0 + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "np-reserved", Desired: desiredPool(BootstrapNodePoolName, new(true)), Finalized: true}, + } + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(reserved) unexpected error: %v", err) + } + if res.RequeueAfter != 0 || api.getCalls != 0 || api.createCalls != 0 { + t.Fatalf("reserved pool name should not call SKE: res=%v get=%d create=%d", res, api.getCalls, api.createCalls) + } + if scope.reason != ReasonInvalidNodePoolConfiguration || + scope.nodePoolStatuses["np-reserved"].reason != ReasonInvalidNodePoolConfiguration { + t.Fatalf("unexpected reasons for reserved name: cluster=%q pool=%q", + scope.reason, scope.nodePoolStatuses["np-reserved"].reason) + } + + // Deleting the reserved-name CR marks it Deleted so its finalizer can clear. + scope.nodePools[0].Deleting = true + api.getClusterFn = func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return clusterWithState(skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, defaultBootstrapSDKPool()), nil + } + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile(deleting reserved) unexpected error: %v", err) + } + if scope.nodePoolStatuses["np-reserved"].reason != ReasonDeleted { + t.Fatalf("deleting reserved pool reason = %q, want %q", scope.nodePoolStatuses["np-reserved"].reason, ReasonDeleted) + } +} + +func TestServicePausedNodePoolPreservesObservedSKEStateUntilUnpaused(t *testing.T) { + var ( + capturedPayloads []skev2.CreateOrUpdateClusterPayload + observedCluster *skev2.Cluster + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(observedCluster) + case http.MethodPut: + var body skev2.CreateOrUpdateClusterPayload + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode PUT body: %v", err) + } + capturedPayloads = append(capturedPayloads, body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(observedCluster) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + t.Cleanup(server.Close) + + client, err := skev2.NewAPIClient(config.WithoutAuthentication(), config.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("NewAPIClient() error: %v", err) + } + + obsPoolA := desiredPool("pool-a", new(true)) + obsPoolA.Maximum = 3 + obsPoolB := desiredPool("pool-b", new(true)) + obsPoolB.Maximum = 3 + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, + obsPoolA.ToSKESDK(), + obsPoolB.ToSKESDK(), + ) + + // 1. pool-a is paused and its desired Maximum changed to 5; pool-b is unpaused and unchanged. + // Expect 0 PUT calls because paused pool-a's observed state (Maximum=3) is preserved. + modPoolA := obsPoolA + modPoolA.Maximum = 5 + scope := newFakeScope(client.DefaultAPI) + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "cr-a", Desired: modPoolA, Finalized: true, Paused: true}, + {ResourceName: "cr-b", Desired: obsPoolB, Finalized: true, Paused: false}, + } + svc := New(scope) + + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile(paused pool-a) error: %v", err) + } + if len(capturedPayloads) != 0 { + t.Fatalf("expected 0 PUT calls when only paused pool changed, got %d", len(capturedPayloads)) + } + + // 2. Unpaused pool-b changes Maximum to 4 while pool-a remains paused (Desired Maximum=5). + // Expect 1 PUT where pool-a is preserved at Maximum=3 and pool-b is updated to Maximum=4. + modPoolB := obsPoolB + modPoolB.Maximum = 4 + scope.nodePools[1].Desired = modPoolB + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile(pool-b updated while pool-a paused) error: %v", err) + } + if len(capturedPayloads) != 1 || len(capturedPayloads[0].Nodepools) != 2 { + t.Fatalf("expected 1 PUT with 2 pools, got %#v", capturedPayloads) + } + if capturedPayloads[0].Nodepools[0].Maximum != 3 || capturedPayloads[0].Nodepools[1].Maximum != 4 { + t.Fatalf("expected pool-a Maximum=3 (preserved) and pool-b Maximum=4, got %#v", capturedPayloads[0].Nodepools) + } + + // 3. Unpause pool-a -> next reconcile applies pool-a's Maximum=5 change. + observedCluster = clusterWithState( + skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, + obsPoolA.ToSKESDK(), + modPoolB.ToSKESDK(), + ) + scope.nodePools[0].Paused = false + if _, err := svc.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile(unpaused pool-a) error: %v", err) + } + if len(capturedPayloads) != 2 || capturedPayloads[1].Nodepools[0].Maximum != 5 { + t.Fatalf("expected pool-a Maximum=5 after unpause, got %#v", capturedPayloads) + } +} + +func TestServiceSemanticEqualityPreventsEndlessPUTSpam(t *testing.T) { + // Desired pool has nil AllowSystemComponents, empty VolumeType, unsorted AZs and taints, nil Labels. + desiredA := ManagedNodePoolDesiredState{ + Name: "pool-a", + AvailabilityZones: []string{"eu01-2", "eu01-1"}, + MachineType: "c2i.2", + ImageName: "flatcar", + ImageVersion: "4459.2.4", + Minimum: 1, + Maximum: 3, + VolumeSize: 30, + VolumeType: "", + Taints: []ManagedNodePoolTaint{ + {Key: "b", Effect: "NoSchedule", Value: ""}, + {Key: "a", Effect: "NoExecute", Value: "v1"}, + }, + } + desiredB := desiredPool("pool-b", new(false)) + + // Observed SKE response returns pools in reverse order, sorted AZs, defaulted + // allowSystemComponents=true, defaulted volume.type="storage_premium_perf1", + // empty map labels, reversed taints, and server-defaulted CRI/MaxSurge/MaxUnavailable. + emptyLabels := map[string]string{} + obsA := skev2.Nodepool{ + Name: "pool-a", + AvailabilityZones: []string{"eu01-1", "eu01-2"}, + AllowSystemComponents: new(true), + Cri: &skev2.CRI{Name: new(skev2.NAMEOFTHECRILIBRARY_CONTAINERD)}, + Labels: &emptyLabels, + Machine: skev2.Machine{ + Type: "c2i.2", + Image: skev2.Image{Name: "flatcar", Version: "4459.2.4"}, + }, + Minimum: 1, + Maximum: 3, + MaxSurge: new(int32(1)), + MaxUnavailable: new(int32(0)), + Taints: []skev2.Taint{ + {Key: "a", Effect: skev2.TAINTEFFECT_NO_EXECUTE, Value: new("v1")}, + {Key: "b", Effect: skev2.TAINTEFFECT_NO_SCHEDULE}, + }, + Volume: skev2.Volume{ + Size: 30, + Type: new("storage_premium_perf1"), + }, + } + obsB := desiredB.ToSKESDK() + + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + // Return [obsB, obsA] (reverse order of [desiredA, desiredB]). + return clusterWithState(skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, obsB, obsA), nil + }, + } + scope := newFakeScope(api) + scope.nodePools = []ManagedNodePoolState{ + {ResourceName: "cr-a", Desired: desiredA, Finalized: true}, + {ResourceName: "cr-b", Desired: desiredB, Finalized: true}, + } + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile() unexpected error: %v", err) + } + if api.createCalls != 0 { + t.Fatalf("expected 0 PUT calls for semantically identical pools, got %d", api.createCalls) + } + if res.RequeueAfter != 0 || !scope.ready || !scope.nodePoolStatuses["cr-a"].ready || !scope.nodePoolStatuses["cr-b"].ready { + t.Fatalf("expected Ready=true and no requeue, got res=%v clusterReady=%v pools=%#v", + res, scope.ready, scope.nodePoolStatuses) + } +} + +func TestServiceReconcileHandlesCreateConflictIdempotently(t *testing.T) { + api := &fakeSKEAPI{ + createOrUpdateFn: func(_ skev2.ApiCreateOrUpdateClusterRequest) (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusConflict, ErrorMessage: "409 Conflict"} + }, + } + scope := newFakeScope(api) + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile() unexpected error on 409 conflict: %v", err) + } + if res.RequeueAfter != time.Minute { + t.Fatalf("Reconcile() RequeueAfter = %v, want %v", res.RequeueAfter, time.Minute) + } + if scope.reason != "Creating" || scope.ready { + t.Fatalf("scope state = (ready=%v, reason=%q), want (false, Creating)", scope.ready, scope.reason) + } +} + +func TestServiceRefusesToAdoptOrDeleteClusterWithoutMatchingOwnershipLabels(t *testing.T) { + state := skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY + staleUIDLabels := util.ManagedClusterTags("capi-mc", "default", "uid-capi-mc-old", nil) + for _, tc := range []struct { + name string + cluster *skev2.Cluster + }{ + { + name: "missing labels", + cluster: &skev2.Cluster{ + Name: new("ske-poc"), + Status: &skev2.ClusterStatus{ + Aggregated: &state, + }, + }, + }, + { + name: "same name and namespace but different UID", + cluster: &skev2.Cluster{ + Name: new("ske-poc"), + Labels: &staleUIDLabels, + Status: &skev2.ClusterStatus{ + Aggregated: &state, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return tc.cluster, nil + }, + } + scope := newFakeScope(api) + svc := New(scope) + + if _, err := svc.Reconcile(context.Background()); !cloud.IsConflict(err) { + t.Fatalf("Reconcile(%s) err = %v, want ErrConflict", tc.name, err) + } + if scope.ready || scope.provisioned != nil || scope.reason != "ClusterNotManaged" || api.createCalls != 0 { + t.Fatalf("unexpected state on %s Reconcile: ready=%v provisioned=%v reason=%q createCalls=%d", + tc.name, scope.ready, scope.provisioned, scope.reason, api.createCalls) + } + + if _, err := svc.Delete(context.Background()); !cloud.IsConflict(err) { + t.Fatalf("Delete(%s) err = %v, want ErrConflict", tc.name, err) + } + if api.deleteCalls != 0 || scope.reason != "ClusterNotManaged" { + t.Fatalf("unexpected state on %s Delete: deleteCalls=%d reason=%q", + tc.name, api.deleteCalls, scope.reason) + } + }) + } +} + +func TestServiceReconcileRepeatedPollingAndStickyProvisioned(t *testing.T) { + currentState := skev2.CLUSTERSTATUSSTATE_STATE_CREATING + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + c := clusterWithState(currentState) + if currentState == skev2.CLUSTERSTATUSSTATE_STATE_UNHEALTHY { + c.Status.Errors = []skev2.ClusterError{ + {Message: new("node disk pressure")}, + } + } + return c, nil + }, + } + scope := newFakeScope(api) + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(CREATING) unexpected error: %v", err) + } + if api.createCalls != 0 || res.RequeueAfter != time.Minute || scope.ready || scope.provisioned != nil { + t.Fatalf("unexpected state during CREATING: createCalls=%d res=%v ready=%v provisioned=%v", + api.createCalls, res, scope.ready, scope.provisioned) + } + + currentState = skev2.CLUSTERSTATUSSTATE_STATE_RECONCILING + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(RECONCILING) unexpected error: %v", err) + } + if api.createCalls != 0 || res.RequeueAfter != time.Minute || scope.reason != "Reconciling" { + t.Fatalf("unexpected state during RECONCILING: createCalls=%d res=%v reason=%q", + api.createCalls, res, scope.reason) + } + + currentState = skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(HEALTHY) unexpected error: %v", err) + } + if res.RequeueAfter != 0 || !scope.ready || scope.provisioned == nil || !*scope.provisioned { + t.Fatalf("unexpected state during HEALTHY: res=%v ready=%v provisioned=%v", + res, scope.ready, scope.provisioned) + } + + currentState = skev2.CLUSTERSTATUSSTATE_STATE_UNHEALTHY + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(UNHEALTHY) unexpected error: %v", err) + } + if res.RequeueAfter != time.Minute || scope.ready || scope.reason != "Unhealthy" || scope.message != "node disk pressure" { + t.Fatalf("unexpected state during UNHEALTHY: res=%v ready=%v reason=%q message=%q", + res, scope.ready, scope.reason, scope.message) + } + if scope.provisioned == nil || !*scope.provisioned { + t.Fatalf("Provisioned was reset on UNHEALTHY: got %v, want true", scope.provisioned) + } +} + +func TestServiceReconcileHandlesUnknownStatesAndAPIFailures(t *testing.T) { + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return clusterWithState(skev2.CLUSTERSTATUSSTATE_UNKNOWN_DEFAULT_OPEN_API), nil + }, + } + scope := newFakeScope(api) + svc := New(scope) + + res, err := svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(UNKNOWN) unexpected error: %v", err) + } + if res.RequeueAfter != time.Minute || scope.reason != "UnknownState" || scope.ready { + t.Fatalf("unexpected state for UNKNOWN: res=%v reason=%q ready=%v", res, scope.reason, scope.ready) + } + + api.getClusterFn = func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + labels := util.ManagedClusterTags("capi-mc", "default", "uid-capi-mc-1", nil) + return &skev2.Cluster{Name: new("ske-poc"), Labels: &labels}, nil + } + res, err = svc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile(nil status) unexpected error: %v", err) + } + if res.RequeueAfter != time.Minute || scope.reason != "UnknownState" { + t.Fatalf("unexpected state for nil status: res=%v reason=%q", res, scope.reason) + } + + api.getClusterFn = func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return nil, nil + } + if _, err := svc.Reconcile(context.Background()); !cloud.IsTransient(err) || api.createCalls != 0 { + t.Fatalf("Reconcile(nil, nil) err = %v, createCalls = %d, want ErrTransient and 0 createCalls", err, api.createCalls) + } + + api.getClusterFn = func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusUnauthorized, ErrorMessage: "401 Unauthorized"} + } + if _, err := svc.Reconcile(context.Background()); !cloud.IsUnauthorized(err) || api.createCalls != 0 { + t.Fatalf("Reconcile(401) err = %v, createCalls = %d, want ErrUnauthorized and 0 createCalls", err, api.createCalls) + } +} + +func TestServiceDeleteLifecycleAndErrorHandling(t *testing.T) { + currentState := skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY + notFound := false + api := &fakeSKEAPI{ + getClusterFn: func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + if notFound { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} + } + return clusterWithState(currentState), nil + }, + } + scope := newFakeScope(api) + svc := New(scope) + + res, err := svc.Delete(context.Background()) + if err != nil { + t.Fatalf("Delete(HEALTHY) unexpected error: %v", err) + } + if api.deleteCalls != 1 || res.RequeueAfter != time.Minute || scope.reason != "Deleting" { + t.Fatalf("unexpected state after initial Delete: deleteCalls=%d res=%v reason=%q", + api.deleteCalls, res, scope.reason) + } + + api.deleteClusterFn = func(_ skev2.ApiDeleteClusterRequest) (map[string]any, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} + } + res, err = svc.Delete(context.Background()) + if err != nil { + t.Fatalf("Delete(DeleteCluster 404) unexpected error: %v", err) + } + if res.RequeueAfter != time.Minute || scope.reason != "Deleting" { + t.Fatalf("Delete(DeleteCluster 404) res=%v reason=%q, want RequeueAfter=%v and Deleting", + res, scope.reason, time.Minute) + } + api.deleteClusterFn = nil + + currentState = skev2.CLUSTERSTATUSSTATE_STATE_DELETING + res, err = svc.Delete(context.Background()) + if err != nil { + t.Fatalf("Delete(DELETING) unexpected error: %v", err) + } + if api.deleteCalls != 2 || res.RequeueAfter != time.Minute { + t.Fatalf("unexpected state while DELETING: deleteCalls=%d res=%v", api.deleteCalls, res) + } + + notFound = true + res, err = svc.Delete(context.Background()) + if err != nil { + t.Fatalf("Delete(404) unexpected error: %v", err) + } + if res.RequeueAfter != 0 || api.deleteCalls != 2 { + t.Fatalf("unexpected state on 404: res=%v deleteCalls=%d", res, api.deleteCalls) + } + + api.getClusterFn = func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return nil, nil + } + if _, err := svc.Delete(context.Background()); !cloud.IsTransient(err) { + t.Fatalf("Delete(nil, nil) err = %v, want ErrTransient", err) + } + + api.getClusterFn = func(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusForbidden, ErrorMessage: "403 Forbidden"} + } + if _, err := svc.Delete(context.Background()); !cloud.IsUnauthorized(err) { + t.Fatalf("Delete(403) err = %v, want ErrUnauthorized", err) + } +} diff --git a/cloud/services/ske/service.go b/cloud/services/ske/service.go new file mode 100644 index 0000000..b6b180a --- /dev/null +++ b/cloud/services/ske/service.go @@ -0,0 +1,58 @@ +/* +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 ske + +import ( + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +// ManagedNodePoolState represents the Kubernetes-side state of an associated +// StackitManagedNodePool passed into the SKE service. +type ManagedNodePoolState struct { + ResourceName string + Desired ManagedNodePoolDesiredState + Finalized bool + Deleting bool + Paused bool +} + +// Scope defines the cluster scope data and status callbacks required by the +// SKE service. +type Scope interface { + ProjectID() string + Region() string + ClusterName() string + KubernetesVersion() string + NetworkID() string + Labels() map[string]string + OwnershipLabels() map[string]string + SKEClient() skev2.DefaultAPI + ManagedNodePools() []ManagedNodePoolState + SetReady() + SetNotReady(reason, message string) + SetNodePoolReady(resourceName string) + SetNodePoolNotReady(resourceName, reason, message string) +} + +// Service reconciles SKE clusters and embedded node pools for a +// StackitManagedCluster. +type Service struct { + scope Scope + skeClient skev2.DefaultAPI +} + +// New returns a new SKE Service from the given scope. +func New(scope Scope) *Service { + return &Service{ + scope: scope, + skeClient: scope.SKEClient(), + } +} diff --git a/cloud/services/ske/ske.go b/cloud/services/ske/ske.go index 183b864..731f205 100644 --- a/cloud/services/ske/ske.go +++ b/cloud/services/ske/ske.go @@ -16,10 +16,13 @@ You may obtain a copy of the License at package ske import ( + "cmp" "maps" "slices" "strings" + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" ) @@ -164,3 +167,252 @@ func ComposeManagedCluster( NodePools: sortedPools, }, nil } + +// AllowsSystemComponents reports whether the node pool allows system +// components (defaulting to true when AllowSystemComponents is nil, matching +// SKE API defaulting). +func (np ManagedNodePoolDesiredState) AllowsSystemComponents() bool { + return np.AllowSystemComponents == nil || *np.AllowSystemComponents +} + +// ToSKESDK converts a ManagedNodePoolDesiredState into an SKE v2 SDK Nodepool. +func (np ManagedNodePoolDesiredState) ToSKESDK() skev2.Nodepool { + zones := slices.Clone(np.AvailabilityZones) + slices.Sort(zones) + + volumeType := canonicalVolumeType(np.VolumeType) + out := skev2.Nodepool{ + Name: np.Name, + AvailabilityZones: zones, + AllowSystemComponents: new(np.AllowsSystemComponents()), + Machine: skev2.Machine{ + Type: np.MachineType, + Image: skev2.Image{ + Name: canonicalImageName(np.ImageName), + Version: np.ImageVersion, + }, + }, + Minimum: np.Minimum, + Maximum: np.Maximum, + Volume: skev2.Volume{ + Size: np.VolumeSize, + Type: new(volumeType), + }, + } + + if len(np.Labels) > 0 { + labels := maps.Clone(np.Labels) + out.Labels = &labels + } + if len(np.Taints) > 0 { + taints := make([]skev2.Taint, len(np.Taints)) + for i, t := range np.Taints { + taint := skev2.Taint{ + Effect: skev2.TaintEffect(t.Effect), + Key: t.Key, + } + if t.Value != "" { + taint.Value = new(t.Value) + } + taints[i] = taint + } + out.Taints = taints + } + if np.MaxSurge != nil { + out.MaxSurge = new(*np.MaxSurge) + } + if np.MaxUnavailable != nil { + out.MaxUnavailable = new(*np.MaxUnavailable) + } + + return out +} + +// NodePoolDesiredStateFromSDK converts an observed SKE SDK Nodepool into a +// ManagedNodePoolDesiredState so an existing pool can be retained verbatim +// during a two-stage bootstrap handoff. +func NodePoolDesiredStateFromSDK(in skev2.Nodepool) ManagedNodePoolDesiredState { + var labels map[string]string + if in.HasLabels() && len(in.GetLabels()) > 0 { + labels = maps.Clone(in.GetLabels()) + } + + var taints []ManagedNodePoolTaint + if len(in.Taints) > 0 { + taints = make([]ManagedNodePoolTaint, len(in.Taints)) + for i, t := range in.Taints { + taints[i] = ManagedNodePoolTaint{ + Effect: string(t.Effect), + Key: t.Key, + Value: t.GetValue(), + } + } + } + + var allowSystem *bool + if in.HasAllowSystemComponents() { + allowSystem = new(in.GetAllowSystemComponents()) + } + var maxSurge *int32 + if in.HasMaxSurge() { + maxSurge = new(in.GetMaxSurge()) + } + var maxUnavailable *int32 + if in.HasMaxUnavailable() { + maxUnavailable = new(in.GetMaxUnavailable()) + } + + return ManagedNodePoolDesiredState{ + Name: in.Name, + AvailabilityZones: slices.Clone(in.AvailabilityZones), + MachineType: in.Machine.Type, + ImageName: in.Machine.Image.Name, + ImageVersion: in.Machine.Image.Version, + Minimum: in.Minimum, + Maximum: in.Maximum, + VolumeSize: in.Volume.Size, + VolumeType: in.Volume.GetType(), + AllowSystemComponents: allowSystem, + Labels: labels, + Taints: taints, + MaxSurge: maxSurge, + MaxUnavailable: maxUnavailable, + } +} + +// NodePoolMatchesObserved performs a canonical semantic comparison between a +// desired node pool and an observed SKE node pool, normalizing order-insensitive +// slices, nil vs empty maps/slices, and SKE API defaults. +func NodePoolMatchesObserved(desired ManagedNodePoolDesiredState, observed skev2.Nodepool) bool { + if desired.Name != observed.Name { + return false + } + if !canonicalStringSliceEqual(desired.AvailabilityZones, observed.AvailabilityZones) { + return false + } + if desired.MachineType != observed.Machine.Type { + return false + } + if canonicalImageName(desired.ImageName) != canonicalImageName(observed.Machine.Image.Name) || + desired.ImageVersion != observed.Machine.Image.Version { + return false + } + if desired.Minimum != observed.Minimum || desired.Maximum != observed.Maximum { + return false + } + if desired.VolumeSize != observed.Volume.Size || + canonicalVolumeType(desired.VolumeType) != canonicalVolumeType(observed.Volume.GetType()) { + return false + } + if desired.AllowsSystemComponents() != observedAllowsSystemComponents(observed) { + return false + } + if !canonicalStringMapEqual(desired.Labels, observed.GetLabels()) { + return false + } + if !canonicalTaintsEqual(desired.Taints, observed.Taints) { + return false + } + if desired.MaxSurge != nil && (!observed.HasMaxSurge() || observed.GetMaxSurge() != *desired.MaxSurge) { + return false + } + if desired.MaxUnavailable != nil && (!observed.HasMaxUnavailable() || observed.GetMaxUnavailable() != *desired.MaxUnavailable) { + return false + } + return true +} + +// NodePoolListMatchesObserved reports whether the complete desired node-pool +// set is semantically equal to the observed SKE cluster node pools, +// independent of slice order. +func NodePoolListMatchesObserved(desired []ManagedNodePoolDesiredState, observed []skev2.Nodepool) bool { + if len(desired) != len(observed) { + return false + } + observedByName := make(map[string]skev2.Nodepool, len(observed)) + for _, np := range observed { + observedByName[np.Name] = np + } + if len(observedByName) != len(desired) { + return false + } + for _, d := range desired { + obs, exists := observedByName[d.Name] + if !exists || !NodePoolMatchesObserved(d, obs) { + return false + } + } + return true +} + +func observedAllowsSystemComponents(np skev2.Nodepool) bool { + if !np.HasAllowSystemComponents() { + return true + } + return np.GetAllowSystemComponents() +} + +func canonicalImageName(name string) string { + if name == "" { + return defaultNodePoolImageName + } + return name +} + +func canonicalVolumeType(volType string) string { + if volType == "" { + return defaultNodePoolVolumeType + } + return volType +} + +func canonicalStringSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + ca := slices.Clone(a) + cb := slices.Clone(b) + slices.Sort(ca) + slices.Sort(cb) + return slices.Equal(ca, cb) +} + +func canonicalStringMapEqual(a, b map[string]string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return maps.Equal(a, b) +} + +func canonicalTaintsEqual(desired []ManagedNodePoolTaint, observed []skev2.Taint) bool { + if len(desired) != len(observed) { + return false + } + if len(desired) == 0 { + return true + } + ca := slices.Clone(desired) + cb := make([]ManagedNodePoolTaint, len(observed)) + for i, t := range observed { + cb[i] = ManagedNodePoolTaint{ + Effect: string(t.Effect), + Key: t.Key, + Value: t.GetValue(), + } + } + cmpTaint := func(x, y ManagedNodePoolTaint) int { + if c := cmp.Compare(x.Key, y.Key); c != 0 { + return c + } + if c := cmp.Compare(x.Effect, y.Effect); c != 0 { + return c + } + return cmp.Compare(x.Value, y.Value) + } + slices.SortFunc(ca, cmpTaint) + slices.SortFunc(cb, cmpTaint) + return slices.Equal(ca, cb) +} diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 1698a99..804c160 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -203,8 +203,9 @@ func main() { os.Exit(1) } if err := (&controller.StackitManagedClusterReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + SKEClientFactory: cloud.NewSKEClient, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "stackitmanagedcluster") os.Exit(1) diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml index 3f3ac39..da2cd4f 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmanagednodepools.yaml @@ -121,7 +121,14 @@ spec: name: description: name is the name of the node pool in SKE. maxLength: 15 + minLength: 1 type: string + x-kubernetes-validations: + - message: name is immutable + rule: self == oldSelf + - message: name 'capstk-system' is reserved for the provider bootstrap + node pool + rule: self != 'capstk-system' providerIDList: description: |- providerIDList are the identification IDs of machine instances provided by diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index ce62985..df12f9e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -51,6 +51,7 @@ rules: - stackitclusters/finalizers - stackitmachines/finalizers - stackitmanagedclusters/finalizers + - stackitmanagednodepools/finalizers verbs: - update - apiGroups: diff --git a/controller/stackitmanagedcluster_controller.go b/controller/stackitmanagedcluster_controller.go index 6d6794b..cdd7743 100644 --- a/controller/stackitmanagedcluster_controller.go +++ b/controller/stackitmanagedcluster_controller.go @@ -18,34 +18,57 @@ package controller import ( "context" + "fmt" + "slices" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + kerrors "k8s.io/apimachinery/pkg/util/errors" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/util/annotations" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "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" + skeservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/ske" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +const ( + managedClusterDeleteRequeueAfter = 10 * time.Second ) // StackitManagedClusterReconciler reconciles a StackitManagedCluster object. type StackitManagedClusterReconciler struct { client.Client Scheme *runtime.Scheme + + // SKEClientFactory builds an SKE API client from parsed credentials. It is + // injected so tests can swap in an in-memory fake. + SKEClientFactory cloud.SKEFactory } // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters/status,verbs=get;update;patch // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters/finalizers,verbs=update -// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools,verbs=get;list;watch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools,verbs=get;list;watch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools/status,verbs=get;update;patch // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machinepools,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch -func (r *StackitManagedClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *StackitManagedClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) { log := logf.FromContext(ctx) stackitManagedCluster := &infrav1.StackitManagedCluster{} @@ -56,11 +79,129 @@ func (r *StackitManagedClusterReconciler) Reconcile(ctx context.Context, req ctr return ctrl.Result{}, err } - log.Info("Reconciling StackitManagedCluster", "namespace", req.Namespace, "name", req.Name) + if !stackitManagedCluster.DeletionTimestamp.IsZero() && + !hasManagedClusterFinalizer(stackitManagedCluster) { + return ctrl.Result{}, nil + } + + cluster, err := getVerifiedOwnerCluster(ctx, r.Client, stackitManagedCluster) + if err != nil { + return ctrl.Result{}, fmt.Errorf("get owner cluster: %w", err) + } + if cluster == nil && stackitManagedCluster.DeletionTimestamp.IsZero() { + log.Info("StackitManagedCluster has no owning Cluster yet, requeueing") + return ctrl.Result{}, nil + } + + clusterScope, err := scope.NewManagedClusterScope(r.Client, cluster, stackitManagedCluster) + if err != nil { + return ctrl.Result{}, fmt.Errorf("create managed cluster scope: %w", err) + } + defer func() { + if patchErr := clusterScope.PatchObject(ctx); patchErr != nil { + res = ctrl.Result{} + err = kerrors.NewAggregate([]error{err, patchErr}) + } + }() + + if !stackitManagedCluster.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, clusterScope) + } + + if paused, message := util.ReconciliationPaused(cluster, stackitManagedCluster); paused { + util.SetPausedCondition(&stackitManagedCluster.Status.Conditions, stackitManagedCluster.Generation, true, message) + return ctrl.Result{}, nil + } + util.SetPausedCondition(&stackitManagedCluster.Status.Conditions, stackitManagedCluster.Generation, false, "") + + return r.reconcileNormal(ctx, clusterScope) +} + +func (r *StackitManagedClusterReconciler) reconcileNormal( + ctx context.Context, + clusterScope *scope.ManagedClusterScope, +) (ctrl.Result, error) { + stackitManagedCluster := clusterScope.StackitManagedCluster + + if !controllerutil.ContainsFinalizer(stackitManagedCluster, infrav1.ManagedClusterFinalizer) { + controllerutil.AddFinalizer(stackitManagedCluster, infrav1.ManagedClusterFinalizer) + if err := clusterScope.PatchObject(ctx); err != nil { + return ctrl.Result{}, fmt.Errorf("persist finalizer: %w", err) + } + } + + nodePools, err := listManagedNodePoolsForCluster(ctx, r.Client, clusterScope.Cluster, stackitManagedCluster) + if err != nil { + return ctrl.Result{}, fmt.Errorf("list StackitManagedNodePools: %w", err) + } + if err := clusterScope.SetManagedNodePools(nodePools); err != nil { + return ctrl.Result{}, fmt.Errorf("snapshot StackitManagedNodePool patch helpers: %w", err) + } + + skeClient, err := util.BuildSKEClient(ctx, r.Client, r.SKEClientFactory, stackitManagedCluster) + if err != nil { + clusterScope.SetNotReady("CredentialsError", err.Error()) + return ctrl.Result{}, err + } + clusterScope.SetSKEClient(skeClient) + + return skeservice.New(clusterScope).Reconcile(ctx) +} + +func (r *StackitManagedClusterReconciler) reconcileDelete( + ctx context.Context, + clusterScope *scope.ManagedClusterScope, +) (ctrl.Result, error) { + log := logf.FromContext(ctx) + stackitManagedCluster := clusterScope.StackitManagedCluster + + nodePools, err := listManagedNodePoolsForCluster(ctx, r.Client, clusterScope.Cluster, stackitManagedCluster) + if err != nil { + return ctrl.Result{}, fmt.Errorf("list StackitManagedNodePools for deletion: %w", err) + } + if len(nodePools) > 0 { + for _, entry := range nodePools { + np := entry.NodePool + if np.DeletionTimestamp.IsZero() { + if deleteErr := r.Delete(ctx, np); deleteErr != nil && !apierrors.IsNotFound(deleteErr) { + return ctrl.Result{}, fmt.Errorf("delete child StackitManagedNodePool %s: %w", np.Name, deleteErr) + } + } + } + log.Info("Waiting for StackitManagedNodePools to be deleted before deleting SKE cluster", "remainingNodePools", len(nodePools)) + clusterScope.SetNotReady( + "WaitingForNodePoolsDeletion", + fmt.Sprintf("waiting for %d StackitManagedNodePool(s) to be deleted", len(nodePools)), + ) + return ctrl.Result{RequeueAfter: managedClusterDeleteRequeueAfter}, nil + } + + skeClient, err := util.BuildSKEClient(ctx, r.Client, r.SKEClientFactory, stackitManagedCluster) + if err != nil { + clusterScope.SetNotReady("CredentialsError", err.Error()) + return ctrl.Result{}, err + } + clusterScope.SetSKEClient(skeClient) + + res, err := skeservice.New(clusterScope).Delete(ctx) + if err != nil || res.RequeueAfter > 0 { + return res, err + } + removeManagedClusterFinalizers(stackitManagedCluster) return ctrl.Result{}, nil } +func hasManagedClusterFinalizer(managedCluster *infrav1.StackitManagedCluster) bool { + return controllerutil.ContainsFinalizer(managedCluster, infrav1.ManagedClusterFinalizer) || + controllerutil.ContainsFinalizer(managedCluster, infrav1.LegacyManagedClusterFinalizer) +} + +func removeManagedClusterFinalizers(managedCluster *infrav1.StackitManagedCluster) { + controllerutil.RemoveFinalizer(managedCluster, infrav1.ManagedClusterFinalizer) + controllerutil.RemoveFinalizer(managedCluster, infrav1.LegacyManagedClusterFinalizer) +} + // SetupWithManager registers the controller with the manager. func (r *StackitManagedClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). @@ -69,14 +210,88 @@ func (r *StackitManagedClusterReconciler) SetupWithManager(mgr ctrl.Manager) err &clusterv1.Cluster{}, handler.EnqueueRequestsFromMapFunc(r.filterForStackitManagedClusterResources), ). + Watches( + &clusterv1.MachinePool{}, + handler.EnqueueRequestsFromMapFunc(r.stackitManagedClusterRequestsForMachinePool), + builder.WithPredicates(machinePoolWatchPredicate()), + ). Watches( &infrav1.StackitManagedNodePool{}, handler.EnqueueRequestsFromMapFunc(r.stackitManagedClusterRequestsForNodePool), + builder.WithPredicates(managedNodePoolWatchPredicate()), ). Named("stackitmanagedcluster"). Complete(r) } +func managedNodePoolWatchPredicate() predicate.Funcs { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { + return true + }, + DeleteFunc: func(_ event.DeleteEvent) bool { + return true + }, + GenericFunc: func(_ event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return managedNodePoolChanged(e) + }, + } +} + +// managedNodePoolChanged filters out status-only updates written by +// StackitManagedClusterReconciler while allowing desired-state, deletion, +// finalizer, ownership, label, and pause transitions to enqueue the parent +// StackitManagedCluster. +func managedNodePoolChanged(e event.UpdateEvent) bool { + oldPool, okOld := e.ObjectOld.(*infrav1.StackitManagedNodePool) + newPool, okNew := e.ObjectNew.(*infrav1.StackitManagedNodePool) + if !okOld || !okNew { + return true + } + return oldPool.GetGeneration() != newPool.GetGeneration() || + oldPool.GetDeletionTimestamp().IsZero() != newPool.GetDeletionTimestamp().IsZero() || + controllerutil.ContainsFinalizer(oldPool, infrav1.ManagedNodePoolFinalizer) != + controllerutil.ContainsFinalizer(newPool, infrav1.ManagedNodePoolFinalizer) || + !slices.Equal(oldPool.GetOwnerReferences(), newPool.GetOwnerReferences()) || + oldPool.GetLabels()[clusterv1.ClusterNameLabel] != newPool.GetLabels()[clusterv1.ClusterNameLabel] || + oldPool.GetLabels()[util.LabelClusterUID] != newPool.GetLabels()[util.LabelClusterUID] || + annotations.HasPaused(oldPool) != annotations.HasPaused(newPool) +} + +func machinePoolWatchPredicate() predicate.Funcs { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { + return true + }, + DeleteFunc: func(_ event.DeleteEvent) bool { + return true + }, + GenericFunc: func(_ event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return machinePoolChanged(e) + }, + } +} + +func machinePoolChanged(e event.UpdateEvent) bool { + oldMP, okOld := e.ObjectOld.(*clusterv1.MachinePool) + newMP, okNew := e.ObjectNew.(*clusterv1.MachinePool) + if !okOld || !okNew { + return true + } + return oldMP.GetDeletionTimestamp().IsZero() != newMP.GetDeletionTimestamp().IsZero() || + annotations.HasPaused(oldMP) != annotations.HasPaused(newMP) || + oldMP.Spec.ClusterName != newMP.Spec.ClusterName || + oldMP.Spec.Template.Spec.InfrastructureRef != newMP.Spec.Template.Spec.InfrastructureRef || + !slices.Equal(oldMP.GetOwnerReferences(), newMP.GetOwnerReferences()) || + oldMP.GetLabels()[clusterv1.ClusterNameLabel] != newMP.GetLabels()[clusterv1.ClusterNameLabel] +} + func (r *StackitManagedClusterReconciler) filterForStackitManagedClusterResources(_ context.Context, obj client.Object) []reconcile.Request { cluster, ok := obj.(*clusterv1.Cluster) if !ok { @@ -94,6 +309,18 @@ func (r *StackitManagedClusterReconciler) filterForStackitManagedClusterResource }} } +func (r *StackitManagedClusterReconciler) stackitManagedClusterRequestsForMachinePool(ctx context.Context, obj client.Object) []reconcile.Request { + machinePool, ok := obj.(*clusterv1.MachinePool) + if !ok || r.Client == nil || !isStackitManagedNodePoolRef(machinePool.Spec.Template.Spec.InfrastructureRef, "") { + return nil + } + cluster, err := getClusterForMachinePool(ctx, r.Client, machinePool) + if err != nil || cluster == nil { + return nil + } + return r.filterForStackitManagedClusterResources(ctx, cluster) +} + func (r *StackitManagedClusterReconciler) stackitManagedClusterRequestsForNodePool(ctx context.Context, obj client.Object) []reconcile.Request { nodePool, ok := obj.(*infrav1.StackitManagedNodePool) if !ok || r.Client == nil { @@ -105,18 +332,39 @@ func (r *StackitManagedClusterReconciler) stackitManagedClusterRequestsForNodePo logf.FromContext(ctx).Error(err, "Failed to get MachinePool for StackitManagedNodePool watch", "nodePool", client.ObjectKeyFromObject(nodePool)) return nil } - if machinePool == nil { - return nil + if machinePool != nil { + cluster, err := getClusterForMachinePool(ctx, r.Client, machinePool) + if err != nil { + logf.FromContext(ctx).Error(err, "Failed to get Cluster for StackitManagedNodePool watch", "machinePool", client.ObjectKeyFromObject(machinePool)) + return nil + } + if cluster != nil { + managedCluster, err := getStackitManagedClusterForCluster(ctx, r.Client, cluster) + if err != nil || (managedCluster != nil && hasIncompatibleNodePoolClusterUIDLabel(nodePool, managedCluster)) { + return nil + } + return r.filterForStackitManagedClusterResources(ctx, cluster) + } } - cluster, err := getClusterForMachinePool(ctx, r.Client, machinePool) - if err != nil { - logf.FromContext(ctx).Error(err, "Failed to get Cluster for StackitManagedNodePool watch", "machinePool", client.ObjectKeyFromObject(machinePool)) + // UID-safe fallback during teardown when MachinePool or Cluster was already + // deleted: match the owning StackitManagedCluster by its exact UID label. + uidLabel := nodePool.Labels[util.LabelClusterUID] + if uidLabel == "" { return nil } - if cluster == nil { + managedClusters := &infrav1.StackitManagedClusterList{} + if err := r.List(ctx, managedClusters, client.InNamespace(nodePool.Namespace)); err != nil { return nil } - - return r.filterForStackitManagedClusterResources(ctx, cluster) + var requests []reconcile.Request + for i := range managedClusters.Items { + mc := &managedClusters.Items[i] + if string(mc.UID) == uidLabel { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(mc), + }) + } + } + return requests } diff --git a/controller/stackitmanagedcluster_controller_test.go b/controller/stackitmanagedcluster_controller_test.go index dfef7e8..94e48bd 100644 --- a/controller/stackitmanagedcluster_controller_test.go +++ b/controller/stackitmanagedcluster_controller_test.go @@ -18,13 +18,18 @@ package controller import ( "context" + "errors" "fmt" + "net/http" "strings" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" @@ -33,8 +38,94 @@ 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/cloud" + skeservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/ske" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) +type fakeControllerSKEAPI struct { + skev2.DefaultAPI + + getClusterFn func() (*skev2.Cluster, error) + createOrUpdateFn func() (*skev2.Cluster, error) + deleteClusterFn func() (map[string]any, error) + + getCalls int + createCalls int + deleteCalls int +} + +func (f *fakeControllerSKEAPI) GetCluster(_ context.Context, _, _, _ string) skev2.ApiGetClusterRequest { + return skev2.ApiGetClusterRequest{ApiService: f} +} + +func (f *fakeControllerSKEAPI) GetClusterExecute(_ skev2.ApiGetClusterRequest) (*skev2.Cluster, error) { + f.getCalls++ + if f.getClusterFn != nil { + return f.getClusterFn() + } + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} +} + +func (f *fakeControllerSKEAPI) CreateOrUpdateCluster(_ context.Context, _, _, _ string) skev2.ApiCreateOrUpdateClusterRequest { + return skev2.ApiCreateOrUpdateClusterRequest{ApiService: f} +} + +func (f *fakeControllerSKEAPI) CreateOrUpdateClusterExecute(_ skev2.ApiCreateOrUpdateClusterRequest) (*skev2.Cluster, error) { + f.createCalls++ + if f.createOrUpdateFn != nil { + return f.createOrUpdateFn() + } + return &skev2.Cluster{Name: new("ske-test")}, nil +} + +func (f *fakeControllerSKEAPI) DeleteCluster(_ context.Context, _, _, _ string) skev2.ApiDeleteClusterRequest { + return skev2.ApiDeleteClusterRequest{ApiService: f} +} + +func (f *fakeControllerSKEAPI) DeleteClusterExecute(_ skev2.ApiDeleteClusterRequest) (map[string]any, error) { + f.deleteCalls++ + if f.deleteClusterFn != nil { + return f.deleteClusterFn() + } + return map[string]any{}, nil +} + +type failingPatchClient struct { + client.Client + failPatch bool + failStatusPatch bool + failStatusForObject string +} + +func (c *failingPatchClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if c.failPatch { + return errors.New("simulated metadata patch failure") + } + return c.Client.Patch(ctx, obj, patch, opts...) +} + +func (c *failingPatchClient) Status() client.SubResourceWriter { + return &failingStatusWriter{ + SubResourceWriter: c.Client.Status(), + failStatusPatch: c.failStatusPatch, + failStatusForObject: c.failStatusForObject, + } +} + +type failingStatusWriter struct { + client.SubResourceWriter + failStatusPatch bool + failStatusForObject string +} + +func (w *failingStatusWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if w.failStatusPatch || (w.failStatusForObject != "" && obj.GetName() == w.failStatusForObject) { + return errors.New("simulated status patch failure") + } + return w.SubResourceWriter.Patch(ctx, obj, patch, opts...) +} + func validManagedClusterSpec() infrav1.StackitManagedClusterSpec { return infrav1.StackitManagedClusterSpec{ ProjectID: testProjectID, @@ -69,67 +160,866 @@ func validManagedNodePoolSpec() infrav1.StackitManagedNodePoolSpec { } } +func skeClusterWithState( + managedCluster *infrav1.StackitManagedCluster, + state skev2.ClusterStatusState, + pools ...skev2.Nodepool, +) *skev2.Cluster { + labels := util.ManagedClusterTags(managedCluster.Name, managedCluster.Namespace, string(managedCluster.UID), nil) + if len(pools) == 0 { + pools = []skev2.Nodepool{ + skeservice.ManagedNodePoolDesiredState{ + Name: infrav1.ManagedNodePoolBootstrapName, + AvailabilityZones: []string{"eu01-1"}, + MachineType: "c2i.2", + ImageName: "flatcar", + ImageVersion: "4593.2.5-containerd2.1.9", + Minimum: 1, + Maximum: 1, + VolumeSize: 20, + VolumeType: "storage_premium_perf1", + AllowSystemComponents: new(true), + }.ToSKESDK(), + } + } + return &skev2.Cluster{ + Name: new("ske-test"), + Labels: &labels, + Nodepools: pools, + Status: &skev2.ClusterStatus{ + Aggregated: &state, + }, + } +} + var _ = Describe("StackitManagedCluster Controller", func() { - Context("When reconciling a resource", func() { - const resourceName = "test-resource" + const namespace = "default" + + Context("When reconciling StackitManagedCluster lifecycle", func() { + var ( + clusterName string + credentials string + managedKey types.NamespacedName + request reconcile.Request + ownerCluster *clusterv1.Cluster + managedCluster *infrav1.StackitManagedCluster + fakeSKE *fakeControllerSKEAPI + reconciler *StackitManagedClusterReconciler + ) - ctx := context.Background() + BeforeEach(func() { + clusterName = fmt.Sprintf("mc-%d", time.Now().UnixNano()) + credentials = "stackit-creds-" + clusterName + managedKey = types.NamespacedName{Namespace: namespace, Name: clusterName} + request = reconcile.Request{NamespacedName: managedKey} + fakeSKE = &fakeControllerSKEAPI{} - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", - } - managedCluster := &infrav1.StackitManagedCluster{} + createCredentialsSecret(ctx, credentials, namespace, testProjectID) - BeforeEach(func() { - By("creating the custom resource for the Kind StackitManagedCluster") - err := k8sClient.Get(ctx, typeNamespacedName, managedCluster) - if err != nil && errors.IsNotFound(err) { - resource := &infrav1.StackitManagedCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + ownerCluster = &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedCluster", + Name: clusterName, }, - Spec: validManagedClusterSpec(), - } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }, + } + Expect(k8sClient.Create(ctx, ownerCluster)).To(Succeed()) + + spec := validManagedClusterSpec() + spec.CredentialsSecretRef.Name = credentials + spec.CredentialsSecretRef.Namespace = namespace + managedCluster = &infrav1.StackitManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: ownerCluster.Name, + UID: ownerCluster.UID, + }}, + }, + Spec: spec, + } + Expect(k8sClient.Create(ctx, managedCluster)).To(Succeed()) + + reconciler = &StackitManagedClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SKEClientFactory: func(context.Context, cloud.Credentials) (skev2.DefaultAPI, error) { + return fakeSKE, nil + }, } }) AfterEach(func() { - resource := &infrav1.StackitManagedCluster{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) + got := &infrav1.StackitManagedCluster{} + if err := k8sClient.Get(ctx, managedKey, got); err == nil { + got.Finalizers = nil + _ = k8sClient.Update(ctx, got) + deleteIfExists(ctx, got) + } + deleteIfExists(ctx, ownerCluster) + deleteIfExists(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: credentials, Namespace: namespace}}) + }) + + It("returns nil when the resource is not found", func() { + res, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: namespace, Name: "non-existent"}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + }) + + It("persists the finalizer before calling SKE CreateOrUpdateCluster and transitions Ready and Provisioned correctly", func() { + finalizerPresentDuringCreate := false + fakeSKE.createOrUpdateFn = func() (*skev2.Cluster, error) { + persisted := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, persisted)).To(Succeed()) + for _, f := range persisted.Finalizers { + if f == infrav1.ManagedClusterFinalizer { + finalizerPresentDuringCreate = true + } + } + return &skev2.Cluster{Name: new("ske-test")}, nil + } + + // 1. Initial reconcile: cluster is 404 -> finalizer persisted first, then CreateOrUpdateCluster called. + res, err := reconciler.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(finalizerPresentDuringCreate).To(BeTrue(), "finalizer must be persisted in API server before CreateOrUpdateCluster is called") + Expect(fakeSKE.createCalls).To(Equal(1)) - By("cleaning up the test StackitManagedCluster") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedClusterFinalizer)) + Expect(got.Status.Ready).To(BeFalse()) + Expect(got.Status.Initialization.Provisioned).To(BeNil()) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "Creating") + + // 2. Second reconcile: SKE is STATE_CREATING -> polls without calling CreateOrUpdateCluster again. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_CREATING), nil + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.createCalls).To(Equal(1)) + + // 3. Third reconcile: SKE reaches STATE_HEALTHY -> Ready=true and Initialization.Provisioned=true. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY), nil + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeTrue()) + Expect(got.Status.Initialization.Provisioned).NotTo(BeNil()) + Expect(*got.Status.Initialization.Provisioned).To(BeTrue()) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionTrue, "Available") + + // 4. Fourth reconcile: SKE becomes STATE_UNHEALTHY -> Ready=false, but Provisioned stays true (sticky). + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + c := skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_UNHEALTHY) + c.Status.Errors = []skev2.ClusterError{{Message: new("node pressure")}} + return c, nil + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + Expect(got.Status.Initialization.Provisioned).NotTo(BeNil()) + Expect(*got.Status.Initialization.Provisioned).To(BeTrue()) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "Unhealthy") + + // 5. Fifth reconcile: SKE reports an unknown state -> Ready=false (UnknownState), Provisioned stays true. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_UNKNOWN_DEFAULT_OPEN_API), nil + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + Expect(got.Status.Initialization.Provisioned).NotTo(BeNil()) + Expect(*got.Status.Initialization.Provisioned).To(BeTrue()) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "UnknownState") + }) + + It("does not call CreateOrUpdateCluster if persisting the finalizer fails", func() { + reconciler.Client = &failingPatchClient{ + Client: k8sClient, + failPatch: true, + } + + _, err := reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated metadata patch failure")) + Expect(fakeSKE.createCalls).To(Equal(0)) + }) + + It("propagates status patch errors from deferred PatchObject", func() { + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY), nil + } + reconciler.Client = &failingPatchClient{ + Client: k8sClient, + failStatusPatch: true, + } + + res, err := reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated status patch failure")) + Expect(res).To(Equal(reconcile.Result{})) + }) + + It("keeps the finalizer during asynchronous deletion and removes it only after confirmed HTTP 404", func() { + // Provision first so finalizer is present. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY), nil + } + _, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + + // 1. Delete triggered while cluster is HEALTHY -> calls DeleteCluster, retains finalizer. + res, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.deleteCalls).To(Equal(1)) + + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedClusterFinalizer)) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "Deleting") + + // 2. Next poll while SKE is STATE_DELETING -> does not call DeleteCluster again, retains finalizer. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_DELETING), nil + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.deleteCalls).To(Equal(1)) + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedClusterFinalizer)) + + // 3. Next poll returns HTTP 404 NotFound -> removes finalizer and object is deleted. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + + err = k8sClient.Get(ctx, managedKey, got) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &StackitManagedClusterReconciler{ + + It("completes deletion even when the owning Cluster is already deleted and the resource is paused", func() { + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY), nil + } + _, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + // Delete the owning CAPI Cluster first. + Expect(k8sClient.Delete(ctx, ownerCluster)).To(Succeed()) + + // Mark StackitManagedCluster as paused and delete it. + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + if got.Annotations == nil { + got.Annotations = map[string]string{} + } + got.Annotations[clusterv1.PausedAnnotation] = "true" + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + + // First delete reconcile triggers DeleteCluster despite missing owner Cluster and paused annotation. + res, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.deleteCalls).To(Equal(1)) + + // Second delete reconcile observes 404 and removes the finalizer. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, managedKey, got))).To(BeTrue()) + }) + + It("retains the finalizer when credentials Secret is missing during deletion", func() { + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY), nil + } + _, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + // Delete the credentials Secret and then delete StackitManagedCluster. + Expect(k8sClient.Delete(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: credentials, Namespace: namespace}, + })).To(Succeed()) + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + + _, err = reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(fakeSKE.deleteCalls).To(Equal(0)) + + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedClusterFinalizer)) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "CredentialsError") + }) + + It("refuses to adopt or delete a pre-existing SKE cluster with the same name and namespace but a different UID", func() { + state := skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY + staleLabels := util.ManagedClusterTags(managedCluster.Name, managedCluster.Namespace, "stale-uid-from-previous-cr", nil) + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return &skev2.Cluster{ + Name: new("ske-test"), + Labels: &staleLabels, + Status: &skev2.ClusterStatus{ + Aggregated: &state, + }, + }, nil + } + + _, err := reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(fakeSKE.createCalls).To(Equal(0)) + + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeFalse()) + Expect(got.Status.Initialization.Provisioned).To(BeNil()) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "ClusterNotManaged") + + // Deleting the StackitManagedCluster must NOT delete the unrelated SKE cluster. + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(fakeSKE.deleteCalls).To(Equal(0)) + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedClusterFinalizer)) + expectCondition(got.Status.Conditions, infrav1.ManagedClusterReadyCondition, metav1.ConditionFalse, "ClusterNotManaged") + }) + + It("tolerates and removes the legacy ManagedCluster finalizer during deletion", func() { + got := &infrav1.StackitManagedCluster{} + Expect(k8sClient.Get(ctx, managedKey, got)).To(Succeed()) + got.Finalizers = []string{infrav1.LegacyManagedClusterFinalizer} + Expect(k8sClient.Update(ctx, got)).To(Succeed()) + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} + } + res, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, managedKey, got))).To(BeTrue()) + }) + + It("aggregates multiple StackitManagedNodePools with a single GetCluster, projects child statuses, and completes standalone and full-cluster deletion", func() { + npReconciler := &StackitManagedNodePoolReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) + createChildPool := func(suffix, skePoolName string) *infrav1.StackitManagedNodePool { + mpName := fmt.Sprintf("%s-mp-%s", clusterName, suffix) + npName := fmt.Sprintf("%s-np-%s", clusterName, suffix) + mp := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: mpName, + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: ownerCluster.Name, + UID: ownerCluster.UID, + }}, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: clusterName, + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + ClusterName: clusterName, + Bootstrap: clusterv1.Bootstrap{DataSecretName: new("")}, + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: npName, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mp)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, mp) }) + + spec := validManagedNodePoolSpec() + spec.Name = skePoolName + np := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: npName, + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "MachinePool", + Name: mp.Name, + UID: mp.UID, + }}, + }, + Spec: spec, + } + Expect(k8sClient.Create(ctx, np)).To(Succeed()) + DeferCleanup(func() { + got := &infrav1.StackitManagedNodePool{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(np), got); err == nil { + got.Finalizers = nil + _ = k8sClient.Update(ctx, got) + deleteIfExists(ctx, got) + } + }) + + _, err := npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(np)}) + Expect(err).NotTo(HaveOccurred()) + return np + } + + poolA := createChildPool("a", "pool-a") + poolB := createChildPool("b", "pool-b") + + // Verify child reconciler made 0 SKE calls. + Expect(fakeSKE.getCalls).To(Equal(0)) + Expect(fakeSKE.createCalls).To(Equal(0)) + + sdkPoolA := skeservice.NodePoolDesiredState(poolA).ToSKESDK() + sdkPoolB := skeservice.NodePoolDesiredState(poolB).ToSKESDK() + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, sdkPoolA, sdkPoolB), nil + } + + // Simulate a partial child status-patch failure on poolB first. + reconciler.Client = &failingPatchClient{ + Client: k8sClient, + failStatusForObject: poolB.Name, + } + _, err := reconciler.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated status patch failure")) + reconciler.Client = k8sClient + + // Next reconcile succeeds and projects status onto both children using 1 GetCluster call. + beforeGets := fakeSKE.getCalls + res, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + Expect(fakeSKE.getCalls - beforeGets).To(Equal(1)) + + gotA := &infrav1.StackitManagedNodePool{} + gotB := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolA), gotA)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolB), gotB)).To(Succeed()) + Expect(gotA.Status.Ready).To(BeTrue()) + Expect(*gotA.Status.Initialization.Provisioned).To(BeTrue()) + Expect(gotA.Status.Replicas).To(BeZero()) + Expect(gotA.Spec.ProviderIDList).To(BeEmpty()) + expectCondition(gotA.Status.Conditions, infrav1.ManagedNodePoolReadyCondition, metav1.ConditionTrue, "Available") + expectCondition(gotA.Status.Conditions, clusterv1.PausedCondition, metav1.ConditionFalse, clusterv1.NotPausedReason) + Expect(gotB.Status.Ready).To(BeTrue()) + Expect(*gotB.Status.Initialization.Provisioned).To(BeTrue()) + + // Standalone deletion of poolB while poolA remains active: + Expect(k8sClient.Delete(ctx, poolB)).To(Succeed()) + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.createCalls).To(Equal(1)) + + // Next poll confirms poolB is absent from SKE -> parent writes Reason="Deleted" on poolB. + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, sdkPoolA), nil + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolB), gotB)).To(Succeed()) + expectCondition(gotB.Status.Conditions, infrav1.ManagedNodePoolReadyCondition, metav1.ConditionFalse, "Deleted") + + // Child reconciler observes Reason="Deleted" and removes its finalizer with 0 SKE calls. + beforeGets = fakeSKE.getCalls + _, err = npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(poolB)}) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeSKE.getCalls).To(Equal(beforeGets)) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolB), gotB))).To(BeTrue()) + + // Full cluster teardown with poolA still present: + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(managedClusterDeleteRequeueAfter)) + // Must NOT call DeleteCluster yet while poolA still exists, and must NOT issue extra PUTs. + Expect(fakeSKE.deleteCalls).To(Equal(0)) + Expect(fakeSKE.createCalls).To(Equal(1)) + + // poolA hits parent-deleting fast-path and releases its finalizer immediately. + _, err = npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(poolA)}) + Expect(err).NotTo(HaveOccurred()) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolA), gotA))).To(BeTrue()) + + // Now StackitManagedCluster proceeds with SKE DeleteCluster and removes its finalizer on 404. + res, err = reconciler.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.deleteCalls).To(Equal(1)) + + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound, ErrorMessage: "404 Not Found"} + } + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, managedKey, &infrav1.StackitManagedCluster{}))).To(BeTrue()) }) - It("should return nil when resource is not found", func() { - controllerReconciler := &StackitManagedClusterReconciler{ + It("resolves duplicate node-pool name deadlock when one of the colliding StackitManagedNodePools is deleted", func() { + npReconciler := &StackitManagedNodePoolReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "non-existent", + createDupPool := func(suffix string) *infrav1.StackitManagedNodePool { + mpName := fmt.Sprintf("%s-dup-mp-%s", clusterName, suffix) + npName := fmt.Sprintf("%s-dup-np-%s", clusterName, suffix) + mp := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: mpName, + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: ownerCluster.Name, + UID: ownerCluster.UID, + }}, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: clusterName, + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + ClusterName: clusterName, + Bootstrap: clusterv1.Bootstrap{DataSecretName: new("")}, + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: npName, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mp)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, mp) }) + + spec := validManagedNodePoolSpec() + spec.Name = "workers" + np := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: npName, + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "MachinePool", + Name: mp.Name, + UID: mp.UID, + }}, + }, + Spec: spec, + } + Expect(k8sClient.Create(ctx, np)).To(Succeed()) + DeferCleanup(func() { + got := &infrav1.StackitManagedNodePool{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(np), got); err == nil { + got.Finalizers = nil + _ = k8sClient.Update(ctx, got) + deleteIfExists(ctx, got) + } + }) + + _, err := npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(np)}) + Expect(err).NotTo(HaveOccurred()) + return np + } + + poolA := createDupPool("a") + poolB := createDupPool("b") + + sdkWorkers := skeservice.NodePoolDesiredState(poolA).ToSKESDK() + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, sdkWorkers), nil + } + + // 1. Both pools active with spec.name="workers" -> DuplicateNodePoolName, 0 SKE calls. + res, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + Expect(fakeSKE.getCalls).To(Equal(0)) + + gotA := &infrav1.StackitManagedNodePool{} + gotB := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolA), gotA)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolB), gotB)).To(Succeed()) + expectCondition(gotA.Status.Conditions, infrav1.ManagedNodePoolReadyCondition, metav1.ConditionFalse, skeservice.ReasonDuplicateNodePoolName) + expectCondition(gotB.Status.Conditions, infrav1.ManagedNodePoolReadyCondition, metav1.ConditionFalse, skeservice.ReasonDuplicateNodePoolName) + + // 2. Delete poolB to resolve the duplicate conflict. + Expect(k8sClient.Delete(ctx, poolB)).To(Succeed()) + res, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(reconcile.Result{})) + Expect(fakeSKE.createCalls).To(Equal(0)) + + // poolB gets Reason=Deleted while poolA becomes Ready=true. + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolA), gotA)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolB), gotB)).To(Succeed()) + Expect(gotA.Status.Ready).To(BeTrue()) + expectCondition(gotB.Status.Conditions, infrav1.ManagedNodePoolReadyCondition, metav1.ConditionFalse, skeservice.ReasonDeleted) + + // 3. Reconciling poolB removes its finalizer and deletes the CR. + _, err = npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(poolB)}) + Expect(err).NotTo(HaveOccurred()) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, client.ObjectKeyFromObject(poolB), gotB))).To(BeTrue()) + }) + + It("enforces per-pool pause semantics for both StackitManagedNodePool and MachinePool pause annotations", func() { + npReconciler := &StackitManagedNodePoolReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + mp := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName + "-pause-mp", + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: ownerCluster.Name, + UID: ownerCluster.UID, + }}, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: clusterName, + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + ClusterName: clusterName, + Bootstrap: clusterv1.Bootstrap{DataSecretName: new("")}, + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: clusterName + "-pause-np", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mp)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, mp) }) + + np := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName + "-pause-np", + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "MachinePool", + Name: mp.Name, + UID: mp.UID, + }}, }, + Spec: validManagedNodePoolSpec(), + } + Expect(k8sClient.Create(ctx, np)).To(Succeed()) + DeferCleanup(func() { + got := &infrav1.StackitManagedNodePool{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(np), got); err == nil { + got.Finalizers = nil + _ = k8sClient.Update(ctx, got) + deleteIfExists(ctx, got) + } }) + + // Finalize child and reconcile healthy cluster. + _, err := npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(np)}) + Expect(err).NotTo(HaveOccurred()) + + initialSDKPool := skeservice.NodePoolDesiredState(np).ToSKESDK() + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY, initialSDKPool), nil + } + _, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeSKE.createCalls).To(Equal(0)) + + // 1. Pause StackitManagedNodePool and change spec.maximum -> 0 SKE PUT calls. + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(np), np)).To(Succeed()) + np.Annotations = map[string]string{clusterv1.PausedAnnotation: "true"} + np.Spec.Maximum = 5 + Expect(k8sClient.Update(ctx, np)).To(Succeed()) + + _, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeSKE.createCalls).To(Equal(0)) + + // 2. Unpause StackitManagedNodePool, but pause MachinePool -> still 0 SKE PUT calls. + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(mp), mp)).To(Succeed()) + mp.Annotations = map[string]string{clusterv1.PausedAnnotation: "true"} + Expect(k8sClient.Update(ctx, mp)).To(Succeed()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(np), np)).To(Succeed()) + delete(np.Annotations, clusterv1.PausedAnnotation) + Expect(k8sClient.Update(ctx, np)).To(Succeed()) + + _, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeSKE.createCalls).To(Equal(0)) + + // 3. Unpause MachinePool -> desired spec change (Maximum=5) is now pushed to SKE. + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(mp), mp)).To(Succeed()) + delete(mp.Annotations, clusterv1.PausedAnnotation) + Expect(k8sClient.Update(ctx, mp)).To(Succeed()) + + res, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.createCalls).To(Equal(1)) + + // 4. Pause again and delete parent StackitManagedCluster -> parent deletion bypasses pause. + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(np), np)).To(Succeed()) + np.Annotations = map[string]string{clusterv1.PausedAnnotation: "true"} + Expect(k8sClient.Update(ctx, np)).To(Succeed()) + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + + _, err = reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + _, err = npReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(np)}) + Expect(err).NotTo(HaveOccurred()) + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, client.ObjectKeyFromObject(np), &infrav1.StackitManagedNodePool{}))).To(BeTrue()) + }) + + It("does not select or delete a StackitManagedNodePool belonging to a recreated Cluster with the same name but a different UID", func() { + fakeSKE.getClusterFn = func() (*skev2.Cluster, error) { + return skeClusterWithState(managedCluster, skev2.CLUSTERSTATUSSTATE_STATE_HEALTHY), nil + } + _, err := reconciler.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + // Delete the original Cluster (UID=A) and mark old StackitManagedCluster deleting. + Expect(k8sClient.Delete(ctx, ownerCluster)).To(Succeed()) + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + + // Recreate a new Cluster with the exact same name (which gets a new UID=B) and a new MachinePool + StackitManagedNodePool. + recreatedCluster := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedCluster", + Name: clusterName + "-v2", + }, + }, + } + Expect(k8sClient.Create(ctx, recreatedCluster)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, recreatedCluster) }) + + newMP := &clusterv1.MachinePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName + "-new-mp", + Namespace: namespace, + Labels: map[string]string{clusterv1.ClusterNameLabel: clusterName}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: recreatedCluster.Name, + UID: recreatedCluster.UID, + }}, + }, + Spec: clusterv1.MachinePoolSpec{ + ClusterName: clusterName, + Template: clusterv1.MachineTemplateSpec{ + Spec: clusterv1.MachineSpec{ + ClusterName: clusterName, + Bootstrap: clusterv1.Bootstrap{DataSecretName: new("")}, + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupVersion.Group, + Kind: "StackitManagedNodePool", + Name: clusterName + "-new-np", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, newMP)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, newMP) }) + + newNP := &infrav1.StackitManagedNodePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName + "-new-np", + Namespace: namespace, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: clusterName, + util.LabelClusterUID: "new-managed-cluster-uid", + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "MachinePool", + Name: newMP.Name, + UID: newMP.UID, + }}, + }, + Spec: validManagedNodePoolSpec(), + } + Expect(k8sClient.Create(ctx, newNP)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, newNP) }) + + // Reconciling the old terminating StackitManagedCluster must NOT delete or wait on newNP; + // it proceeds directly to SKE DeleteCluster. + res, err := reconciler.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(time.Minute)) + Expect(fakeSKE.deleteCalls).To(Equal(1)) + + gotNewNP := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(newNP), gotNewNP)).To(Succeed()) + Expect(gotNewNP.DeletionTimestamp.IsZero()).To(BeTrue()) }) }) @@ -220,14 +1110,14 @@ var _ = Describe("StackitManagedCluster Controller", func() { cluster := newManagedCluster("invalid-project-mc") cluster.Spec.ProjectID = "not-a-uuid" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects an invalid region", func() { cluster := newManagedCluster("invalid-region-mc") cluster.Spec.Region = "eu-central-1" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects an invalid network ID when network is specified", func() { @@ -236,14 +1126,14 @@ var _ = Describe("StackitManagedCluster Controller", func() { ID: "invalid-network-id", } err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects missing credentials secret name", func() { cluster := newManagedCluster("missing-creds-mc") cluster.Spec.CredentialsSecretRef.Name = "" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) Expect(err.Error()).To(ContainSubstring("credentialsSecretRef.name is required")) }) @@ -265,35 +1155,35 @@ var _ = Describe("StackitManagedCluster Controller", func() { cluster := newManagedCluster("long-name-mc") cluster.Spec.ClusterName = "ske-cluster1" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects an uppercase cluster name", func() { cluster := newManagedCluster("uppercase-name-mc") cluster.Spec.ClusterName = "SkeCluster" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects a cluster name containing an underscore", func() { cluster := newManagedCluster("underscore-name-mc") cluster.Spec.ClusterName = "ske_cluster" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects a cluster name with a leading hyphen", func() { cluster := newManagedCluster("leading-hyphen-mc") cluster.Spec.ClusterName = "-skecluster" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects a cluster name with a trailing hyphen", func() { cluster := newManagedCluster("trailing-hyphen-mc") cluster.Spec.ClusterName = "skecluster-" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("accepts Kubernetes version 1.35.3", func() { @@ -307,21 +1197,21 @@ var _ = Describe("StackitManagedCluster Controller", func() { cluster := newManagedCluster("short-k8s-ver-mc") cluster.Spec.Kubernetes.Version = "1.35" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects Kubernetes version v1.35.3 with leading v", func() { cluster := newManagedCluster("v-prefix-k8s-ver-mc") cluster.Spec.Kubernetes.Version = "v1.35.3" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects Kubernetes version latest", func() { cluster := newManagedCluster("latest-k8s-ver-mc") cluster.Spec.Kubernetes.Version = "latest" err := k8sClient.Create(ctx, cluster) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("validates status.initialization pointer shape and MinProperties=1", func() { @@ -344,7 +1234,7 @@ var _ = Describe("StackitManagedCluster Controller", func() { raw.SetKind("StackitManagedCluster") Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), raw)).To(Succeed()) Expect(unstructured.SetNestedMap(raw.Object, map[string]any{}, "status", "initialization")).To(Succeed()) - Expect(errors.IsInvalid(k8sClient.Status().Update(ctx, raw))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Status().Update(ctx, raw))).To(BeTrue()) }) }) @@ -417,42 +1307,61 @@ var _ = Describe("StackitManagedCluster Controller", func() { pool := newManagedNodePool("long-name-np") pool.Spec.Name = "nodepool-too-long" err := k8sClient.Create(ctx, pool) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("rejects the reserved provider bootstrap node-pool name capstk-system", func() { + pool := newManagedNodePool("reserved-name-np") + pool.Spec.Name = infrav1.ManagedNodePoolBootstrapName + err := k8sClient.Create(ctx, pool) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("reserved for the provider bootstrap node pool")) + }) + + It("enforces immutability of spec.name after creation", func() { + pool := newManagedNodePool("immutable-name-np") + Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + DeferCleanup(func() { deleteIfExists(ctx, pool) }) + + pool.Spec.Name = "renamed-np" + err := k8sClient.Update(ctx, pool) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("name is immutable")) }) It("rejects missing required availabilityZones", func() { pool := newManagedNodePool("missing-azs-np") pool.Spec.AvailabilityZones = nil err := k8sClient.Create(ctx, pool) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects missing machine", func() { raw := newUnstructuredManagedNodePool("missing-machine-np") unstructured.RemoveNestedField(raw.Object, "spec", "machine") err := k8sClient.Create(ctx, raw) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects missing required machine image fields", func() { rawMissingImage := newUnstructuredManagedNodePool("missing-image-np") unstructured.RemoveNestedField(rawMissingImage.Object, "spec", "machine", "image") - Expect(errors.IsInvalid(k8sClient.Create(ctx, rawMissingImage))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, rawMissingImage))).To(BeTrue()) rawMissingImageName := newUnstructuredManagedNodePool("missing-image-name-np") unstructured.RemoveNestedField(rawMissingImageName.Object, "spec", "machine", "image", "name") - Expect(errors.IsInvalid(k8sClient.Create(ctx, rawMissingImageName))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, rawMissingImageName))).To(BeTrue()) rawMissingImageVersion := newUnstructuredManagedNodePool("missing-image-ver-np") unstructured.RemoveNestedField(rawMissingImageVersion.Object, "spec", "machine", "image", "version") - Expect(errors.IsInvalid(k8sClient.Create(ctx, rawMissingImageVersion))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, rawMissingImageVersion))).To(BeTrue()) }) It("rejects missing volume", func() { raw := newUnstructuredManagedNodePool("missing-volume-np") unstructured.RemoveNestedField(raw.Object, "spec", "volume") err := k8sClient.Create(ctx, raw) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects node-pool labels exceeding MaxProperties=80", func() { @@ -461,7 +1370,7 @@ var _ = Describe("StackitManagedCluster Controller", func() { for i := range 81 { pool.Spec.Labels[fmt.Sprintf("k%d", i)] = "v" } - Expect(errors.IsInvalid(k8sClient.Create(ctx, pool))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, pool))).To(BeTrue()) }) It("rejects minimum greater than maximum", func() { @@ -469,7 +1378,7 @@ var _ = Describe("StackitManagedCluster Controller", func() { pool.Spec.Minimum = 5 pool.Spec.Maximum = 3 err := k8sClient.Create(ctx, pool) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) Expect(err.Error()).To(ContainSubstring("minimum must be less than or equal to maximum")) }) @@ -477,24 +1386,24 @@ var _ = Describe("StackitManagedCluster Controller", func() { poolMaxZero := newManagedNodePool("max-zero-np") poolMaxZero.Spec.Minimum = 0 poolMaxZero.Spec.Maximum = 0 - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolMaxZero))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolMaxZero))).To(BeTrue()) poolMaxOver1000 := newManagedNodePool("max-over-1000-np") poolMaxOver1000.Spec.Maximum = 1001 - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolMaxOver1000))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolMaxOver1000))).To(BeTrue()) poolMinOver1000 := newManagedNodePool("min-over-1000-np") poolMinOver1000.Spec.Minimum = 1001 poolMinOver1000.Spec.Maximum = 1000 - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolMinOver1000))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolMinOver1000))).To(BeTrue()) poolVolTooSmall := newManagedNodePool("vol-small-np") poolVolTooSmall.Spec.Volume.Size = 19 - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolVolTooSmall))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolVolTooSmall))).To(BeTrue()) poolVolTooLarge := newManagedNodePool("vol-large-np") poolVolTooLarge.Spec.Volume.Size = 10241 - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolVolTooLarge))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolVolTooLarge))).To(BeTrue()) }) It("rejects an invalid taint effect", func() { @@ -506,17 +1415,17 @@ var _ = Describe("StackitManagedCluster Controller", func() { }, } err := k8sClient.Create(ctx, pool) - Expect(errors.IsInvalid(err)).To(BeTrue()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) }) It("rejects invalid providerIDList items violating CAPI contract bounds", func() { poolEmptyItem := newManagedNodePool("empty-providerid-np") poolEmptyItem.Spec.ProviderIDList = []string{""} - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolEmptyItem))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolEmptyItem))).To(BeTrue()) poolTooLongItem := newManagedNodePool("long-providerid-np") poolTooLongItem.Spec.ProviderIDList = []string{strings.Repeat("a", 513)} - Expect(errors.IsInvalid(k8sClient.Create(ctx, poolTooLongItem))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Create(ctx, poolTooLongItem))).To(BeTrue()) }) It("validates status.initialization pointer shape, MinProperties=1, and zero replicas serialization", func() { @@ -546,7 +1455,7 @@ var _ = Describe("StackitManagedCluster Controller", func() { Expect(replicasVal).To(Equal(int64(0))) Expect(unstructured.SetNestedMap(raw.Object, map[string]any{}, "status", "initialization")).To(Succeed()) - Expect(errors.IsInvalid(k8sClient.Status().Update(ctx, raw))).To(BeTrue()) + Expect(apierrors.IsInvalid(k8sClient.Status().Update(ctx, raw))).To(BeTrue()) }) }) }) diff --git a/controller/stackitmanagednodepool_controller.go b/controller/stackitmanagednodepool_controller.go index cb15092..8965df4 100644 --- a/controller/stackitmanagednodepool_controller.go +++ b/controller/stackitmanagednodepool_controller.go @@ -17,29 +17,49 @@ limitations under the License. package controller import ( + "cmp" "context" "errors" "fmt" + "slices" + "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/utils/ptr" clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" clusterutil "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/annotations" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +const ( + nodePoolDeletedReason = "Deleted" ) // StackitManagedNodePoolReconciler reconciles a StackitManagedNodePool object. // -// Only the managed-cluster reconciliation path performs mutating SKE cluster -// API calls; this reconciler resolves CAPI ownership relationships and leaves -// SKE cluster payload mutation to StackitManagedClusterReconciler. +// StackitManagedNodePoolReconciler is a pure Kubernetes lifecycle and finalizer +// controller. It makes zero SKE API calls and does not access STACKIT +// credentials; all SKE reads, writes, and child status projections are owned by +// StackitManagedClusterReconciler. type StackitManagedNodePoolReconciler struct { client.Client Scheme *runtime.Scheme @@ -47,11 +67,12 @@ type StackitManagedNodePoolReconciler struct { // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagednodepools/finalizers,verbs=update // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=stackitmanagedclusters,verbs=get;list;watch // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=machinepools,verbs=get;list;watch // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters,verbs=get;list;watch -func (r *StackitManagedNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *StackitManagedNodePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) { log := logf.FromContext(ctx) nodePool := &infrav1.StackitManagedNodePool{} @@ -62,58 +83,155 @@ func (r *StackitManagedNodePoolReconciler) Reconcile(ctx context.Context, req ct return ctrl.Result{}, err } + if !nodePool.DeletionTimestamp.IsZero() && + !controllerutil.ContainsFinalizer(nodePool, infrav1.ManagedNodePoolFinalizer) { + return ctrl.Result{}, nil + } + machinePool, err := getMachinePoolForNodePool(ctx, r.Client, nodePool) if err != nil { return ctrl.Result{}, fmt.Errorf("get owning MachinePool: %w", err) } - if machinePool == nil { - log.Info("StackitManagedNodePool has no owning MachinePool yet, requeueing") - return ctrl.Result{}, nil - } cluster, err := getClusterForMachinePool(ctx, r.Client, machinePool) if err != nil { return ctrl.Result{}, fmt.Errorf("get Cluster for MachinePool: %w", err) } - if cluster == nil { - log.Info("MachinePool has no owning Cluster yet, requeueing", "machinePool", machinePool.Name) - return ctrl.Result{}, nil + if cluster != nil && (!isStackitManagedClusterRef(cluster.Spec.InfrastructureRef) || !isNodePoolAssociatedWithMachinePool(nodePool, machinePool, cluster)) { + cluster = nil } - if !isStackitManagedClusterRef(cluster.Spec.InfrastructureRef) { - log.Info("Cluster does not reference a StackitManagedCluster, skipping", "cluster", cluster.Name) - return ctrl.Result{}, nil + var managedCluster *infrav1.StackitManagedCluster + if cluster != nil { + managedCluster, err = getStackitManagedClusterForCluster(ctx, r.Client, cluster) + if err != nil { + return ctrl.Result{}, err + } + if managedCluster != nil && hasIncompatibleNodePoolClusterUIDLabel(nodePool, managedCluster) { + managedCluster = nil + } } - managedCluster, err := getStackitManagedClusterForCluster(ctx, r.Client, cluster) + if nodePool.DeletionTimestamp.IsZero() { + if machinePool == nil { + log.Info("StackitManagedNodePool has no owning MachinePool yet, requeueing") + return ctrl.Result{}, nil + } + if cluster == nil { + log.Info("MachinePool has no owning Cluster referencing a StackitManagedCluster yet, requeueing", "machinePool", machinePool.Name) + return ctrl.Result{}, nil + } + if managedCluster == nil { + log.Info("StackitManagedCluster not found, requeueing", "managedCluster", cluster.Spec.InfrastructureRef.Name) + return ctrl.Result{}, nil + } + if !cluster.DeletionTimestamp.IsZero() || !managedCluster.DeletionTimestamp.IsZero() { + log.Info("Parent cluster is deleting, skipping finalizer addition for StackitManagedNodePool") + return ctrl.Result{}, nil + } + } + + nodePoolScope, err := scope.NewManagedNodePoolScope(r.Client, cluster, machinePool, managedCluster, nodePool) if err != nil { - return ctrl.Result{}, err + return ctrl.Result{}, fmt.Errorf("create managed node pool scope: %w", err) } - if managedCluster == nil { - log.Info("StackitManagedCluster not found, requeueing", "managedCluster", cluster.Spec.InfrastructureRef.Name) + defer func() { + if patchErr := nodePoolScope.PatchObject(ctx); patchErr != nil { + res = ctrl.Result{} + err = kerrors.NewAggregate([]error{err, patchErr}) + } + }() + + if !nodePool.DeletionTimestamp.IsZero() { + r.reconcileDelete(ctx, nodePoolScope) return ctrl.Result{}, nil } - if !nodePool.DeletionTimestamp.IsZero() { - log.Info( - "Reconciling deleting StackitManagedNodePool", - "cluster", cluster.Name, - "machinePool", machinePool.Name, - "managedCluster", managedCluster.Name, - ) + if paused, message := managedNodePoolPaused(cluster, machinePool, managedCluster, nodePool); paused { + util.SetPausedCondition(&nodePool.Status.Conditions, nodePool.Generation, true, message) return ctrl.Result{}, nil } + util.SetPausedCondition(&nodePool.Status.Conditions, nodePool.Generation, false, "") - log.Info( - "Reconciling StackitManagedNodePool", - "cluster", cluster.Name, - "machinePool", machinePool.Name, - "managedCluster", managedCluster.Name, - ) + if !controllerutil.ContainsFinalizer(nodePool, infrav1.ManagedNodePoolFinalizer) { + controllerutil.AddFinalizer(nodePool, infrav1.ManagedNodePoolFinalizer) + } + if managedCluster.UID != "" { + if nodePool.Labels == nil { + nodePool.Labels = make(map[string]string) + } + nodePool.Labels[util.LabelClusterUID] = string(managedCluster.UID) + } return ctrl.Result{}, nil } +func (r *StackitManagedNodePoolReconciler) reconcileDelete( + ctx context.Context, + nodePoolScope *scope.ManagedNodePoolScope, +) { + log := logf.FromContext(ctx) + nodePool := nodePoolScope.StackitManagedNodePool + cluster := nodePoolScope.Cluster + machinePool := nodePoolScope.MachinePool + managedCluster := nodePoolScope.StackitManagedCluster + + // Fast path: if any parent in the CAPI hierarchy is already gone or + // deleting, release the node-pool finalizer immediately so parent cluster + // teardown can delete the entire SKE cluster in one operation. + if machinePool == nil || + cluster == nil || + managedCluster == nil || + !cluster.DeletionTimestamp.IsZero() || + !managedCluster.DeletionTimestamp.IsZero() { + log.Info("Releasing StackitManagedNodePool finalizer because parent cluster is missing or deleting") + controllerutil.RemoveFinalizer(nodePool, infrav1.ManagedNodePoolFinalizer) + return + } + + // Standalone deletion handshake: remove the finalizer once the parent + // StackitManagedClusterReconciler confirms the external node pool is absent + // from SKE by projecting Ready=False with Reason="Deleted". + readyCond := meta.FindStatusCondition(nodePool.Status.Conditions, infrav1.ManagedNodePoolReadyCondition) + if readyCond != nil && + readyCond.Status == metav1.ConditionFalse && + readyCond.Reason == nodePoolDeletedReason { + log.Info("Confirmed external SKE node pool deletion, removing finalizer", "nodePoolName", nodePool.Spec.Name) + controllerutil.RemoveFinalizer(nodePool, infrav1.ManagedNodePoolFinalizer) + return + } + + log.V(1).Info("Waiting for StackitManagedCluster to confirm SKE node pool removal", "nodePoolName", nodePool.Spec.Name) +} + +func managedNodePoolPaused( + cluster *clusterv1.Cluster, + machinePool *clusterv1.MachinePool, + managedCluster *infrav1.StackitManagedCluster, + nodePool *infrav1.StackitManagedNodePool, +) (bool, string) { + var reasons []string + if cluster != nil && ptr.Deref(cluster.Spec.Paused, false) { + reasons = append(reasons, "Cluster spec.paused is set to true") + } + if cluster != nil && annotations.HasPaused(cluster) { + reasons = append(reasons, "Cluster has the cluster.x-k8s.io/paused annotation") + } + if managedCluster != nil && annotations.HasPaused(managedCluster) { + reasons = append(reasons, "StackitManagedCluster has the cluster.x-k8s.io/paused annotation") + } + if machinePool != nil && annotations.HasPaused(machinePool) { + reasons = append(reasons, "MachinePool has the cluster.x-k8s.io/paused annotation") + } + if annotations.HasPaused(nodePool) { + reasons = append(reasons, "object has the cluster.x-k8s.io/paused annotation") + } + if len(reasons) == 0 { + return false, "" + } + return true, strings.Join(reasons, ", ") +} + // SetupWithManager registers the controller with the manager. func (r *StackitManagedNodePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). @@ -122,10 +240,55 @@ func (r *StackitManagedNodePoolReconciler) SetupWithManager(mgr ctrl.Manager) er &clusterv1.MachinePool{}, handler.EnqueueRequestsFromMapFunc(r.stackitManagedNodePoolRequestsForMachinePool), ). + Watches( + &infrav1.StackitManagedCluster{}, + handler.EnqueueRequestsFromMapFunc(r.stackitManagedNodePoolRequestsForManagedCluster), + builder.WithPredicates(parentLifecycleWatchPredicate()), + ). + Watches( + &clusterv1.Cluster{}, + handler.EnqueueRequestsFromMapFunc(r.stackitManagedNodePoolRequestsForCluster), + builder.WithPredicates(parentLifecycleWatchPredicate()), + ). Named("stackitmanagednodepool"). Complete(r) } +func parentLifecycleWatchPredicate() predicate.Funcs { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { + return false + }, + DeleteFunc: func(_ event.DeleteEvent) bool { + return true + }, + GenericFunc: func(_ event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return parentLifecycleChanged(e) + }, + } +} + +func parentLifecycleChanged(e event.UpdateEvent) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return false + } + if e.ObjectOld.GetDeletionTimestamp().IsZero() != e.ObjectNew.GetDeletionTimestamp().IsZero() { + return true + } + if annotations.HasPaused(e.ObjectOld) != annotations.HasPaused(e.ObjectNew) { + return true + } + oldCluster, okOld := e.ObjectOld.(*clusterv1.Cluster) + newCluster, okNew := e.ObjectNew.(*clusterv1.Cluster) + if okOld && okNew { + return ptr.Deref(oldCluster.Spec.Paused, false) != ptr.Deref(newCluster.Spec.Paused, false) + } + return false +} + func (r *StackitManagedNodePoolReconciler) stackitManagedNodePoolRequestsForMachinePool(_ context.Context, obj client.Object) []reconcile.Request { machinePool, ok := obj.(*clusterv1.MachinePool) if !ok { @@ -134,6 +297,50 @@ func (r *StackitManagedNodePoolReconciler) stackitManagedNodePoolRequestsForMach return stackitManagedNodePoolRequestForMachinePool(machinePool) } +func (r *StackitManagedNodePoolReconciler) stackitManagedNodePoolRequestsForManagedCluster(ctx context.Context, obj client.Object) []reconcile.Request { + managedCluster, ok := obj.(*infrav1.StackitManagedCluster) + if !ok || r.Client == nil { + return nil + } + cluster, err := getVerifiedOwnerCluster(ctx, r.Client, managedCluster) + if err != nil { + return nil + } + entries, err := listManagedNodePoolsForCluster(ctx, r.Client, cluster, managedCluster) + if err != nil { + return nil + } + requests := make([]reconcile.Request, 0, len(entries)) + for _, entry := range entries { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(entry.NodePool), + }) + } + return requests +} + +func (r *StackitManagedNodePoolReconciler) stackitManagedNodePoolRequestsForCluster(ctx context.Context, obj client.Object) []reconcile.Request { + cluster, ok := obj.(*clusterv1.Cluster) + if !ok || r.Client == nil || !isStackitManagedClusterRef(cluster.Spec.InfrastructureRef) { + return nil + } + managedCluster, err := getStackitManagedClusterForCluster(ctx, r.Client, cluster) + if err != nil { + return nil + } + entries, err := listManagedNodePoolsForCluster(ctx, r.Client, cluster, managedCluster) + if err != nil { + return nil + } + requests := make([]reconcile.Request, 0, len(entries)) + for _, entry := range entries { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(entry.NodePool), + }) + } + return requests +} + func stackitManagedNodePoolRequestForMachinePool(machinePool *clusterv1.MachinePool) []reconcile.Request { if machinePool == nil { return nil @@ -163,6 +370,115 @@ func isStackitManagedClusterRef(ref clusterv1.ContractVersionedObjectReference) ref.Name != "" } +func hasIncompatibleOwnerUID(refs []metav1.OwnerReference, kind string, expectedUID types.UID) bool { + if expectedUID == "" { + return false + } + for _, ref := range refs { + if ref.Kind != kind { + continue + } + gv, err := schema.ParseGroupVersion(ref.APIVersion) + if err != nil || gv.Group != clusterv1.GroupVersion.Group { + continue + } + if ref.UID != "" && ref.UID != expectedUID { + return true + } + } + return false +} + +func hasMatchingOwnerUID(refs []metav1.OwnerReference, kind string, expectedUID types.UID) bool { + if expectedUID == "" { + return false + } + for _, ref := range refs { + if ref.Kind != kind { + continue + } + gv, err := schema.ParseGroupVersion(ref.APIVersion) + if err != nil || gv.Group != clusterv1.GroupVersion.Group { + continue + } + if ref.UID == expectedUID { + return true + } + } + return false +} + +func hasIncompatibleNodePoolClusterUIDLabel( + nodePool *infrav1.StackitManagedNodePool, + managedCluster *infrav1.StackitManagedCluster, +) bool { + if nodePool == nil || managedCluster == nil || managedCluster.UID == "" { + return false + } + uidLabel := nodePool.Labels[util.LabelClusterUID] + return uidLabel != "" && uidLabel != string(managedCluster.UID) +} + +func getVerifiedOwnerCluster( + ctx context.Context, + c client.Client, + managedCluster *infrav1.StackitManagedCluster, +) (*clusterv1.Cluster, error) { + if managedCluster == nil { + return nil, nil + } + cluster, err := clusterutil.GetOwnerCluster(ctx, c, managedCluster.ObjectMeta) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + if cluster != nil && hasIncompatibleOwnerUID(managedCluster.OwnerReferences, "Cluster", cluster.UID) { + return nil, nil + } + return cluster, nil +} + +func isMachinePoolAssociatedWithCluster(machinePool *clusterv1.MachinePool, cluster *clusterv1.Cluster) bool { + if machinePool == nil || cluster == nil || machinePool.Namespace != cluster.Namespace { + return false + } + if hasIncompatibleOwnerUID(machinePool.OwnerReferences, "Cluster", cluster.UID) { + return false + } + labelCluster := machinePool.Labels[clusterv1.ClusterNameLabel] + if labelCluster != "" && labelCluster != cluster.Name { + return false + } + if machinePool.Spec.ClusterName != "" && machinePool.Spec.ClusterName != cluster.Name { + return false + } + return machinePool.Spec.ClusterName == cluster.Name || labelCluster == cluster.Name +} + +func isNodePoolAssociatedWithMachinePool( + nodePool *infrav1.StackitManagedNodePool, + machinePool *clusterv1.MachinePool, + cluster *clusterv1.Cluster, +) bool { + if nodePool == nil || machinePool == nil || nodePool.Namespace != machinePool.Namespace { + return false + } + if !isStackitManagedNodePoolRef(machinePool.Spec.Template.Spec.InfrastructureRef, nodePool.Name) { + return false + } + if hasIncompatibleOwnerUID(nodePool.OwnerReferences, "MachinePool", machinePool.UID) { + return false + } + if cluster != nil { + if labelCluster := nodePool.Labels[clusterv1.ClusterNameLabel]; labelCluster != "" && labelCluster != cluster.Name { + return false + } + } + return true +} + func getMachinePoolForNodePool( ctx context.Context, c client.Client, @@ -177,7 +493,7 @@ func getMachinePoolForNodePool( return nil, err } if machinePool != nil { - if !isStackitManagedNodePoolRef(machinePool.Spec.Template.Spec.InfrastructureRef, nodePool.Name) { + if !isNodePoolAssociatedWithMachinePool(nodePool, machinePool, nil) { return nil, nil } return machinePool, nil @@ -189,7 +505,7 @@ func getMachinePoolForNodePool( } for i := range machinePools.Items { mp := &machinePools.Items[i] - if isStackitManagedNodePoolRef(mp.Spec.Template.Spec.InfrastructureRef, nodePool.Name) { + if isNodePoolAssociatedWithMachinePool(nodePool, mp, nil) { return mp, nil } } @@ -205,27 +521,24 @@ func getClusterForMachinePool( return nil, nil } + var cluster *clusterv1.Cluster + var err error if machinePool.Labels[clusterv1.ClusterNameLabel] != "" { - cluster, err := clusterutil.GetClusterFromMetadata(ctx, c, machinePool.ObjectMeta) - switch { - case errors.Is(err, clusterutil.ErrNoCluster) || apierrors.IsNotFound(err): - return nil, nil - case err != nil: + cluster, err = clusterutil.GetClusterFromMetadata(ctx, c, machinePool.ObjectMeta) + if err != nil && !errors.Is(err, clusterutil.ErrNoCluster) && !apierrors.IsNotFound(err) { return nil, err - default: - return cluster, nil } } - - if machinePool.Spec.ClusterName != "" { - cluster, err := clusterutil.GetClusterByName(ctx, c, machinePool.Namespace, machinePool.Spec.ClusterName) - if apierrors.IsNotFound(err) { - return nil, nil + if cluster == nil && machinePool.Spec.ClusterName != "" { + cluster, err = clusterutil.GetClusterByName(ctx, c, machinePool.Namespace, machinePool.Spec.ClusterName) + if err != nil && !apierrors.IsNotFound(err) { + return nil, err } - return cluster, err } - - return nil, nil + if cluster != nil && !isMachinePoolAssociatedWithCluster(machinePool, cluster) { + return nil, nil + } + return cluster, nil } func getStackitManagedClusterForCluster( @@ -247,5 +560,138 @@ func getStackitManagedClusterForCluster( } return nil, fmt.Errorf("get StackitManagedCluster %s: %w", key, err) } + if hasIncompatibleOwnerUID(managedCluster.OwnerReferences, "Cluster", cluster.UID) { + return nil, nil + } return managedCluster, nil } + +// listManagedNodePoolsForCluster discovers all StackitManagedNodePools +// belonging to the given Cluster / StackitManagedCluster, sorted +// deterministically by Kubernetes resource name. +func listManagedNodePoolsForCluster( + ctx context.Context, + c client.Client, + cluster *clusterv1.Cluster, + managedCluster *infrav1.StackitManagedCluster, +) ([]scope.ManagedNodePoolEntry, error) { + if managedCluster == nil { + return nil, nil + } + + targetCluster := cluster + if targetCluster != nil { + if !isStackitManagedClusterRef(targetCluster.Spec.InfrastructureRef) || + targetCluster.Spec.InfrastructureRef.Name != managedCluster.Name || + hasIncompatibleOwnerUID(managedCluster.OwnerReferences, "Cluster", targetCluster.UID) { + return nil, nil + } + } else { + clusterName, clusterUID := ownerClusterIdentity(managedCluster) + if clusterName != "" { + targetCluster = &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: managedCluster.Namespace, + UID: clusterUID, + }, + } + } + } + + byName := make(map[string]scope.ManagedNodePoolEntry) + if targetCluster != nil { + machinePools := &clusterv1.MachinePoolList{} + if err := c.List(ctx, machinePools, client.InNamespace(managedCluster.Namespace)); err != nil { + return nil, fmt.Errorf("list MachinePools: %w", err) + } + + for i := range machinePools.Items { + mp := &machinePools.Items[i] + if !isMachinePoolAssociatedWithCluster(mp, targetCluster) { + continue + } + ref := mp.Spec.Template.Spec.InfrastructureRef + if !isStackitManagedNodePoolRef(ref, "") { + continue + } + nodePool := &infrav1.StackitManagedNodePool{} + key := types.NamespacedName{Namespace: managedCluster.Namespace, Name: ref.Name} + if err := c.Get(ctx, key, nodePool); err != nil { + if apierrors.IsNotFound(err) { + continue + } + return nil, fmt.Errorf("get StackitManagedNodePool %s: %w", key, err) + } + if !isNodePoolAssociatedWithMachinePool(nodePool, mp, targetCluster) { + continue + } + if hasIncompatibleNodePoolClusterUIDLabel(nodePool, managedCluster) { + continue + } + // When the owning Cluster object is gone (cluster == nil), require + // positive UID proof so a terminating StackitManagedCluster cannot + // match a newly created MachinePool/NodePool with the same cluster name. + if cluster == nil { + uidMatched := hasMatchingOwnerUID(mp.OwnerReferences, "Cluster", targetCluster.UID) || + (managedCluster.UID != "" && nodePool.Labels[util.LabelClusterUID] == string(managedCluster.UID)) + if !uidMatched { + continue + } + } + byName[nodePool.Name] = scope.ManagedNodePoolEntry{ + NodePool: nodePool, + Paused: annotations.HasPaused(mp) || annotations.HasPaused(nodePool), + } + } + } + + // UID-safe deletion fallback: if a MachinePool was already deleted before + // its child StackitManagedNodePool finished releasing its finalizer, match + // only StackitManagedNodePools carrying the exact StackitManagedCluster UID + // label stamped when the child finalizer was established. + if (cluster == nil || !cluster.DeletionTimestamp.IsZero() || !managedCluster.DeletionTimestamp.IsZero()) && + managedCluster.UID != "" { + uidTaggedPools := &infrav1.StackitManagedNodePoolList{} + if err := c.List(ctx, uidTaggedPools, + client.InNamespace(managedCluster.Namespace), + client.MatchingLabels{util.LabelClusterUID: string(managedCluster.UID)}, + ); err != nil { + return nil, fmt.Errorf("list UID-labeled StackitManagedNodePools: %w", err) + } + for i := range uidTaggedPools.Items { + np := &uidTaggedPools.Items[i] + if np.Labels[util.LabelClusterUID] != string(managedCluster.UID) { + continue + } + if _, exists := byName[np.Name]; !exists { + byName[np.Name] = scope.ManagedNodePoolEntry{ + NodePool: np, + Paused: annotations.HasPaused(np), + } + } + } + } + + result := make([]scope.ManagedNodePoolEntry, 0, len(byName)) + for _, entry := range byName { + result = append(result, entry) + } + slices.SortFunc(result, func(a, b scope.ManagedNodePoolEntry) int { + return cmp.Compare(a.NodePool.Name, b.NodePool.Name) + }) + return result, nil +} + +func ownerClusterIdentity(managedCluster *infrav1.StackitManagedCluster) (string, types.UID) { + for _, ref := range managedCluster.OwnerReferences { + if ref.Kind != "Cluster" { + continue + } + gv, err := schema.ParseGroupVersion(ref.APIVersion) + if err == nil && gv.Group == clusterv1.GroupVersion.Group { + return ref.Name, ref.UID + } + } + return "", "" +} diff --git a/controller/stackitmanagednodepool_controller_test.go b/controller/stackitmanagednodepool_controller_test.go index b94af96..83bcd06 100644 --- a/controller/stackitmanagednodepool_controller_test.go +++ b/controller/stackitmanagednodepool_controller_test.go @@ -18,16 +18,22 @@ package controller import ( "context" + "fmt" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + 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" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/reconcile" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/util" ) var _ = Describe("StackitManagedNodePool Controller", func() { @@ -44,7 +50,14 @@ var _ = Describe("StackitManagedNodePool Controller", func() { Spec: validManagedClusterSpec(), } Expect(k8sClient.Create(ctx, managedCluster)).To(Succeed()) - DeferCleanup(func() { deleteIfExists(ctx, managedCluster) }) + DeferCleanup(func() { + got := &infrav1.StackitManagedCluster{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(managedCluster), got); err == nil { + got.Finalizers = nil + _ = k8sClient.Update(ctx, got) + deleteIfExists(ctx, got) + } + }) cluster := &clusterv1.Cluster{ ObjectMeta: metav1.ObjectMeta{ @@ -62,6 +75,14 @@ var _ = Describe("StackitManagedNodePool Controller", func() { Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) DeferCleanup(func() { deleteIfExists(ctx, cluster) }) + managedCluster.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: cluster.Name, + UID: cluster.UID, + }} + Expect(k8sClient.Update(ctx, managedCluster)).To(Succeed()) + machinePool := &clusterv1.MachinePool{ ObjectMeta: metav1.ObjectMeta{ Name: machinePoolName, @@ -69,6 +90,12 @@ var _ = Describe("StackitManagedNodePool Controller", func() { Labels: map[string]string{ clusterv1.ClusterNameLabel: clusterName, }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: cluster.Name, + UID: cluster.UID, + }}, }, Spec: clusterv1.MachinePoolSpec{ ClusterName: clusterName, @@ -94,6 +121,9 @@ var _ = Describe("StackitManagedNodePool Controller", func() { ObjectMeta: metav1.ObjectMeta{ Name: nodePoolName, Namespace: namespace, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: clusterName, + }, OwnerReferences: []metav1.OwnerReference{ { APIVersion: clusterv1.GroupVersion.String(), @@ -106,7 +136,14 @@ var _ = Describe("StackitManagedNodePool Controller", func() { Spec: validManagedNodePoolSpec(), } Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) - DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) + DeferCleanup(func() { + got := &infrav1.StackitManagedNodePool{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got); err == nil { + got.Finalizers = nil + _ = k8sClient.Update(ctx, got) + deleteIfExists(ctx, got) + } + }) return cluster, managedCluster, machinePool, nodePool } @@ -127,10 +164,10 @@ var _ = Describe("StackitManagedNodePool Controller", func() { Expect(err).NotTo(HaveOccurred()) }) - It("returns nil without fabricating status when no owning MachinePool exists", func() { + It("does not add a finalizer or fabricate status when no owning MachinePool exists", func() { nodePool := &infrav1.StackitManagedNodePool{ ObjectMeta: metav1.ObjectMeta{ - Name: "orphan-nodepool", + Name: fmt.Sprintf("orphan-np-%d", time.Now().UnixNano()), Namespace: namespace, }, Spec: validManagedNodePoolSpec(), @@ -157,12 +194,13 @@ var _ = Describe("StackitManagedNodePool Controller", func() { Expect(got.Finalizers).To(BeEmpty()) }) - It("resolves MachinePool, Cluster, and StackitManagedCluster without mutating status or adding finalizers", func() { + It("adds ManagedNodePoolFinalizer and sets Paused=False without touching cloud-owned status fields", func() { + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) _, _, _, nodePool := createManagedClusterChain( - "np-reconcile-cluster", - "np-reconcile-mc", - "np-reconcile-mp", - "np-reconcile-pool", + "np-cl-"+suffix, + "np-mc-"+suffix, + "np-mp-"+suffix, + "np-pool-"+suffix, ) reconciler := &StackitManagedNodePoolReconciler{ @@ -177,165 +215,206 @@ var _ = Describe("StackitManagedNodePool Controller", func() { got := &infrav1.StackitManagedNodePool{} Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedNodePoolFinalizer)) Expect(got.Status.Ready).To(BeFalse()) Expect(got.Status.Initialization.Provisioned).To(BeNil()) Expect(got.Status.Replicas).To(BeZero()) Expect(got.Spec.ProviderIDList).To(BeEmpty()) - Expect(got.Finalizers).To(BeEmpty()) + expectCondition(got.Status.Conditions, clusterv1.PausedCondition, metav1.ConditionFalse, clusterv1.NotPausedReason) }) - }) - Context("When mapping MachinePool events to StackitManagedNodePool", func() { - reconciler := &StackitManagedNodePoolReconciler{} + It("respects pause annotations during normal reconciliation and bypasses pause during parent-cluster deletion", func() { + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + cluster, managedCluster, _, nodePool := createManagedClusterChain( + "pause-cl-"+suffix, + "pause-mc-"+suffix, + "pause-mp-"+suffix, + "pause-pool-"+suffix, + ) - It("enqueues the referenced StackitManagedNodePool when infrastructureRef matches", func() { - machinePool := &clusterv1.MachinePool{ - ObjectMeta: metav1.ObjectMeta{ - Name: "mp-0", - Namespace: namespace, - }, - Spec: clusterv1.MachinePoolSpec{ - ClusterName: "my-cluster", - Template: clusterv1.MachineTemplateSpec{ - Spec: clusterv1.MachineSpec{ - InfrastructureRef: clusterv1.ContractVersionedObjectReference{ - APIGroup: infrav1.GroupVersion.Group, - Kind: "StackitManagedNodePool", - Name: "my-nodepool", - }, - }, - }, - }, + reconciler := &StackitManagedNodePoolReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), } - requests := reconciler.stackitManagedNodePoolRequestsForMachinePool(context.Background(), machinePool) - Expect(requests).To(Equal([]reconcile.Request{{ - NamespacedName: types.NamespacedName{ - Namespace: namespace, - Name: "my-nodepool", - }, - }})) - }) + // First reconcile adds finalizer. + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) - It("ignores MachinePools with unrelated infrastructureRef Kind", func() { - machinePool := &clusterv1.MachinePool{ - ObjectMeta: metav1.ObjectMeta{ - Name: "mp-0", - Namespace: namespace, - }, - Spec: clusterv1.MachinePoolSpec{ - ClusterName: "my-cluster", - Template: clusterv1.MachineTemplateSpec{ - Spec: clusterv1.MachineSpec{ - InfrastructureRef: clusterv1.ContractVersionedObjectReference{ - APIGroup: infrav1.GroupVersion.Group, - Kind: "StackitMachineTemplate", - Name: "unmanaged-template", - }, - }, - }, - }, - } + // Pause the Cluster and reconcile StackitManagedNodePool -> sets Paused=True. + cluster.Spec.Paused = new(true) + Expect(k8sClient.Update(ctx, cluster)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) - requests := reconciler.stackitManagedNodePoolRequestsForMachinePool(context.Background(), machinePool) - Expect(requests).To(BeEmpty()) + got := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got)).To(Succeed()) + expectCondition(got.Status.Conditions, clusterv1.PausedCondition, metav1.ConditionTrue, clusterv1.PausedReason) + + // Mark parent StackitManagedCluster with finalizer and delete both parent and child while still paused. + controllerutil.AddFinalizer(managedCluster, infrav1.ManagedClusterFinalizer) + Expect(k8sClient.Update(ctx, managedCluster)).To(Succeed()) + Expect(k8sClient.Delete(ctx, managedCluster)).To(Succeed()) + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got))).To(BeTrue()) }) - }) - Context("When mapping StackitManagedNodePool events to StackitManagedCluster", func() { - It("enqueues the owning StackitManagedCluster via MachinePool and Cluster", func() { - _, managedCluster, _, nodePool := createManagedClusterChain( - "map-chain-cluster", - "map-chain-mc", - "map-chain-mp", - "map-chain-np", + It("retains ManagedNodePoolFinalizer during standalone deletion until parent projects Reason=Deleted", func() { + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + _, _, _, nodePool := createManagedClusterChain( + "del-cl-"+suffix, + "del-mc-"+suffix, + "del-mp-"+suffix, + "del-pool-"+suffix, ) - clusterReconciler := &StackitManagedClusterReconciler{ + reconciler := &StackitManagedNodePoolReconciler{ Client: k8sClient, Scheme: k8sClient.Scheme(), } - requests := clusterReconciler.stackitManagedClusterRequestsForNodePool(ctx, nodePool) - Expect(requests).To(Equal([]reconcile.Request{{ - NamespacedName: client.ObjectKeyFromObject(managedCluster), - }})) + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) + + // Delete only the child StackitManagedNodePool while parent cluster stays active. + Expect(k8sClient.Delete(ctx, nodePool)).To(Succeed()) + + // 1. Before parent projects Reason="Deleted", finalizer is retained. + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) + + got := &infrav1.StackitManagedNodePool{} + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got)).To(Succeed()) + Expect(got.Finalizers).To(ContainElement(infrav1.ManagedNodePoolFinalizer)) + + // 2. Simulate parent projecting Ready=False with Reason="Deleted". + util.SetConditions(&got.Status.Conditions, got.Generation, metav1.ConditionFalse, "Deleted", "SKE node pool has been deleted", infrav1.ManagedNodePoolReadyCondition) + Expect(k8sClient.Status().Update(ctx, got)).To(Succeed()) + + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(nodePool), + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(apierrors.IsNotFound(k8sClient.Get(ctx, client.ObjectKeyFromObject(nodePool), got))).To(BeTrue()) }) + }) - It("does not enqueue anything when the Cluster references an unmanaged StackitCluster", func() { - unmanagedCluster := &clusterv1.Cluster{ + Context("Watch predicates and cross-cluster isolation", func() { + It("ignores status-only updates on StackitManagedNodePool while enqueuing spec, deletion, and finalizer transitions", func() { + base := &infrav1.StackitManagedNodePool{ ObjectMeta: metav1.ObjectMeta{ - Name: "unmanaged-capi-cluster", - Namespace: namespace, - }, - Spec: clusterv1.ClusterSpec{ - InfrastructureRef: clusterv1.ContractVersionedObjectReference{ - APIGroup: infrav1.GroupVersion.Group, - Kind: "StackitCluster", - Name: "unmanaged-stackit-cluster", - }, + Name: "pool-pred", + Namespace: namespace, + Generation: 1, + Finalizers: []string{infrav1.ManagedNodePoolFinalizer}, + Labels: map[string]string{clusterv1.ClusterNameLabel: "c1"}, }, + Spec: validManagedNodePoolSpec(), } - Expect(k8sClient.Create(ctx, unmanagedCluster)).To(Succeed()) - DeferCleanup(func() { deleteIfExists(ctx, unmanagedCluster) }) + // Status-only update must NOT trigger parent reconcile. + statusUpdated := base.DeepCopy() + statusUpdated.Status.Ready = true + statusUpdated.Status.Initialization.Provisioned = new(true) + util.SetConditions(&statusUpdated.Status.Conditions, 1, metav1.ConditionTrue, "Available", "", infrav1.ManagedNodePoolReadyCondition) + Expect(managedNodePoolChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: statusUpdated})).To(BeFalse()) + + // Generation (spec) change MUST trigger parent reconcile. + specUpdated := base.DeepCopy() + specUpdated.Generation = 2 + Expect(managedNodePoolChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: specUpdated})).To(BeTrue()) + + // DeletionTimestamp transition MUST trigger parent reconcile. + now := metav1.Now() + deletingUpdated := base.DeepCopy() + deletingUpdated.DeletionTimestamp = &now + Expect(managedNodePoolChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: deletingUpdated})).To(BeTrue()) + + // Finalizer addition MUST trigger parent reconcile. + unfinalized := base.DeepCopy() + unfinalized.Finalizers = nil + Expect(managedNodePoolChanged(event.UpdateEvent{ObjectOld: unfinalized, ObjectNew: base})).To(BeTrue()) + }) + + It("does not aggregate MachinePools whose OwnerReference points to a different Cluster UID", func() { + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + cluster, managedCluster, machinePool, _ := createManagedClusterChain( + "uid-cl-"+suffix, + "uid-mc-"+suffix, + "uid-mp-"+suffix, + "uid-np-"+suffix, + ) + + // Point machinePool's Cluster OwnerReference to a stale Cluster UID. + machinePool.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: clusterv1.GroupVersion.String(), + Kind: "Cluster", + Name: cluster.Name, + UID: types.UID("different-cluster-uid"), + }} + Expect(k8sClient.Update(ctx, machinePool)).To(Succeed()) + + pools, err := listManagedNodePoolsForCluster(ctx, k8sClient, cluster, managedCluster) + Expect(err).NotTo(HaveOccurred()) + Expect(pools).To(BeEmpty()) + }) + }) + + Context("When mapping MachinePool and StackitManagedNodePool events", func() { + reconciler := &StackitManagedNodePoolReconciler{} + + It("enqueues the referenced StackitManagedNodePool when infrastructureRef matches", func() { machinePool := &clusterv1.MachinePool{ ObjectMeta: metav1.ObjectMeta{ - Name: "unmanaged-mp", + Name: "mp-0", Namespace: namespace, - Labels: map[string]string{ - clusterv1.ClusterNameLabel: unmanagedCluster.Name, - }, }, Spec: clusterv1.MachinePoolSpec{ - ClusterName: unmanagedCluster.Name, + ClusterName: "my-cluster", Template: clusterv1.MachineTemplateSpec{ Spec: clusterv1.MachineSpec{ - ClusterName: unmanagedCluster.Name, - Bootstrap: clusterv1.Bootstrap{ - DataSecretName: new(""), - }, InfrastructureRef: clusterv1.ContractVersionedObjectReference{ APIGroup: infrav1.GroupVersion.Group, Kind: "StackitManagedNodePool", - Name: "unmanaged-np", + Name: "my-nodepool", }, }, }, }, } - Expect(k8sClient.Create(ctx, machinePool)).To(Succeed()) - DeferCleanup(func() { deleteIfExists(ctx, machinePool) }) - nodePool := &infrav1.StackitManagedNodePool{ - ObjectMeta: metav1.ObjectMeta{ - Name: "unmanaged-np", + requests := reconciler.stackitManagedNodePoolRequestsForMachinePool(context.Background(), machinePool) + Expect(requests).To(Equal([]reconcile.Request{{ + NamespacedName: types.NamespacedName{ Namespace: namespace, + Name: "my-nodepool", }, - Spec: validManagedNodePoolSpec(), - } - Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) - DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) - - clusterReconciler := &StackitManagedClusterReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - } - - requests := clusterReconciler.stackitManagedClusterRequestsForNodePool(ctx, nodePool) - Expect(requests).To(BeEmpty()) + }})) }) - It("does not enqueue anything for an unassociated StackitManagedNodePool", func() { - nodePool := &infrav1.StackitManagedNodePool{ - ObjectMeta: metav1.ObjectMeta{ - Name: "unlinked-np", - Namespace: namespace, - }, - Spec: validManagedNodePoolSpec(), - } - Expect(k8sClient.Create(ctx, nodePool)).To(Succeed()) - DeferCleanup(func() { deleteIfExists(ctx, nodePool) }) + It("enqueues the owning StackitManagedCluster via MachinePool and Cluster", func() { + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + _, managedCluster, _, nodePool := createManagedClusterChain( + "map-cl-"+suffix, + "map-mc-"+suffix, + "map-mp-"+suffix, + "map-np-"+suffix, + ) clusterReconciler := &StackitManagedClusterReconciler{ Client: k8sClient, @@ -343,7 +422,9 @@ var _ = Describe("StackitManagedNodePool Controller", func() { } requests := clusterReconciler.stackitManagedClusterRequestsForNodePool(ctx, nodePool) - Expect(requests).To(BeEmpty()) + Expect(requests).To(Equal([]reconcile.Request{{ + NamespacedName: client.ObjectKeyFromObject(managedCluster), + }})) }) }) }) diff --git a/go.mod b/go.mod index dc54ea2..4688277 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.26.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.12.0 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.13.0 + github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.0 k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.4 diff --git a/go.sum b/go.sum index 0dd07b9..77bfab8 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,8 @@ github.com/stackitcloud/stackit-sdk-go/services/iaas v1.12.0 h1:H4V3H8qSKOaOalIr github.com/stackitcloud/stackit-sdk-go/services/iaas v1.12.0/go.mod h1:Ts06id0KejUlQWbpR+/rm+tKng6QkTuFV1VQTPJ4dA4= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.13.0 h1:UuLNwFHjJCpL11y4F7B9oBKtZkxpu01VkNPILNkpex4= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.13.0/go.mod h1:+Ld3dn648I+YKcBV3fEkYpDSr3fel421+LurJGywSBs= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.0 h1:eu9PhRdL2GkXpHtf4NN9YBCUYcARdvAwq+g/FW4hmi4= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.0/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/scope/managed_cluster_scope.go b/scope/managed_cluster_scope.go new file mode 100644 index 0000000..43fe026 --- /dev/null +++ b/scope/managed_cluster_scope.go @@ -0,0 +1,239 @@ +/* +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 scope + +import ( + "context" + + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kerrors "k8s.io/apimachinery/pkg/util/errors" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + skeservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/ske" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +// ManagedNodePoolEntry pairs a discovered StackitManagedNodePool with its +// resolved per-pool pause state (true when either the owning MachinePool or the +// StackitManagedNodePool itself carries the CAPI paused annotation). +type ManagedNodePoolEntry struct { + NodePool *infrav1.StackitManagedNodePool + Paused bool +} + +// ManagedClusterScope holds the per-reconcile state for a StackitManagedCluster +// and its associated child StackitManagedNodePools. +type ManagedClusterScope struct { + Client client.Client + Cluster *clusterv1.Cluster + StackitManagedCluster *infrav1.StackitManagedCluster + + skeClient skev2.DefaultAPI + patchHelper *patch.Helper + nodePools []ManagedNodePoolEntry + nodePoolByName map[string]*infrav1.StackitManagedNodePool + nodePoolPatchHelpers map[string]*patch.Helper +} + +// NewManagedClusterScope constructs a ManagedClusterScope and snapshots the +// original StackitManagedCluster so the patchHelper can compute a minimal patch. +func NewManagedClusterScope( + k8sClient client.Client, + cluster *clusterv1.Cluster, + stackitManagedCluster *infrav1.StackitManagedCluster, +) (*ManagedClusterScope, error) { + patchHelper, err := patch.NewHelper(stackitManagedCluster, k8sClient) + if err != nil { + return nil, err + } + return &ManagedClusterScope{ + Client: k8sClient, + Cluster: cluster, + StackitManagedCluster: stackitManagedCluster, + patchHelper: patchHelper, + }, nil +} + +// SetManagedNodePools registers the associated StackitManagedNodePools for this +// reconcile pass and snapshots a patch.Helper for each child so projected +// status changes can be persisted on scope close. +func (s *ManagedClusterScope) SetManagedNodePools(entries []ManagedNodePoolEntry) error { + s.nodePools = entries + s.nodePoolByName = make(map[string]*infrav1.StackitManagedNodePool, len(entries)) + s.nodePoolPatchHelpers = make(map[string]*patch.Helper, len(entries)) + for _, entry := range entries { + np := entry.NodePool + helper, err := patch.NewHelper(np, s.Client) + if err != nil { + return err + } + s.nodePoolByName[np.Name] = np + s.nodePoolPatchHelpers[np.Name] = helper + } + return nil +} + +// PatchObject writes back any metadata/spec/status changes to the +// StackitManagedCluster and any projected status changes to associated +// StackitManagedNodePools, refreshing patchHelper baselines after each patch. +func (s *ManagedClusterScope) PatchObject(ctx context.Context) error { + var errs []error + + if err := s.patchHelper.Patch(ctx, s.StackitManagedCluster, patch.WithOwnedConditions{Conditions: []string{ + infrav1.ManagedClusterReadyCondition, + clusterv1.PausedCondition, + }}); err != nil { + errs = append(errs, err) + } else if patchHelper, err := patch.NewHelper(s.StackitManagedCluster, s.Client); err != nil { + errs = append(errs, err) + } else { + s.patchHelper = patchHelper + } + + for _, entry := range s.nodePools { + np := entry.NodePool + helper := s.nodePoolPatchHelpers[np.Name] + if helper == nil { + continue + } + if err := helper.Patch(ctx, np, patch.WithOwnedConditions{Conditions: []string{ + infrav1.ManagedNodePoolReadyCondition, + }}); err != nil { + if apierrors.IsNotFound(err) && !np.DeletionTimestamp.IsZero() { + continue + } + errs = append(errs, err) + continue + } + if nextHelper, err := patch.NewHelper(np, s.Client); err == nil { + s.nodePoolPatchHelpers[np.Name] = nextHelper + } + } + + return kerrors.NewAggregate(errs) +} + +func (s *ManagedClusterScope) SetConditions(status metav1.ConditionStatus, reason, message string, conditionTypes ...string) { + util.SetConditions(&s.StackitManagedCluster.Status.Conditions, s.StackitManagedCluster.Generation, status, reason, message, conditionTypes...) +} + +// SetReady marks the StackitManagedCluster as ready and records that initial +// infrastructure provisioning has completed. +func (s *ManagedClusterScope) SetReady() { + s.StackitManagedCluster.Status.Ready = true + s.StackitManagedCluster.Status.Initialization.Provisioned = new(true) + s.SetConditions(metav1.ConditionTrue, "Available", "", infrav1.ManagedClusterReadyCondition) +} + +// SetNotReady marks the StackitManagedCluster as not ready without resetting +// Initialization.Provisioned if the cluster was previously provisioned. +func (s *ManagedClusterScope) SetNotReady(reason, message string) { + s.StackitManagedCluster.Status.Ready = false + s.SetConditions(metav1.ConditionFalse, reason, message, infrav1.ManagedClusterReadyCondition) +} + +// ManagedNodePools returns the Kubernetes-side state of all associated +// StackitManagedNodePools for the SKE service. +func (s *ManagedClusterScope) ManagedNodePools() []skeservice.ManagedNodePoolState { + if len(s.nodePools) == 0 { + return nil + } + out := make([]skeservice.ManagedNodePoolState, len(s.nodePools)) + for i, entry := range s.nodePools { + np := entry.NodePool + out[i] = skeservice.ManagedNodePoolState{ + ResourceName: np.Name, + Desired: skeservice.NodePoolDesiredState(np), + Finalized: controllerutil.ContainsFinalizer(np, infrav1.ManagedNodePoolFinalizer), + Deleting: !np.DeletionTimestamp.IsZero(), + Paused: entry.Paused, + } + } + return out +} + +// SetNodePoolReady marks an associated StackitManagedNodePool as ready and +// records sticky Initialization.Provisioned=true. +func (s *ManagedClusterScope) SetNodePoolReady(resourceName string) { + np, ok := s.nodePoolByName[resourceName] + if !ok || np == nil { + return + } + np.Status.Ready = true + np.Status.Initialization.Provisioned = new(true) + util.SetConditions(&np.Status.Conditions, np.Generation, metav1.ConditionTrue, "Available", "", infrav1.ManagedNodePoolReadyCondition) +} + +// SetNodePoolNotReady marks an associated StackitManagedNodePool as not ready +// while preserving sticky Initialization.Provisioned if already true. +func (s *ManagedClusterScope) SetNodePoolNotReady(resourceName, reason, message string) { + np, ok := s.nodePoolByName[resourceName] + if !ok || np == nil { + return + } + np.Status.Ready = false + util.SetConditions(&np.Status.Conditions, np.Generation, metav1.ConditionFalse, reason, message, infrav1.ManagedNodePoolReadyCondition) +} + +func (s *ManagedClusterScope) SetSKEClient(skeClient skev2.DefaultAPI) { + s.skeClient = skeClient +} + +func (s *ManagedClusterScope) SKEClient() skev2.DefaultAPI { + return s.skeClient +} + +func (s *ManagedClusterScope) ProjectID() string { + return s.StackitManagedCluster.Spec.ProjectID +} + +func (s *ManagedClusterScope) Region() string { + return s.StackitManagedCluster.Spec.Region +} + +func (s *ManagedClusterScope) ClusterName() string { + return s.StackitManagedCluster.Spec.ClusterName +} + +func (s *ManagedClusterScope) KubernetesVersion() string { + return s.StackitManagedCluster.Spec.Kubernetes.Version +} + +func (s *ManagedClusterScope) NetworkID() string { + if s.StackitManagedCluster.Spec.Network == nil { + return "" + } + return s.StackitManagedCluster.Spec.Network.ID +} + +func (s *ManagedClusterScope) Labels() map[string]string { + return util.ManagedClusterTags( + s.StackitManagedCluster.Name, + s.StackitManagedCluster.Namespace, + string(s.StackitManagedCluster.UID), + s.StackitManagedCluster.Spec.Labels, + ) +} + +func (s *ManagedClusterScope) OwnershipLabels() map[string]string { + return util.ManagedClusterTags( + s.StackitManagedCluster.Name, + s.StackitManagedCluster.Namespace, + string(s.StackitManagedCluster.UID), + nil, + ) +} diff --git a/scope/managed_nodepool_scope.go b/scope/managed_nodepool_scope.go new file mode 100644 index 0000000..c728d04 --- /dev/null +++ b/scope/managed_nodepool_scope.go @@ -0,0 +1,76 @@ +/* +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 scope + +import ( + "context" + + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +// ManagedNodePoolScope holds the Kubernetes-only per-reconcile state for a +// StackitManagedNodePool. +// +// StackitManagedNodePoolReconciler owns metadata finalizers and the Paused +// condition only; cloud-derived status (Ready, Initialization.Provisioned, and +// the Ready condition) is projected by StackitManagedClusterReconciler. +type ManagedNodePoolScope struct { + Client client.Client + Cluster *clusterv1.Cluster + MachinePool *clusterv1.MachinePool + StackitManagedCluster *infrav1.StackitManagedCluster + StackitManagedNodePool *infrav1.StackitManagedNodePool + + patchHelper *patch.Helper +} + +// NewManagedNodePoolScope constructs a ManagedNodePoolScope and snapshots the +// original StackitManagedNodePool for minimal patching. +func NewManagedNodePoolScope( + k8sClient client.Client, + cluster *clusterv1.Cluster, + machinePool *clusterv1.MachinePool, + managedCluster *infrav1.StackitManagedCluster, + nodePool *infrav1.StackitManagedNodePool, +) (*ManagedNodePoolScope, error) { + patchHelper, err := patch.NewHelper(nodePool, k8sClient) + if err != nil { + return nil, err + } + return &ManagedNodePoolScope{ + Client: k8sClient, + Cluster: cluster, + MachinePool: machinePool, + StackitManagedCluster: managedCluster, + StackitManagedNodePool: nodePool, + patchHelper: patchHelper, + }, nil +} + +// PatchObject writes back metadata/finalizer and PausedCondition changes to the +// StackitManagedNodePool and refreshes the patchHelper baseline. +func (s *ManagedNodePoolScope) PatchObject(ctx context.Context) error { + if err := s.patchHelper.Patch(ctx, s.StackitManagedNodePool, patch.WithOwnedConditions{Conditions: []string{ + clusterv1.PausedCondition, + }}); err != nil { + return err + } + patchHelper, err := patch.NewHelper(s.StackitManagedNodePool, s.Client) + if err != nil { + return err + } + s.patchHelper = patchHelper + return nil +} diff --git a/util/reconcile.go b/util/reconcile.go index 6a8b4b4..b366677 100644 --- a/util/reconcile.go +++ b/util/reconcile.go @@ -24,6 +24,8 @@ import ( "sigs.k8s.io/cluster-api/util/annotations" "sigs.k8s.io/controller-runtime/pkg/client" + skev2 "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" "github.com/stackitcloud/cluster-api-provider-stackit/cloud" ) @@ -91,3 +93,38 @@ func CredentialsSecretKey(stackitCluster *infrav1.StackitCluster) types.Namespac Name: stackitCluster.Spec.CredentialsSecretRef.Name, } } + +func BuildSKEClient( + ctx context.Context, + k8sClient client.Client, + factory cloud.SKEFactory, + managedCluster *infrav1.StackitManagedCluster, +) (skev2.DefaultAPI, error) { + if factory == nil { + return nil, errors.New("SKEClientFactory is not configured") + } + + secret := &corev1.Secret{} + key := ManagedClusterCredentialsSecretKey(managedCluster) + if err := k8sClient.Get(ctx, key, secret); err != nil { + return nil, fmt.Errorf("get credentials secret %s: %w", key, err) + } + + creds, err := ParseCredentialsSecret(secret, managedCluster.Spec.ProjectID, managedCluster.Spec.Region) + if err != nil { + return nil, err + } + + return factory(ctx, creds) +} + +func ManagedClusterCredentialsSecretKey(managedCluster *infrav1.StackitManagedCluster) types.NamespacedName { + namespace := managedCluster.Spec.CredentialsSecretRef.Namespace + if namespace == "" { + namespace = managedCluster.Namespace + } + return types.NamespacedName{ + Namespace: namespace, + Name: managedCluster.Spec.CredentialsSecretRef.Name, + } +} diff --git a/util/tags.go b/util/tags.go index ea4f9ff..6897b64 100644 --- a/util/tags.go +++ b/util/tags.go @@ -17,6 +17,7 @@ package util const ( LabelClusterName = "cluster.x-k8s.io/cluster-name" LabelClusterNamespace = "cluster.x-k8s.io/cluster-namespace" + LabelClusterUID = "cluster.x-k8s.io/cluster-uid" LabelMachineName = "cluster.x-k8s.io/machine-name" LabelMachineUID = "cluster.x-k8s.io/machine-uid" LabelManagedBy = "cluster.x-k8s.io/managed-by" @@ -49,6 +50,28 @@ func ClusterTags(clusterName, clusterNamespace string, additionalLabels map[stri return out } +// ManagedClusterTags returns the canonical tags applied to SKE managed clusters, +// including the StackitManagedCluster UID. additionalLabels is merged in last +// so the provider's own tags cannot be overwritten. +func ManagedClusterTags( + clusterName, clusterNamespace, clusterUID string, + additionalLabels map[string]string, +) map[string]string { + out := map[string]string{ + LabelClusterName: clusterName, + LabelClusterNamespace: clusterNamespace, + LabelClusterUID: clusterUID, + LabelManagedBy: ManagedByValue, + } + for k, v := range additionalLabels { + if _, isReserved := out[k]; isReserved { + continue + } + out[k] = v + } + return out +} + // MachineTags returns the canonical tags applied to per-machine cloud // resources (e.g. VMs). additionalLabels is merged in last so the provider's // own tags cannot be overwritten.