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/2] 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/2] 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()) + }) + }) +})