From 68ea711552f6ffe79c33c42127eaf85296ae0abf Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:40:13 +0200 Subject: [PATCH 01/19] feat(api): make network.id optional and add network status for managed networks --- api/v1alpha1/stackitcluster_types.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 413452e..03db8d2 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -76,10 +76,11 @@ type StackitClusterSpec struct { // StackitClusterNetworkSpec references an existing STACKIT network. type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. - // +required + // Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID + // +optional // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` - ID string `json:"id"` + ID string `json:"id,omitempty"` } // StackitAPIServerLoadBalancerSpec configures an optional load balancer for @@ -160,6 +161,10 @@ type StackitClusterStatus struct { // +optional Initialization StackitClusterInitializationStatus `json:"initialization,omitempty"` + // network stores observed provider-managed network resources. + // +optional + Network StackitNetworkStatus `json:"network,omitempty"` + // apiServerEndpoint is the endpoint the Kubernetes API server is reachable at. // +optional APIServerEndpoint clusterv1.APIEndpoint `json:"apiServerEndpoint,omitempty,omitzero"` @@ -217,6 +222,14 @@ type StackitBastionStatus struct { CloudInitHash string `json:"cloudInitHash,omitempty"` } +// network contains status of a STACKIT network created by CAPSTK +type StackitNetworkStatus struct { + // id is the STACKIT network ID + // +optional + ID string `json:"id,omitempty"` + Managed bool `json:"managed,omitempty"` +} + // Condition types maintained by the StackitClusterReconciler. const ( ClusterReadyCondition = "Ready" From b5dc72e12d498fb80b97d52c31d80d830fd2993b Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:01:16 +0200 Subject: [PATCH 02/19] feat(api): make network.id optional and add network status for managed networks --- api/v1alpha1/stackitcluster_types.go | 1 - api/v1alpha1/stackitmachine_types.go | 9 ++++----- api/v1alpha1/zz_generated.deepcopy.go | 16 ++++++++++++++++ ...ructure.cluster.x-k8s.io_stackitclusters.yaml | 16 ++++++++++++---- ...cluster.x-k8s.io_stackitclustertemplates.yaml | 7 +++---- ...ructure.cluster.x-k8s.io_stackitmachines.yaml | 4 ---- ...cluster.x-k8s.io_stackitmachinetemplates.yaml | 4 ---- 7 files changed, 35 insertions(+), 22 deletions(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 03db8d2..712a38c 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -78,7 +78,6 @@ type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. // Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID // +optional - // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` ID string `json:"id,omitempty"` } diff --git a/api/v1alpha1/stackitmachine_types.go b/api/v1alpha1/stackitmachine_types.go index 208e77e..e849301 100644 --- a/api/v1alpha1/stackitmachine_types.go +++ b/api/v1alpha1/stackitmachine_types.go @@ -66,8 +66,8 @@ type StackitMachineSpec struct { RootVolume StackitRootVolumeSpec `json:"rootVolume,omitempty"` // network references the STACKIT network the VM should be attached to. - // +required - Network StackitMachineNetworkSpec `json:"network"` + // +optional + Network StackitMachineNetworkSpec `json:"network,omitempty"` // securityGroups is a list of STACKIT security group IDs applied to the VM. // +optional @@ -101,10 +101,9 @@ type StackitRootVolumeSpec struct { // StackitMachineNetworkSpec references an existing STACKIT network. type StackitMachineNetworkSpec struct { // id is the STACKIT network ID. - // +required - // +kubebuilder:validation:MinLength=1 + // +optional // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` - ID string `json:"id"` + ID string `json:"id,omitempty"` } // StackitMachineStatus defines the observed state of StackitMachine. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d5d3fdf..e7247b8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -217,6 +217,7 @@ func (in *StackitClusterSpec) DeepCopy() *StackitClusterSpec { func (in *StackitClusterStatus) DeepCopyInto(out *StackitClusterStatus) { *out = *in out.Initialization = in.Initialization + out.Network = in.Network out.APIServerEndpoint = in.APIServerEndpoint out.Bastion = in.Bastion if in.FailureDomains != nil { @@ -578,6 +579,21 @@ 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 *StackitNetworkStatus) DeepCopyInto(out *StackitNetworkStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StackitNetworkStatus. +func (in *StackitNetworkStatus) DeepCopy() *StackitNetworkStatus { + if in == nil { + return nil + } + out := new(StackitNetworkStatus) + 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/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml index ac12a64..de59583 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -204,12 +204,11 @@ spec: For MVP the network must already exist. properties: id: - description: id is the STACKIT network ID. - minLength: 1 + description: |- + id is the STACKIT network ID. + Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID pattern: ^[0-9a-fA-F-]{36}$ type: string - required: - - id type: object projectID: description: projectID is the STACKIT project that owns the cluster @@ -396,6 +395,15 @@ spec: is provisioned. type: boolean type: object + network: + description: network stores observed provider-managed network resources. + properties: + id: + description: id is the STACKIT network ID + type: string + managed: + type: boolean + type: object ready: description: ready indicates that the infrastructure required for the cluster is ready. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml index 202fe16..7d584e6 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -240,12 +240,11 @@ spec: For MVP the network must already exist. properties: id: - description: id is the STACKIT network ID. - minLength: 1 + description: |- + id is the STACKIT network ID. + Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID pattern: ^[0-9a-fA-F-]{36}$ type: string - required: - - id type: object projectID: description: projectID is the STACKIT project that owns the diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachines.yaml index ae3aa81..bf02366 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachines.yaml @@ -96,11 +96,8 @@ spec: properties: id: description: id is the STACKIT network ID. - minLength: 1 pattern: ^[0-9a-fA-F-]{36}$ type: string - required: - - id type: object providerID: description: |- @@ -142,7 +139,6 @@ spec: required: - imageID - machineType - - network type: object status: description: status defines the observed state of StackitMachine diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachinetemplates.yaml index b09022b..82aa89b 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitmachinetemplates.yaml @@ -128,11 +128,8 @@ spec: properties: id: description: id is the STACKIT network ID. - minLength: 1 pattern: ^[0-9a-fA-F-]{36}$ type: string - required: - - id type: object providerID: description: |- @@ -175,7 +172,6 @@ spec: required: - imageID - machineType - - network type: object required: - spec From 52024ab6925edee321c9b202bbc9aee8a61e2803 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:23:41 +0200 Subject: [PATCH 03/19] feat(webhook): allow optional network.id --- webhook/v1alpha1/validation_helpers.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webhook/v1alpha1/validation_helpers.go b/webhook/v1alpha1/validation_helpers.go index d44d3f8..d6bd2ce 100644 --- a/webhook/v1alpha1/validation_helpers.go +++ b/webhook/v1alpha1/validation_helpers.go @@ -86,12 +86,15 @@ func validateStackitClusterSpec(spec infrav1.StackitClusterSpec, fldPath *field. allErrs = append(allErrs, validateRequiredPattern(spec.ProjectID, uuidPattern, fldPath.Child("projectID"), "must be a STACKIT project UUID")...) allErrs = append(allErrs, validateRequiredPattern(spec.Region, regionPattern, fldPath.Child("region"), "must use STACKIT region format, for example eu01")...) allErrs = append(allErrs, validateSecretReference(spec.CredentialsSecretRef, fldPath.Child("credentialsSecretRef"))...) - allErrs = append(allErrs, validateRequiredPattern(spec.Network.ID, uuidPattern, fldPath.Child("network", "id"), "must be a STACKIT network UUID")...) if !spec.APIServerLoadBalancer.Enabled && spec.ControlPlaneEndpoint.Host == "" { allErrs = append(allErrs, field.Required(fldPath.Child("controlPlaneEndpoint", "host"), "required when apiServerLoadBalancer.enabled is false")) } + if spec.Network.ID != "" && !uuidPattern.MatchString(spec.Network.ID) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("network", "id"), spec.Network.ID, "must be a STACKIT network UUID")) + } + allErrs = append(allErrs, validateBastionSpec(spec.Bastion, fldPath.Child("bastion"))...) allErrs = append(allErrs, validateAdditionalLabels(spec.AdditionalLabels, fldPath.Child("additionalLabels"))...) @@ -205,7 +208,11 @@ func validateStackitMachineSpec(spec infrav1.StackitMachineSpec, fldPath *field. if spec.SSHKeyName != "" && !sshKeyNamePattern.MatchString(spec.SSHKeyName) { allErrs = append(allErrs, field.Invalid(fldPath.Child("sshKeyName"), spec.SSHKeyName, "must be a valid STACKIT SSH key name")) } - allErrs = append(allErrs, validateRequiredPattern(spec.Network.ID, uuidPattern, fldPath.Child("network", "id"), "must be a STACKIT network UUID")...) + + if spec.Network.ID != "" && !uuidPattern.MatchString(spec.Network.ID) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("network", "id"), spec.Network.ID, "must be a STACKIT network UUID")) + } + allErrs = append(allErrs, validateRootVolume(spec.RootVolume, fldPath.Child("rootVolume"))...) allErrs = append(allErrs, validateSecurityGroups(spec.SecurityGroups, fldPath.Child("securityGroups"))...) allErrs = append(allErrs, validateAdditionalLabels(spec.AdditionalLabels, fldPath.Child("additionalLabels"))...) From 12dd6b8c115cd1b51dce258cf046ed2c91a82803 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:48:22 +0200 Subject: [PATCH 04/19] feat(api): make network.id optional and add network status for managed networks --- api/v1alpha1/stackitcluster_types.go | 8 +-- ...ture.cluster.x-k8s.io_stackitclusters.yaml | 6 +-- ...ster.x-k8s.io_stackitclustertemplates.yaml | 6 +-- controller/stackitcluster_infrastructure.go | 49 ++++++++++--------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 712a38c..ff38236 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -47,8 +47,8 @@ type StackitClusterSpec struct { // +required CredentialsSecretRef corev1.SecretReference `json:"credentialsSecretRef"` - // network references the existing STACKIT network the cluster will use. - // For MVP the network must already exist. + // network configures the STACKIT network the cluster will use. + // ommiting the ID will create a managed dedicated network // +required Network StackitClusterNetworkSpec `json:"network"` @@ -76,9 +76,9 @@ type StackitClusterSpec struct { // StackitClusterNetworkSpec references an existing STACKIT network. type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. - // Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID - // +optional + // Omitting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` + // +optional ID string `json:"id,omitempty"` } diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml index de59583..cef3c8a 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -200,13 +200,13 @@ spec: x-kubernetes-map-type: atomic network: description: |- - network references the existing STACKIT network the cluster will use. - For MVP the network must already exist. + network configures the STACKIT network the cluster will use. + ommiting the ID will create a managed dedicated network properties: id: description: |- id is the STACKIT network ID. - Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID + Omitting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID pattern: ^[0-9a-fA-F-]{36}$ type: string type: object diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml index 7d584e6..4566f51 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -236,13 +236,13 @@ spec: x-kubernetes-map-type: atomic network: description: |- - network references the existing STACKIT network the cluster will use. - For MVP the network must already exist. + network configures the STACKIT network the cluster will use. + ommiting the ID will create a managed dedicated network properties: id: description: |- id is the STACKIT network ID. - Omiting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID + Omitting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID pattern: ^[0-9a-fA-F-]{36}$ type: string type: object diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index f4c4f8e..de77b9e 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -88,28 +88,32 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS } clusterScope.SetFailureDomains(failureDomainsFromZones(stackitCluster.Spec.Region, zones)) - network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) - if err != nil { - log.Error(err, "Failed to get network", - "networkID", stackitCluster.Spec.Network.ID, - "region", stackitCluster.Spec.Region, - ) - record.Warnf( - stackitCluster, "FailedGetNetwork", - "Failed to get network %s in region %s: %v", - stackitCluster.Spec.Network.ID, stackitCluster.Spec.Region, err, - ) - stackitCluster.Status.Ready = false - return util.CloudFailureResult( - &stackitCluster.Status.Conditions, - stackitCluster.Generation, - "NetworkNotFound", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterNetworkReadyCondition, - infrav1.ClusterReadyCondition, - ) + if stackitCluster.Spec.Network.ID != "" { + network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) + if err != nil { + log.Error(err, "Failed to get network", + "networkID", stackitCluster.Spec.Network.ID, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedGetNetwork", + "Failed to get network %s in region %s: %v", + stackitCluster.Spec.Network.ID, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkNotFound", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + } else { + //TODO: implement cloudClient.EnsureNetwork } clusterScope.SetConditions( metav1.ConditionTrue, @@ -453,6 +457,7 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS clusterScope.ClearBastionStatus() record.Event(stackitCluster, "BastionDeleted", "Deleted bastion") } + // TODO: implement cloudClient.deleteNetwork - "if managed" etc. controllerutil.RemoveFinalizer(stackitCluster, infrav1.ClusterFinalizer) return ctrl.Result{}, nil } From 4c7d5bdaeaf49ec23a912c489f983d529abfbca1 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:51:54 +0200 Subject: [PATCH 05/19] feat(api): start implementing ensure network creation function --- controller/stackitcluster_infrastructure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index de77b9e..657e567 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -88,8 +88,8 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS } clusterScope.SetFailureDomains(failureDomainsFromZones(stackitCluster.Spec.Region, zones)) + network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) if stackitCluster.Spec.Network.ID != "" { - network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) if err != nil { log.Error(err, "Failed to get network", "networkID", stackitCluster.Spec.Network.ID, From 76cf0d45cf7054d133fb528b40a6a3bc636e2933 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:16:50 +0200 Subject: [PATCH 06/19] feat(api): start implementing ensure network creation function --- api/v1alpha1/stackitcluster_types.go | 4 +-- cloud/client.go | 8 +++-- cloud/sdk_client.go | 4 +++ cloud/services/network/network.go | 34 +++++++++++++++++++ cloud/types.go | 6 ++++ ...ture.cluster.x-k8s.io_stackitclusters.yaml | 1 - ...ster.x-k8s.io_stackitclustertemplates.yaml | 1 - controller/stackitcluster_infrastructure.go | 3 +- docs/src/getting-started/cloud-resources.md | 15 ++++++-- 9 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 cloud/services/network/network.go diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index ff38236..80db4da 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -49,8 +49,8 @@ type StackitClusterSpec struct { // network configures the STACKIT network the cluster will use. // ommiting the ID will create a managed dedicated network - // +required - Network StackitClusterNetworkSpec `json:"network"` + // +optional + Network StackitClusterNetworkSpec `json:"network,omitempty"` // apiServerLoadBalancer configures the load balancer in front of the // Kubernetes API server. diff --git a/cloud/client.go b/cloud/client.go index a3ab870..f319370 100644 --- a/cloud/client.go +++ b/cloud/client.go @@ -23,6 +23,12 @@ import "context" // - be idempotent (CreateServer/EnsureAPIServerLoadBalancer must not produce // duplicates when a resource with matching tags already exists) type Client interface { + GetNetwork(ctx context.Context, id string) (*Network, error) + + CreateNetwork(ctx context.Context, input CreateNetworkInput) (*Network, error) + + DeleteNetwork(ctx context.Context, id string) error + GetServer(ctx context.Context, id string) (*Server, error) FindServerByTags(ctx context.Context, tags map[string]string) (*Server, error) @@ -33,8 +39,6 @@ type Client interface { DeleteServer(ctx context.Context, id string) error - GetNetwork(ctx context.Context, id string) (*Network, error) - EnsureBastion(ctx context.Context, input BastionInput) (*Bastion, error) DeleteBastion(ctx context.Context, input BastionInput, status Bastion) error diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 433e22f..3089c6f 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -234,6 +234,10 @@ func (c *SDKClient) GetNetwork(ctx context.Context, id string) (*Network, error) }, nil } +func (c *SDKClient) CreateNetwork(ctx context.Context, id string) (*Network, error) { + return nil // TODO +} + func (c *SDKClient) EnsureBastion(ctx context.Context, input BastionInput) (*Bastion, error) { if input.Name == "" || input.NetworkID == "" || input.ImageID == "" || input.MachineType == "" || input.SSHKeyName == "" { diff --git a/cloud/services/network/network.go b/cloud/services/network/network.go new file mode 100644 index 0000000..155b047 --- /dev/null +++ b/cloud/services/network/network.go @@ -0,0 +1,34 @@ +/* +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 network + +import ( + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +const ( + // DefaultNetworkPrefix defines the default CIDR allocated for a managed cluster network. + DefaultNetworkPrefix = "10.0.0.0/24" +) + +func NetworkTags(stackitCluster *infrav1.StackitCluster) map[string]string { + return util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) +} + +func Input(stackitCluster *infrav1.StackitCluster) cloud.CreateNetworkInput { + return cloud.CreateNetworkInput{ + Name: stackitCluster.Name + "-network", + IPv4Prefix: DefaultNetworkPrefix, + Tags: NetworkTags(stackitCluster), + } +} diff --git a/cloud/types.go b/cloud/types.go index 289353f..3b5974e 100644 --- a/cloud/types.go +++ b/cloud/types.go @@ -69,6 +69,12 @@ type Bastion struct { SecurityGroupID string } +type CreateNetworkInput struct { + Name string + IPv4Prefix string + Tags map[string]string +} + // CreateServerInput holds all parameters required to create a new VM. type CreateServerInput struct { Name string diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml index cef3c8a..16de6b8 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -224,7 +224,6 @@ spec: type: string required: - credentialsSecretRef - - network - projectID - region type: object diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml index 4566f51..84a0fff 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -260,7 +260,6 @@ spec: type: string required: - credentialsSecretRef - - network - projectID - region type: object diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index 657e567..e3e5fbc 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -113,7 +113,8 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS ) } } else { - //TODO: implement cloudClient.EnsureNetwork + // TODO: implement cloudClient.EnsureNetwork + } clusterScope.SetConditions( metav1.ConditionTrue, diff --git a/docs/src/getting-started/cloud-resources.md b/docs/src/getting-started/cloud-resources.md index 100f569..37961fc 100644 --- a/docs/src/getting-started/cloud-resources.md +++ b/docs/src/getting-started/cloud-resources.md @@ -18,9 +18,18 @@ network must provide: container image pulls, DNS, NTP, and STACKIT APIs. - Traffic required by the selected CNI. -Network creation is intentionally outside the provider for now because networks -usually encode platform-level policy such as address planning, routing, -peering, VPN, DNS, and shared-service access. +If you don't provide a network ID, CAPS will create one /24 Network for you. + +Planning your Networks usually involves platform-level policy such as address +planning and considers routing, peering, VPN, DNS, and shared-service access +but the default is built for convenience and a newly created (managed) network +will a have an attached tag indicating that it is managed by CAPS. + +Only empty Networks can be deleted, so if you delete your CAPS Cluster and you +have no other manually created resource in that cluster, it will be deleted too. + + +### How to create a STACKIT Network manually Create a network with the STACKIT CLI: From b2ff9b7813941808d2eefb544d99734f00497791 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:25 +0200 Subject: [PATCH 07/19] feat(api): proceed with implementation plan and fix errors --- cloud/fake/client.go | 8 ++++++++ cloud/sdk_client.go | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cloud/fake/client.go b/cloud/fake/client.go index 06465cc..ddeb5b9 100644 --- a/cloud/fake/client.go +++ b/cloud/fake/client.go @@ -251,6 +251,14 @@ func (c *Client) GetNetwork(_ context.Context, id string) (*cloud.Network, error return &out, nil } +func (c *Client) CreateNetwork(_ context.Context, _ cloud.CreateNetworkInput) (*cloud.Network, error) { + return nil, nil // TODO +} + +func (c *Client) DeleteNetwork(_ context.Context, _ string) error { + return nil // TODO +} + func (c *Client) EnsureBastion(_ context.Context, input cloud.BastionInput) (*cloud.Bastion, error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 3089c6f..56d7818 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -234,7 +234,11 @@ func (c *SDKClient) GetNetwork(ctx context.Context, id string) (*Network, error) }, nil } -func (c *SDKClient) CreateNetwork(ctx context.Context, id string) (*Network, error) { +func (c *SDKClient) CreateNetwork(ctx context.Context, input CreateNetworkInput) (*Network, error) { + return nil, nil // TODO +} + +func (c *SDKClient) DeleteNetwork(ctx context.Context, id string) error { return nil // TODO } From af46e754a5f35fe6f54780c49d9c4fe2f2c43120 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:49:27 +0200 Subject: [PATCH 08/19] fix: typo --- api/v1alpha1/stackitcluster_types.go | 2 +- cloud/sdk_client.go | 4 ++++ webhook/v1alpha1/webhook_validation_test.go | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 80db4da..287074e 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -48,7 +48,7 @@ type StackitClusterSpec struct { CredentialsSecretRef corev1.SecretReference `json:"credentialsSecretRef"` // network configures the STACKIT network the cluster will use. - // ommiting the ID will create a managed dedicated network + // omiting the ID will create a managed dedicated network // +optional Network StackitClusterNetworkSpec `json:"network,omitempty"` diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 56d7818..a9dfbf7 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -235,6 +235,10 @@ func (c *SDKClient) GetNetwork(ctx context.Context, id string) (*Network, error) } func (c *SDKClient) CreateNetwork(ctx context.Context, input CreateNetworkInput) (*Network, error) { + if input.Name == "" { + return nil, fmt.Errorf("%w: network name is required", ErrInvalidInput) + } + return nil, nil // TODO } diff --git a/webhook/v1alpha1/webhook_validation_test.go b/webhook/v1alpha1/webhook_validation_test.go index c16f632..a3fa936 100644 --- a/webhook/v1alpha1/webhook_validation_test.go +++ b/webhook/v1alpha1/webhook_validation_test.go @@ -55,6 +55,14 @@ var _ = Describe("STACKIT admission webhooks", func() { Expect(err).NotTo(HaveOccurred()) }) + It("accepts a cluster with omitted network ID for managed networks", func() { + cluster := validStackitCluster("managed-network-cluster") + cluster.Spec.Network.ID = "" + + _, err := validator.ValidateCreate(ctx, cluster) + Expect(err).NotTo(HaveOccurred()) + }) + It("rejects a cluster without credentials secret name", func() { cluster := validStackitCluster("missing-credentials") cluster.Spec.CredentialsSecretRef.Name = "" @@ -126,6 +134,16 @@ var _ = Describe("STACKIT admission webhooks", func() { _, err := validator.ValidateUpdate(ctx, oldCluster, newCluster) expectInvalidField(err, "spec.network.id") }) + + It("rejects setting network ID on a managed cluster", func() { + oldCluster := validStackitCluster("managed-to-byo") + oldCluster.Spec.Network.ID = "" + newCluster := oldCluster.DeepCopy() + newCluster.Spec.Network.ID = validNetworkID + + _, err := validator.ValidateUpdate(ctx, oldCluster, newCluster) + expectInvalidField(err, "spec.network.id") + }) }) Describe("StackitMachine", func() { From 01980abad196c68a56b1b0cb362bfa630d18c64e Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:55:48 +0200 Subject: [PATCH 09/19] feat: implement createnetwork method --- cloud/sdk_client.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index a9dfbf7..3507976 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -239,6 +239,28 @@ func (c *SDKClient) CreateNetwork(ctx context.Context, input CreateNetworkInput) return nil, fmt.Errorf("%w: network name is required", ErrInvalidInput) } + if len(input.Tags) > 0 { + // Check STACKIT API for all networks in this project & region + // filtered by label selector (e.g. "cluster.x-k8s.io/cluster-name=my-cluster") + resp, err := c.iaasClient.DefaultAPI.ListNetworks(ctx, c.projectID, c.region). + LabelSelector(labelSelector(input.Tags)). + Execute() + + if err == nil && resp != nil { + for _, net := range resp.GetItems() { + // Check that the network actually contains all tags + if labelsContainTags(net.GetLabels(), input.Tags) { + ipv4 := net.GetIpv4() + return &Network{ + ID: net.GetId(), + Name: net.GetName(), + IPv4Prefixes: ipv4.GetPrefixes(), + }, nil + } + } + } + } + return nil, nil // TODO } From 4337b0e5b6e210c47fbdafee152ac90c5833c0de Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:18:23 +0200 Subject: [PATCH 10/19] feat: implement create, delete tests for network --- cloud/fake/client.go | 71 ++++++++++++++++++++++++++---- cloud/sdk_client.go | 28 +++++++++++- cloud/sdk_client_test.go | 93 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 11 deletions(-) diff --git a/cloud/fake/client.go b/cloud/fake/client.go index ddeb5b9..00831b3 100644 --- a/cloud/fake/client.go +++ b/cloud/fake/client.go @@ -35,7 +35,7 @@ type Client struct { loadBalancers map[string]*lbEntry publicIPs map[string]*publicIPEntry securityGroups map[string]*securityGroupEntry - networks map[string]*cloud.Network + networks map[string]*networkEntry nextID int @@ -49,6 +49,8 @@ type Client struct { FailNextDeleteLB error FailNextSetTargets error FailNextGetNetwork error + FailNextCreateNetwork error + FailNextDeleteNetwork error FailNextEnsureBastion error FailNextDeleteBastion error FailNextEnsureNodeSSH error @@ -100,6 +102,11 @@ type securityGroupEntry struct { remoteSecurityGroups []string } +type networkEntry struct { + network *cloud.Network + tags map[string]string +} + // New returns a Client preconfigured with the given networks. func New(networks ...cloud.Network) *Client { c := &Client{ @@ -107,11 +114,13 @@ func New(networks ...cloud.Network) *Client { loadBalancers: make(map[string]*lbEntry), publicIPs: make(map[string]*publicIPEntry), securityGroups: make(map[string]*securityGroupEntry), - networks: make(map[string]*cloud.Network), + networks: make(map[string]*networkEntry), } for i := range networks { n := networks[i] - c.networks[n.ID] = &n + c.networks[n.ID] = &networkEntry{ + network: &n, + } } return c } @@ -243,20 +252,64 @@ func (c *Client) GetNetwork(_ context.Context, id string) (*cloud.Network, error if err := consume(&c.FailNextGetNetwork); err != nil { return nil, err } - n, ok := c.networks[id] + entry, ok := c.networks[id] if !ok { return nil, fmt.Errorf("network %q: %w", id, cloud.ErrNotFound) } - out := *n + out := *entry.network return &out, nil } -func (c *Client) CreateNetwork(_ context.Context, _ cloud.CreateNetworkInput) (*cloud.Network, error) { - return nil, nil // TODO +func (c *Client) CreateNetwork(_ context.Context, input cloud.CreateNetworkInput) (*cloud.Network, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := consume(&c.FailNextCreateNetwork); err != nil { + return nil, err + } + if input.Name == "" { + return nil, fmt.Errorf("%w: network name is required", cloud.ErrInvalidInput) + } + + // Idempotency check matching SDK client behavior + if len(input.Tags) > 0 { + for _, entry := range c.networks { + if mapContains(entry.tags, input.Tags) { + out := *entry.network + return &out, nil + } + } + } + + prefixes := []string{} + if input.IPv4Prefix != "" { + prefixes = []string{input.IPv4Prefix} + } + net := &cloud.Network{ + ID: c.genID(), + Name: input.Name, + IPv4Prefixes: prefixes, + } + c.networks[net.ID] = &networkEntry{ + network: net, + tags: maps.Clone(input.Tags), + } + out := *net + return &out, nil } -func (c *Client) DeleteNetwork(_ context.Context, _ string) error { - return nil // TODO +func (c *Client) DeleteNetwork(_ context.Context, id string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if err := consume(&c.FailNextDeleteNetwork); err != nil { + return err + } + if _, ok := c.networks[id]; !ok { + return fmt.Errorf("network %q: %w", id, cloud.ErrNotFound) + } + delete(c.networks, id) + return nil } func (c *Client) EnsureBastion(_ context.Context, input cloud.BastionInput) (*cloud.Bastion, error) { diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 3507976..806775b 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -261,11 +261,35 @@ func (c *SDKClient) CreateNetwork(ctx context.Context, input CreateNetworkInput) } } - return nil, nil // TODO + payload := iaas.NewCreateNetworkPayload(input.Name) + if len(input.Tags) > 0 { + payload.SetLabels(tagsToSDKLabels(input.Tags)) + } + if input.IPv4Prefix != "" { + ipv4Payload := iaas.NewCreateNetworkIPv4WithPrefix(input.IPv4Prefix) + payload.SetIpv4(iaas.CreateNetworkIPv4WithPrefixAsCreateNetworkIPv4(ipv4Payload)) + } + + network, err := c.iaasClient.DefaultAPI.CreateNetwork(ctx, c.projectID, c.region). + CreateNetworkPayload(*payload). + Execute() + if err != nil { + return nil, classifySDKError("create network", err) + } + + ipv4 := network.GetIpv4() + return &Network{ + ID: network.GetId(), + Name: network.GetName(), + IPv4Prefixes: ipv4.GetPrefixes(), + }, nil } func (c *SDKClient) DeleteNetwork(ctx context.Context, id string) error { - return nil // TODO + if err := c.iaasClient.DefaultAPI.DeleteNetwork(ctx, c.projectID, c.region, id).Execute(); err != nil { + return classifySDKError("delete network", err) + } + return nil } func (c *SDKClient) EnsureBastion(ctx context.Context, input BastionInput) (*Bastion, error) { diff --git a/cloud/sdk_client_test.go b/cloud/sdk_client_test.go index 34a3e9b..98239e8 100644 --- a/cloud/sdk_client_test.go +++ b/cloud/sdk_client_test.go @@ -728,3 +728,96 @@ func TestSDKClientListAvailabilityZones(t *testing.T) { t.Fatalf("ListAvailabilityZones() = %v, want [eu01-1 eu01-2 eu01-3]", zones) } } + +func TestSDKClientCreateAndDeleteNetwork(t *testing.T) { + var createdPayload map[string]any + deletedNetworkID := "" + + server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/networks") { + writeJSON(t, w, map[string]any{ + "items": []any{}, + }) + return + } + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/networks") { + createdPayload = readJSON(t, r) + writeJSON(t, w, map[string]any{ + "id": testSDKNetworkID, + "name": "test-network", + "status": "CREATED", + "ipv4": map[string]any{ + "prefixes": []any{"10.0.0.0/24"}, + }, + }) + return + } + if r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/networks/"+testSDKNetworkID) { + deletedNetworkID = testSDKNetworkID + w.WriteHeader(http.StatusNoContent) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + })) + + client := newTestSDKClient(t, server.URL) + net, err := client.CreateNetwork(context.Background(), CreateNetworkInput{ + Name: "test-network", + IPv4Prefix: "10.0.0.0/24", + Tags: map[string]string{"cluster": "test-cluster"}, + }) + if err != nil { + t.Fatalf("CreateNetwork() error = %v", err) + } + if net.ID != testSDKNetworkID { + t.Errorf("CreateNetwork() ID = %s, want %s", net.ID, testSDKNetworkID) + } + if createdPayload["name"] != "test-network" { + t.Errorf("CreateNetwork() payload name = %v, want test-network", createdPayload["name"]) + } + + if err := client.DeleteNetwork(context.Background(), testSDKNetworkID); err != nil { + t.Fatalf("DeleteNetwork() error = %v", err) + } + if deletedNetworkID != testSDKNetworkID { + t.Errorf("DeleteNetwork() deleted = %s, want %s", deletedNetworkID, testSDKNetworkID) + } +} + +func TestSDKClientCreateNetworkIdempotent(t *testing.T) { + server := newSDKTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/networks") { + writeJSON(t, w, map[string]any{ + "items": []any{ + map[string]any{ + "id": testSDKNetworkID, + "name": "existing-network", + "status": "CREATED", + "labels": map[string]any{ + "cluster": "test-cluster", + }, + "ipv4": map[string]any{ + "prefixes": []any{"10.0.0.0/24"}, + }, + }, + }, + }) + return + } + t.Fatalf("unexpected request: %s %s (should not create new network when existing matches)", r.Method, r.URL.String()) + })) + + client := newTestSDKClient(t, server.URL) + net, err := client.CreateNetwork(context.Background(), CreateNetworkInput{ + Name: "existing-network", + IPv4Prefix: "10.0.0.0/24", + Tags: map[string]string{"cluster": "test-cluster"}, + }) + if err != nil { + t.Fatalf("CreateNetwork() error = %v", err) + } + if net.ID != testSDKNetworkID { + t.Errorf("CreateNetwork() ID = %s, want %s", net.ID, testSDKNetworkID) + } +} + From 3ede8b94618fb9d994f5cec82de069c7c9711c12 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:32:16 +0200 Subject: [PATCH 11/19] feat: finalize implementation --- api/v1alpha1/stackitcluster_types.go | 2 +- cloud/sdk_client_test.go | 1 - cloud/services/bastion/bastion.go | 4 +- cloud/services/loadbalancer/loadbalancer.go | 3 +- cloud/services/network/network.go | 11 ++ cloud/services/network/network_test.go | 55 ++++++++ ...ture.cluster.x-k8s.io_stackitclusters.yaml | 2 +- ...ster.x-k8s.io_stackitclustertemplates.yaml | 2 +- controller/stackitcluster_bastion.go | 2 +- controller/stackitcluster_controller_test.go | 35 ++++++ controller/stackitcluster_infrastructure.go | 90 +++++++++++++- controller/stackitmachine_infrastructure.go | 7 +- scope/cluster_scope.go | 11 ++ scope/machine_scope.go | 17 +++ scope/scope_test.go | 117 ++++++++++++++++++ 15 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 cloud/services/network/network_test.go create mode 100644 scope/scope_test.go diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 287074e..90c5770 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -48,7 +48,7 @@ type StackitClusterSpec struct { CredentialsSecretRef corev1.SecretReference `json:"credentialsSecretRef"` // network configures the STACKIT network the cluster will use. - // omiting the ID will create a managed dedicated network + // omitting the ID will create a managed dedicated network // +optional Network StackitClusterNetworkSpec `json:"network,omitempty"` diff --git a/cloud/sdk_client_test.go b/cloud/sdk_client_test.go index 98239e8..b38f621 100644 --- a/cloud/sdk_client_test.go +++ b/cloud/sdk_client_test.go @@ -820,4 +820,3 @@ func TestSDKClientCreateNetworkIdempotent(t *testing.T) { t.Errorf("CreateNetwork() ID = %s, want %s", net.ID, testSDKNetworkID) } } - diff --git a/cloud/services/bastion/bastion.go b/cloud/services/bastion/bastion.go index 6b7f5d9..8d5c451 100644 --- a/cloud/services/bastion/bastion.go +++ b/cloud/services/bastion/bastion.go @@ -16,7 +16,7 @@ import ( "github.com/stackitcloud/cluster-api-provider-stackit/util" ) -func Input(stackitCluster *infrav1.StackitCluster, cloudInit []byte) cloud.BastionInput { +func Input(stackitCluster *infrav1.StackitCluster, networkID string, cloudInit []byte) cloud.BastionInput { deleteOnTermination := true if stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination != nil { deleteOnTermination = *stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination @@ -29,7 +29,7 @@ func Input(stackitCluster *infrav1.StackitCluster, cloudInit []byte) cloud.Basti Name: stackitCluster.Name + "-bastion", ProjectID: stackitCluster.Spec.ProjectID, Region: stackitCluster.Spec.Region, - NetworkID: stackitCluster.Spec.Network.ID, + NetworkID: networkID, ImageID: stackitCluster.Spec.Bastion.ImageID, MachineType: stackitCluster.Spec.Bastion.MachineType, SSHKeyName: stackitCluster.Spec.Bastion.SSHKeyName, diff --git a/cloud/services/loadbalancer/loadbalancer.go b/cloud/services/loadbalancer/loadbalancer.go index b03a355..4d174bf 100644 --- a/cloud/services/loadbalancer/loadbalancer.go +++ b/cloud/services/loadbalancer/loadbalancer.go @@ -41,13 +41,14 @@ func APIServerTags(stackitCluster *infrav1.StackitCluster) map[string]string { func APIServerInput( stackitCluster *infrav1.StackitCluster, + networkID string, targets []cloud.LoadBalancerTargetInput, ) cloud.LoadBalancerInput { return cloud.LoadBalancerInput{ Name: stackitCluster.Name + "-apiserver", ProjectID: stackitCluster.Spec.ProjectID, Region: stackitCluster.Spec.Region, - NetworkID: stackitCluster.Spec.Network.ID, + NetworkID: networkID, Port: defaultAPIServerPort, Tags: APIServerTags(stackitCluster), Targets: targets, diff --git a/cloud/services/network/network.go b/cloud/services/network/network.go index 155b047..a3ffaf4 100644 --- a/cloud/services/network/network.go +++ b/cloud/services/network/network.go @@ -25,6 +25,17 @@ func NetworkTags(stackitCluster *infrav1.StackitCluster) map[string]string { return util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) } +// ClusterNetworkID returns the active network ID for a cluster (spec BYO or status managed). +func ClusterNetworkID(stackitCluster *infrav1.StackitCluster) string { + if stackitCluster == nil { + return "" + } + if stackitCluster.Spec.Network.ID != "" { + return stackitCluster.Spec.Network.ID + } + return stackitCluster.Status.Network.ID +} + func Input(stackitCluster *infrav1.StackitCluster) cloud.CreateNetworkInput { return cloud.CreateNetworkInput{ Name: stackitCluster.Name + "-network", diff --git a/cloud/services/network/network_test.go b/cloud/services/network/network_test.go new file mode 100644 index 0000000..86ea861 --- /dev/null +++ b/cloud/services/network/network_test.go @@ -0,0 +1,55 @@ +/* +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 network + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + "github.com/stackitcloud/cluster-api-provider-stackit/util" +) + +func TestNetworkInput(t *testing.T) { + cluster := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "test-ns", + }, + Spec: infrav1.StackitClusterSpec{ + AdditionalLabels: map[string]string{ + "custom-label": "custom-value", + }, + }, + } + + input := Input(cluster) + + if input.Name != "test-cluster-network" { + t.Errorf("Input() Name = %s, want test-cluster-network", input.Name) + } + if input.IPv4Prefix != DefaultNetworkPrefix { + t.Errorf("Input() IPv4Prefix = %s, want %s", input.IPv4Prefix, DefaultNetworkPrefix) + } + if input.Tags[util.LabelClusterName] != "test-cluster" { + t.Errorf("Input() missing cluster-name tag") + } + if input.Tags[util.LabelClusterNamespace] != "test-ns" { + t.Errorf("Input() missing cluster-namespace tag") + } + if input.Tags[util.LabelManagedBy] != util.ManagedByValue { + t.Errorf("Input() missing managed-by tag") + } + if input.Tags["custom-label"] != "custom-value" { + t.Errorf("Input() missing merged additional label") + } +} diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml index 16de6b8..d01fcbf 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -201,7 +201,7 @@ spec: network: description: |- network configures the STACKIT network the cluster will use. - ommiting the ID will create a managed dedicated network + omitting the ID will create a managed dedicated network properties: id: description: |- diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml index 84a0fff..9e1b11e 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -237,7 +237,7 @@ spec: network: description: |- network configures the STACKIT network the cluster will use. - ommiting the ID will create a managed dedicated network + omitting the ID will create a managed dedicated network properties: id: description: |- diff --git a/controller/stackitcluster_bastion.go b/controller/stackitcluster_bastion.go index e8c3d8b..b907563 100644 --- a/controller/stackitcluster_bastion.go +++ b/controller/stackitcluster_bastion.go @@ -43,7 +43,7 @@ func (r *StackitClusterReconciler) reconcileBastion( ) (ctrl.Result, bool, error) { log := logf.FromContext(ctx) stackitCluster := clusterScope.StackitCluster - input := bastionservice.Input(stackitCluster, nil) + input := bastionservice.Input(stackitCluster, clusterScope.NetworkID(), nil) status := cloud.Bastion{ ServerID: stackitCluster.Status.Bastion.ServerID, PublicIPID: stackitCluster.Status.Bastion.PublicIPID, diff --git a/controller/stackitcluster_controller_test.go b/controller/stackitcluster_controller_test.go index 65077b2..9682041 100644 --- a/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -97,6 +97,41 @@ var _ = Describe("StackitCluster Controller", func() { expectCondition(got.Status.Conditions, infrav1.ClusterReadyCondition, metav1.ConditionTrue, "Available") expectCondition(got.Status.Conditions, infrav1.ClusterNetworkReadyCondition, metav1.ConditionTrue, "Available") expectCondition(got.Status.Conditions, infrav1.ClusterLoadBalancerReadyCondition, metav1.ConditionTrue, "Available") + Expect(got.Status.Network.ID).To(Equal(testNetworkID)) + Expect(got.Status.Network.Managed).To(BeFalse()) + }) + + It("creates and manages a dedicated network when network ID is omitted", func() { + createOwnerCluster(ctx, clusterName+"-managed-net") + defer deleteIfExists(ctx, &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-managed-net", Namespace: namespace}, + }) + managedCluster := newStackitCluster(clusterName+"-managed-net", namespace, false) + managedCluster.Spec.Network.ID = "" + Expect(k8sClient.Create(ctx, managedCluster)).To(Succeed()) + defer deleteIfExists(ctx, managedCluster) + + key := types.NamespacedName{Namespace: namespace, Name: managedCluster.Name} + req := reconcile.Request{NamespacedName: key} + + result, err := reconciler.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + got := &infrav1.StackitCluster{} + Expect(k8sClient.Get(ctx, key, got)).To(Succeed()) + Expect(got.Status.Ready).To(BeTrue()) + Expect(got.Status.Network.ID).NotTo(BeEmpty()) + Expect(got.Status.Network.Managed).To(BeTrue()) + expectCondition(got.Status.Conditions, infrav1.ClusterNetworkReadyCondition, metav1.ConditionTrue, "Available") + + By("deleting the managed cluster and ensuring the network is deleted") + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + _, err = fakeCloud.GetNetwork(ctx, got.Status.Network.ID) + Expect(cloud.IsNotFound(err)).To(BeTrue(), "managed network should be deleted on cluster teardown") }) It("uses an externally provided control plane endpoint when load balancer creation is disabled", func() { diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index e3e5fbc..3eb7a29 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -33,6 +33,7 @@ import ( "github.com/stackitcloud/cluster-api-provider-stackit/cloud" bastionservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/bastion" loadbalancerservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/loadbalancer" + networkservice "github.com/stackitcloud/cluster-api-provider-stackit/cloud/services/network" "github.com/stackitcloud/cluster-api-provider-stackit/scope" "github.com/stackitcloud/cluster-api-provider-stackit/util" ) @@ -88,8 +89,10 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS } clusterScope.SetFailureDomains(failureDomainsFromZones(stackitCluster.Spec.Region, zones)) - network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) + var network *cloud.Network if stackitCluster.Spec.Network.ID != "" { + var err error + network, err = cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) if err != nil { log.Error(err, "Failed to get network", "networkID", stackitCluster.Spec.Network.ID, @@ -112,9 +115,65 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS infrav1.ClusterReadyCondition, ) } + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ + ID: network.ID, + Managed: false, + } } else { - // TODO: implement cloudClient.EnsureNetwork + // Managed network mode: resolve or create dedicated network + var err error + if stackitCluster.Status.Network.ID != "" { + network, err = cloudClient.GetNetwork(ctx, stackitCluster.Status.Network.ID) + if err != nil && !cloud.IsNotFound(err) { + log.Error(err, "Failed to get managed network", + "networkID", stackitCluster.Status.Network.ID, + "region", stackitCluster.Spec.Region, + ) + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + } + + if network == nil { + networkInput := networkservice.Input(stackitCluster) + network, err = cloudClient.CreateNetwork(ctx, networkInput) + if err != nil { + log.Error(err, "Failed to create managed network", + "networkName", networkInput.Name, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedCreateNetwork", + "Failed to create managed network %s in region %s: %v", + networkInput.Name, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkCreateError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + record.Eventf(stackitCluster, "NetworkCreated", "Created managed network %s", network.ID) + } + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ + ID: network.ID, + Managed: true, + } } clusterScope.SetConditions( metav1.ConditionTrue, @@ -155,7 +214,7 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS } loadBalancer, err := cloudClient.EnsureAPIServerLoadBalancer( ctx, - loadbalancerservice.APIServerInput(stackitCluster, seed), + loadbalancerservice.APIServerInput(stackitCluster, clusterScope.NetworkID(), seed), ) if err != nil { log.Error(err, "Failed to create API server load balancer", @@ -443,7 +502,7 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS "Failed to delete bastion node SSH access: %v", err) return ctrl.Result{}, err } - if err := cloudClient.DeleteBastion(ctx, bastionservice.Input(stackitCluster, nil), cloud.Bastion{ + if err := cloudClient.DeleteBastion(ctx, bastionservice.Input(stackitCluster, clusterScope.NetworkID(), nil), cloud.Bastion{ ServerID: stackitCluster.Status.Bastion.ServerID, PublicIPID: stackitCluster.Status.Bastion.PublicIPID, PublicIP: stackitCluster.Status.Bastion.PublicIP, @@ -458,7 +517,28 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS clusterScope.ClearBastionStatus() record.Event(stackitCluster, "BastionDeleted", "Deleted bastion") } - // TODO: implement cloudClient.deleteNetwork - "if managed" etc. + if stackitCluster.Status.Network.Managed && stackitCluster.Status.Network.ID != "" { + networkID := stackitCluster.Status.Network.ID + if err := cloudClient.DeleteNetwork(ctx, networkID); err != nil && !cloud.IsNotFound(err) { + if cloud.IsConflict(err) { + log.Info("Managed network still has resources attached; requeueing deletion", + "cluster", stackitCluster.Name, + "networkID", networkID, + ) + return ctrl.Result{RequeueAfter: deleteRequeueAfter}, nil + } + log.Error(err, "Failed to delete managed network", + "cluster", stackitCluster.Name, + "networkID", networkID, + ) + record.Warnf(stackitCluster, "FailedDeleteNetwork", + "Failed to delete managed network %s: %v", networkID, err) + return ctrl.Result{}, err + } + record.Eventf(stackitCluster, "NetworkDeleted", "Deleted managed network %s", networkID) + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{} + } + controllerutil.RemoveFinalizer(stackitCluster, infrav1.ClusterFinalizer) return ctrl.Result{}, nil } diff --git a/controller/stackitmachine_infrastructure.go b/controller/stackitmachine_infrastructure.go index 920f016..2d7e176 100644 --- a/controller/stackitmachine_infrastructure.go +++ b/controller/stackitmachine_infrastructure.go @@ -337,6 +337,11 @@ func (r *StackitMachineReconciler) ensureServer( ) } + networkID := machineScope.NetworkID() + if networkID == "" { + return nil, false, fmt.Errorf("%w: cluster network ID is not yet available", cloud.ErrTransient) + } + deleteOnTermination := true if stackitMachine.Spec.RootVolume.DeleteOnTermination != nil { deleteOnTermination = *stackitMachine.Spec.RootVolume.DeleteOnTermination @@ -349,7 +354,7 @@ func (r *StackitMachineReconciler) ensureServer( MachineType: stackitMachine.Spec.MachineType, AvailabilityZone: machineScope.AvailabilityZone(), SSHKeyName: stackitMachine.Spec.SSHKeyName, - NetworkID: stackitMachine.Spec.Network.ID, + NetworkID: networkID, SecurityGroups: stackitMachine.Spec.SecurityGroups, UserData: userData, Tags: tags, diff --git a/scope/cluster_scope.go b/scope/cluster_scope.go index d1a4705..476d8e0 100644 --- a/scope/cluster_scope.go +++ b/scope/cluster_scope.go @@ -103,3 +103,14 @@ func (s *ClusterScope) ClearBastionStatus() { func (s *ClusterScope) SetFailureDomains(failureDomains []clusterv1.FailureDomain) { s.StackitCluster.Status.FailureDomains = failureDomains } + +// NetworkID returns the cluster network ID (from spec if BYO, or status if managed). +func (s *ClusterScope) NetworkID() string { + if s.StackitCluster == nil { + return "" + } + if s.StackitCluster.Spec.Network.ID != "" { + return s.StackitCluster.Spec.Network.ID + } + return s.StackitCluster.Status.Network.ID +} diff --git a/scope/machine_scope.go b/scope/machine_scope.go index 58bafde..8846b9a 100644 --- a/scope/machine_scope.go +++ b/scope/machine_scope.go @@ -143,3 +143,20 @@ func (s *MachineScope) AvailabilityZone() string { return "" } + +// NetworkID returns the network ID for this machine, resolving in order: +// 1. StackitMachine.Spec.Network.ID (machine-level override) +// 2. StackitCluster.Spec.Network.ID (BYO cluster network) +// 3. StackitCluster.Status.Network.ID (managed cluster network) +func (s *MachineScope) NetworkID() string { + if s.StackitMachine != nil && s.StackitMachine.Spec.Network.ID != "" { + return s.StackitMachine.Spec.Network.ID + } + if s.StackitCluster != nil { + if s.StackitCluster.Spec.Network.ID != "" { + return s.StackitCluster.Spec.Network.ID + } + return s.StackitCluster.Status.Network.ID + } + return "" +} diff --git a/scope/scope_test.go b/scope/scope_test.go new file mode 100644 index 0000000..649ad7f --- /dev/null +++ b/scope/scope_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +*/ + +package scope + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" +) + +func TestMachineScopeNetworkID(t *testing.T) { + tests := []struct { + name string + stackitMachine *infrav1.StackitMachine + stackitCluster *infrav1.StackitCluster + want string + }{ + { + name: "machine-level network override takes precedence", + stackitMachine: &infrav1.StackitMachine{ + Spec: infrav1.StackitMachineSpec{ + Network: infrav1.StackitMachineNetworkSpec{ID: "machine-network-id"}, + }, + }, + stackitCluster: &infrav1.StackitCluster{ + Spec: infrav1.StackitClusterSpec{ + Network: infrav1.StackitClusterNetworkSpec{ID: "cluster-spec-id"}, + }, + Status: infrav1.StackitClusterStatus{ + Network: infrav1.StackitNetworkStatus{ID: "cluster-status-id", Managed: true}, + }, + }, + want: "machine-network-id", + }, + { + name: "falls back to BYO cluster spec network ID when machine has none", + stackitMachine: &infrav1.StackitMachine{ + Spec: infrav1.StackitMachineSpec{}, + }, + stackitCluster: &infrav1.StackitCluster{ + Spec: infrav1.StackitClusterSpec{ + Network: infrav1.StackitClusterNetworkSpec{ID: "cluster-byo-id"}, + }, + }, + want: "cluster-byo-id", + }, + { + name: "falls back to managed cluster status network ID when spec has none", + stackitMachine: &infrav1.StackitMachine{ + Spec: infrav1.StackitMachineSpec{}, + }, + stackitCluster: &infrav1.StackitCluster{ + Spec: infrav1.StackitClusterSpec{}, + Status: infrav1.StackitClusterStatus{ + Network: infrav1.StackitNetworkStatus{ID: "cluster-managed-status-id", Managed: true}, + }, + }, + want: "cluster-managed-status-id", + }, + { + name: "returns empty when no network is configured anywhere", + stackitMachine: &infrav1.StackitMachine{ + Spec: infrav1.StackitMachineSpec{}, + }, + stackitCluster: &infrav1.StackitCluster{ + Spec: infrav1.StackitClusterSpec{}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scope := &MachineScope{ + StackitMachine: tt.stackitMachine, + StackitCluster: tt.stackitCluster, + } + if got := scope.NetworkID(); got != tt.want { + t.Errorf("NetworkID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClusterScopeNetworkID(t *testing.T) { + clusterBYO := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "byo-cluster"}, + Spec: infrav1.StackitClusterSpec{ + Network: infrav1.StackitClusterNetworkSpec{ID: "byo-id"}, + }, + } + scopeBYO := &ClusterScope{StackitCluster: clusterBYO} + if got := scopeBYO.NetworkID(); got != "byo-id" { + t.Errorf("ClusterScope.NetworkID() = %q, want byo-id", got) + } + + clusterManaged := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "managed-cluster"}, + Status: infrav1.StackitClusterStatus{ + Network: infrav1.StackitNetworkStatus{ID: "managed-status-id", Managed: true}, + }, + } + scopeManaged := &ClusterScope{StackitCluster: clusterManaged} + if got := scopeManaged.NetworkID(); got != "managed-status-id" { + t.Errorf("ClusterScope.NetworkID() = %q, want managed-status-id", got) + } +} From ebcc0b85394c01736a9794936394f66fb2f1806f Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:45:00 +0200 Subject: [PATCH 12/19] fix(test): fix credential error --- controller/stackitcluster_controller_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/controller/stackitcluster_controller_test.go b/controller/stackitcluster_controller_test.go index 9682041..fc11b26 100644 --- a/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -107,6 +107,7 @@ var _ = Describe("StackitCluster Controller", func() { ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-managed-net", Namespace: namespace}, }) managedCluster := newStackitCluster(clusterName+"-managed-net", namespace, false) + managedCluster.Spec.CredentialsSecretRef.Name = credentials managedCluster.Spec.Network.ID = "" Expect(k8sClient.Create(ctx, managedCluster)).To(Succeed()) defer deleteIfExists(ctx, managedCluster) From 3b0a75a57f104e5ae3415676f4cb7d5ddf2655dc Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:46:41 +0200 Subject: [PATCH 13/19] fix: clean up --- api/v1alpha1/stackitcluster_types.go | 16 ++-- cloud/cleanup.go | 11 +++ cloud/cleanup_test.go | 20 +++++ cloud/client.go | 4 + cloud/fake/client.go | 53 +++++++++++-- cloud/sdk_client.go | 60 +++++++++----- cloud/services/network/network.go | 41 +++++++--- cloud/services/network/network_test.go | 79 +++++++++++++++++++ ...ture.cluster.x-k8s.io_stackitclusters.yaml | 8 +- ...ster.x-k8s.io_stackitclustertemplates.yaml | 2 +- controller/stackitcluster_controller_test.go | 67 ++++++++++++++++ controller/stackitcluster_infrastructure.go | 34 ++++++-- controller/stackitmachine_infrastructure.go | 4 +- docs/src/getting-started/cloud-resources.md | 29 +++---- 14 files changed, 360 insertions(+), 68 deletions(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 90c5770..4e61c87 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -73,10 +73,10 @@ type StackitClusterSpec struct { AdditionalLabels map[string]string `json:"additionalLabels,omitempty"` } -// StackitClusterNetworkSpec references an existing STACKIT network. +// StackitClusterNetworkSpec configures the network for a StackitCluster. type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. - // Omitting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID + // Omitting the network ID provisions a dedicated managed STACKIT network for the cluster. // +kubebuilder:validation:Pattern=`^[0-9a-fA-F-]{36}$` // +optional ID string `json:"id,omitempty"` @@ -221,12 +221,14 @@ type StackitBastionStatus struct { CloudInitHash string `json:"cloudInitHash,omitempty"` } -// network contains status of a STACKIT network created by CAPSTK +// StackitNetworkStatus tracks the network used by the cluster (BYO or managed). type StackitNetworkStatus struct { - // id is the STACKIT network ID + // id is the STACKIT network ID. + // +optional + ID string `json:"id,omitempty"` + // managed indicates whether the network is managed (created and owned) by CAPSTK. // +optional - ID string `json:"id,omitempty"` - Managed bool `json:"managed,omitempty"` + Managed bool `json:"managed,omitempty"` } // Condition types maintained by the StackitClusterReconciler. @@ -246,7 +248,7 @@ const ( // +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=".status.apiServerEndpoint.host" // +kubebuilder:printcolumn:name="Bastion IP",type=string,JSONPath=".status.bastion.publicIP" // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" -// +kubebuilder:printcolumn:name="Network ID",type=string,JSONPath=".spec.network.id",priority=1 +// +kubebuilder:printcolumn:name="Network ID",type=string,JSONPath=".status.network.id",priority=1 // +kubebuilder:printcolumn:name="Project ID",type=string,JSONPath=".spec.projectID",priority=1 // +kubebuilder:storageversion diff --git a/cloud/cleanup.go b/cloud/cleanup.go index 9f155d1..83ccbfe 100644 --- a/cloud/cleanup.go +++ b/cloud/cleanup.go @@ -55,6 +55,17 @@ func CleanupByTags(ctx context.Context, client Client, tags map[string]string) e } } + networks, err := client.ListNetworksByTags(ctx, tags) + if err != nil { + errs = append(errs, err.Error()) + } else { + for _, net := range networks { + if err := client.DeleteNetwork(ctx, net.ID); err != nil && !IsNotFound(err) { + errs = append(errs, fmt.Sprintf("delete network %q: %v", net.ID, err)) + } + } + } + if len(errs) > 0 { return fmt.Errorf("%w: %s", ErrTransient, strings.Join(errs, "; ")) } diff --git a/cloud/cleanup_test.go b/cloud/cleanup_test.go index 6ef2778..9a5da79 100644 --- a/cloud/cleanup_test.go +++ b/cloud/cleanup_test.go @@ -60,6 +60,12 @@ func TestCleanupByTagsDeletesMatchingCloudResources(t *testing.T) { }); err != nil { t.Fatalf("EnsureAPIServerLoadBalancer() error = %v", err) } + if _, err := client.CreateNetwork(ctx, cloud.CreateNetworkInput{Name: "matched-net", Tags: tags}); err != nil { + t.Fatalf("CreateNetwork() error = %v", err) + } + if _, err := client.CreateNetwork(ctx, cloud.CreateNetworkInput{Name: "other-net", Tags: otherTags}); err != nil { + t.Fatalf("CreateNetwork() error = %v", err) + } if err := cloud.CleanupByTags(ctx, client, tags); err != nil { t.Fatalf("CleanupByTags() error = %v", err) @@ -86,4 +92,18 @@ func TestCleanupByTagsDeletesMatchingCloudResources(t *testing.T) { if len(matchedLoadBalancers) != 0 { t.Fatalf("matched load balancer count = %d, want 0", len(matchedLoadBalancers)) } + matchedNetworks, err := client.ListNetworksByTags(ctx, tags) + if err != nil { + t.Fatalf("ListNetworksByTags() error = %v", err) + } + if len(matchedNetworks) != 0 { + t.Fatalf("matched network count = %d, want 0", len(matchedNetworks)) + } + otherNetworks, err := client.ListNetworksByTags(ctx, otherTags) + if err != nil { + t.Fatalf("ListNetworksByTags(other) error = %v", err) + } + if len(otherNetworks) != 1 { + t.Fatalf("other network count = %d, want 1", len(otherNetworks)) + } } diff --git a/cloud/client.go b/cloud/client.go index f319370..d48ac99 100644 --- a/cloud/client.go +++ b/cloud/client.go @@ -25,6 +25,10 @@ import "context" type Client interface { GetNetwork(ctx context.Context, id string) (*Network, error) + FindNetworkByTags(ctx context.Context, tags map[string]string) (*Network, error) + + ListNetworksByTags(ctx context.Context, tags map[string]string) ([]*Network, error) + CreateNetwork(ctx context.Context, input CreateNetworkInput) (*Network, error) DeleteNetwork(ctx context.Context, id string) error diff --git a/cloud/fake/client.go b/cloud/fake/client.go index 00831b3..39128f0 100644 --- a/cloud/fake/client.go +++ b/cloud/fake/client.go @@ -260,6 +260,47 @@ func (c *Client) GetNetwork(_ context.Context, id string) (*cloud.Network, error return &out, nil } +func (c *Client) FindNetworkByTags(_ context.Context, tags map[string]string) (*cloud.Network, error) { + c.mu.Lock() + defer c.mu.Unlock() + + matched := c.networksByTags(tags) + if len(matched) == 0 { + return nil, fmt.Errorf("%w: no network matching tags", cloud.ErrNotFound) + } + if len(matched) > 1 { + return nil, fmt.Errorf("%w: multiple networks matching tags", cloud.ErrConflict) + } + out := *matched[0] + return &out, nil +} + +func (c *Client) ListNetworksByTags(_ context.Context, tags map[string]string) ([]*cloud.Network, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if len(tags) == 0 { + return nil, fmt.Errorf("%w: empty tag selector", cloud.ErrInvalidInput) + } + matched := c.networksByTags(tags) + out := make([]*cloud.Network, 0, len(matched)) + for _, n := range matched { + copy := *n + out = append(out, ©) + } + return out, nil +} + +func (c *Client) networksByTags(tags map[string]string) []*cloud.Network { + var matched []*cloud.Network + for _, entry := range c.networks { + if mapContains(entry.tags, tags) { + matched = append(matched, entry.network) + } + } + return matched +} + func (c *Client) CreateNetwork(_ context.Context, input cloud.CreateNetworkInput) (*cloud.Network, error) { c.mu.Lock() defer c.mu.Unlock() @@ -273,11 +314,13 @@ func (c *Client) CreateNetwork(_ context.Context, input cloud.CreateNetworkInput // Idempotency check matching SDK client behavior if len(input.Tags) > 0 { - for _, entry := range c.networks { - if mapContains(entry.tags, input.Tags) { - out := *entry.network - return &out, nil - } + matched := c.networksByTags(input.Tags) + if len(matched) > 1 { + return nil, fmt.Errorf("%w: multiple networks matching tags", cloud.ErrConflict) + } + if len(matched) == 1 { + out := *matched[0] + return &out, nil } } diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 806775b..bf3a17c 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -234,30 +234,54 @@ func (c *SDKClient) GetNetwork(ctx context.Context, id string) (*Network, error) }, nil } +func (c *SDKClient) FindNetworkByTags(ctx context.Context, tags map[string]string) (*Network, error) { + networks, err := c.ListNetworksByTags(ctx, tags) + if err != nil { + return nil, err + } + if len(networks) == 0 { + return nil, fmt.Errorf("%w: no network matching tags", ErrNotFound) + } + if len(networks) > 1 { + return nil, fmt.Errorf("%w: multiple networks matching tags", ErrConflict) + } + return networks[0], nil +} + +func (c *SDKClient) ListNetworksByTags(ctx context.Context, tags map[string]string) ([]*Network, error) { + if len(tags) == 0 { + return nil, fmt.Errorf("%w: empty tag selector", ErrInvalidInput) + } + resp, err := c.iaasClient.DefaultAPI.ListNetworks(ctx, c.projectID, c.region). + LabelSelector(labelSelector(tags)). + Execute() + if err != nil { + return nil, classifySDKError("list networks", err) + } + matched := []*Network{} + for _, net := range resp.GetItems() { + if labelsContainTags(net.GetLabels(), tags) { + ipv4 := net.GetIpv4() + matched = append(matched, &Network{ + ID: net.GetId(), + Name: net.GetName(), + IPv4Prefixes: ipv4.GetPrefixes(), + }) + } + } + return matched, nil +} + func (c *SDKClient) CreateNetwork(ctx context.Context, input CreateNetworkInput) (*Network, error) { if input.Name == "" { return nil, fmt.Errorf("%w: network name is required", ErrInvalidInput) } if len(input.Tags) > 0 { - // Check STACKIT API for all networks in this project & region - // filtered by label selector (e.g. "cluster.x-k8s.io/cluster-name=my-cluster") - resp, err := c.iaasClient.DefaultAPI.ListNetworks(ctx, c.projectID, c.region). - LabelSelector(labelSelector(input.Tags)). - Execute() - - if err == nil && resp != nil { - for _, net := range resp.GetItems() { - // Check that the network actually contains all tags - if labelsContainTags(net.GetLabels(), input.Tags) { - ipv4 := net.GetIpv4() - return &Network{ - ID: net.GetId(), - Name: net.GetName(), - IPv4Prefixes: ipv4.GetPrefixes(), - }, nil - } - } + if existing, err := c.FindNetworkByTags(ctx, input.Tags); err == nil { + return existing, nil + } else if !IsNotFound(err) { + return nil, err } } diff --git a/cloud/services/network/network.go b/cloud/services/network/network.go index a3ffaf4..50b5633 100644 --- a/cloud/services/network/network.go +++ b/cloud/services/network/network.go @@ -11,6 +11,8 @@ You may obtain a copy of the License at package network import ( + "context" + infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" "github.com/stackitcloud/cluster-api-provider-stackit/cloud" "github.com/stackitcloud/cluster-api-provider-stackit/util" @@ -25,17 +27,6 @@ func NetworkTags(stackitCluster *infrav1.StackitCluster) map[string]string { return util.ClusterTags(stackitCluster.Name, stackitCluster.Namespace, stackitCluster.Spec.AdditionalLabels) } -// ClusterNetworkID returns the active network ID for a cluster (spec BYO or status managed). -func ClusterNetworkID(stackitCluster *infrav1.StackitCluster) string { - if stackitCluster == nil { - return "" - } - if stackitCluster.Spec.Network.ID != "" { - return stackitCluster.Spec.Network.ID - } - return stackitCluster.Status.Network.ID -} - func Input(stackitCluster *infrav1.StackitCluster) cloud.CreateNetworkInput { return cloud.CreateNetworkInput{ Name: stackitCluster.Name + "-network", @@ -43,3 +34,31 @@ func Input(stackitCluster *infrav1.StackitCluster) cloud.CreateNetworkInput { Tags: NetworkTags(stackitCluster), } } + +// ResolveManagedNetworkID finds the managed network ID to delete during cluster teardown. +// It returns empty string if the cluster is in BYO mode or if no managed network is found. +// Neither spec nor status is a trustworthy record of what exists in the cloud, so if status +// was lost, it falls back to looking up the network by cluster tags. +func ResolveManagedNetworkID( + ctx context.Context, + cloudClient cloud.Client, + stackitCluster *infrav1.StackitCluster, +) (string, error) { + // If spec.network.id is set, the network is BYO and must NEVER be deleted. + if stackitCluster.Spec.Network.ID != "" { + return "", nil + } + + if stackitCluster.Status.Network.ID != "" { + return stackitCluster.Status.Network.ID, nil + } + + network, err := cloudClient.FindNetworkByTags(ctx, NetworkTags(stackitCluster)) + if err != nil { + if cloud.IsNotFound(err) { + return "", nil + } + return "", err + } + return network.ID, nil +} diff --git a/cloud/services/network/network_test.go b/cloud/services/network/network_test.go index 86ea861..015b9f5 100644 --- a/cloud/services/network/network_test.go +++ b/cloud/services/network/network_test.go @@ -11,11 +11,13 @@ You may obtain a copy of the License at package network import ( + "context" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" + cloudfake "github.com/stackitcloud/cluster-api-provider-stackit/cloud/fake" "github.com/stackitcloud/cluster-api-provider-stackit/util" ) @@ -53,3 +55,80 @@ func TestNetworkInput(t *testing.T) { t.Errorf("Input() missing merged additional label") } } + +func TestResolveManagedNetworkID(t *testing.T) { + ctx := context.Background() + + t.Run("returns empty string for BYO network in spec", func(t *testing.T) { + fakeClient := cloudfake.New() + cluster := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "byo-cluster", Namespace: "default"}, + Spec: infrav1.StackitClusterSpec{ + Network: infrav1.StackitClusterNetworkSpec{ID: "byo-net-id"}, + }, + Status: infrav1.StackitClusterStatus{ + Network: infrav1.StackitNetworkStatus{ID: "byo-net-id", Managed: false}, + }, + } + + id, err := ResolveManagedNetworkID(ctx, fakeClient, cluster) + if err != nil { + t.Fatalf("ResolveManagedNetworkID() error = %v", err) + } + if id != "" { + t.Errorf("ResolveManagedNetworkID() = %q, want empty string for BYO network", id) + } + }) + + t.Run("returns network ID from status when present", func(t *testing.T) { + fakeClient := cloudfake.New() + cluster := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "managed-cluster", Namespace: "default"}, + Status: infrav1.StackitClusterStatus{ + Network: infrav1.StackitNetworkStatus{ID: "status-net-id", Managed: true}, + }, + } + + id, err := ResolveManagedNetworkID(ctx, fakeClient, cluster) + if err != nil { + t.Fatalf("ResolveManagedNetworkID() error = %v", err) + } + if id != "status-net-id" { + t.Errorf("ResolveManagedNetworkID() = %q, want status-net-id", id) + } + }) + + t.Run("falls back to tag lookup when status is empty", func(t *testing.T) { + fakeClient := cloudfake.New() + cluster := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "managed-cluster", Namespace: "default"}, + } + created, err := fakeClient.CreateNetwork(ctx, Input(cluster)) + if err != nil { + t.Fatalf("CreateNetwork() error = %v", err) + } + + id, err := ResolveManagedNetworkID(ctx, fakeClient, cluster) + if err != nil { + t.Fatalf("ResolveManagedNetworkID() error = %v", err) + } + if id != created.ID { + t.Errorf("ResolveManagedNetworkID() = %q, want %q", id, created.ID) + } + }) + + t.Run("returns empty string when no network exists in status or cloud", func(t *testing.T) { + fakeClient := cloudfake.New() + cluster := &infrav1.StackitCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "managed-cluster", Namespace: "default"}, + } + + id, err := ResolveManagedNetworkID(ctx, fakeClient, cluster) + if err != nil { + t.Fatalf("ResolveManagedNetworkID() error = %v", err) + } + if id != "" { + t.Errorf("ResolveManagedNetworkID() = %q, want empty string", id) + } + }) +} diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml index d01fcbf..197cc03 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -34,7 +34,7 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date - - jsonPath: .spec.network.id + - jsonPath: .status.network.id name: Network ID priority: 1 type: string @@ -206,7 +206,7 @@ spec: id: description: |- id is the STACKIT network ID. - Omitting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID + Omitting the network ID provisions a dedicated managed STACKIT network for the cluster. pattern: ^[0-9a-fA-F-]{36}$ type: string type: object @@ -398,9 +398,11 @@ spec: description: network stores observed provider-managed network resources. properties: id: - description: id is the STACKIT network ID + description: id is the STACKIT network ID. type: string managed: + description: managed indicates whether the network is managed + (created and owned) by CAPSTK. type: boolean type: object ready: diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml index 9e1b11e..33e71f9 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -242,7 +242,7 @@ spec: id: description: |- id is the STACKIT network ID. - Omitting the network ID provisions a dedicated STACKIT network for the cluster, tag it as managed (owned) and stores the ID + Omitting the network ID provisions a dedicated managed STACKIT network for the cluster. pattern: ^[0-9a-fA-F-]{36}$ type: string type: object diff --git a/controller/stackitcluster_controller_test.go b/controller/stackitcluster_controller_test.go index fc11b26..a92ee67 100644 --- a/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -135,6 +135,73 @@ var _ = Describe("StackitCluster Controller", func() { Expect(cloud.IsNotFound(err)).To(BeTrue(), "managed network should be deleted on cluster teardown") }) + It("resolves and deletes managed network via tags even when status.network was lost", func() { + createOwnerCluster(ctx, clusterName+"-lost-status") + defer deleteIfExists(ctx, &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-lost-status", Namespace: namespace}, + }) + clusterLostStatus := newStackitCluster(clusterName+"-lost-status", namespace, false) + clusterLostStatus.Spec.CredentialsSecretRef.Name = credentials + clusterLostStatus.Spec.Network.ID = "" + Expect(k8sClient.Create(ctx, clusterLostStatus)).To(Succeed()) + defer deleteIfExists(ctx, clusterLostStatus) + + key := types.NamespacedName{Namespace: namespace, Name: clusterLostStatus.Name} + req := reconcile.Request{NamespacedName: key} + + _, err := reconciler.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + got := &infrav1.StackitCluster{} + Expect(k8sClient.Get(ctx, key, got)).To(Succeed()) + createdNetID := got.Status.Network.ID + Expect(createdNetID).NotTo(BeEmpty()) + + By("wiping the persisted network status as if patch failed") + got.Status.Network = infrav1.StackitNetworkStatus{} + Expect(k8sClient.Status().Update(ctx, got)).To(Succeed()) + + By("deleting the cluster and verifying tag fallback finds and deletes the network") + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + _, err = reconciler.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + _, err = fakeCloud.GetNetwork(ctx, createdNetID) + Expect(cloud.IsNotFound(err)).To(BeTrue(), "network should be deleted via tag fallback") + }) + + It("requeues deletion with condition when managed network has attached resources", func() { + createOwnerCluster(ctx, clusterName+"-net-conflict") + defer deleteIfExists(ctx, &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-net-conflict", Namespace: namespace}, + }) + clusterConflict := newStackitCluster(clusterName+"-net-conflict", namespace, false) + clusterConflict.Spec.CredentialsSecretRef.Name = credentials + clusterConflict.Spec.Network.ID = "" + Expect(k8sClient.Create(ctx, clusterConflict)).To(Succeed()) + defer deleteIfExists(ctx, clusterConflict) + + key := types.NamespacedName{Namespace: namespace, Name: clusterConflict.Name} + req := reconcile.Request{NamespacedName: key} + + _, err := reconciler.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + got := &infrav1.StackitCluster{} + Expect(k8sClient.Get(ctx, key, got)).To(Succeed()) + + By("injecting a conflict error on network delete") + fakeCloud.FailNextDeleteNetwork = fmt.Errorf("ports still attached: %w", cloud.ErrConflict) + + Expect(k8sClient.Delete(ctx, got)).To(Succeed()) + result, err := reconciler.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) + + Expect(k8sClient.Get(ctx, key, got)).To(Succeed()) + expectCondition(got.Status.Conditions, infrav1.ClusterNetworkReadyCondition, metav1.ConditionFalse, "NetworkInUse") + }) + It("uses an externally provided control plane endpoint when load balancer creation is disabled", func() { got := &infrav1.StackitCluster{} Expect(k8sClient.Get(ctx, stackitKey, got)).To(Succeed()) diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index 3eb7a29..79848f5 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -129,6 +129,12 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS "networkID", stackitCluster.Status.Network.ID, "region", stackitCluster.Spec.Region, ) + record.Warnf( + stackitCluster, "FailedGetNetwork", + "Failed to get managed network %s in region %s: %v", + stackitCluster.Status.Network.ID, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false return util.CloudFailureResult( &stackitCluster.Status.Conditions, stackitCluster.Generation, @@ -517,25 +523,39 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS clusterScope.ClearBastionStatus() record.Event(stackitCluster, "BastionDeleted", "Deleted bastion") } - if stackitCluster.Status.Network.Managed && stackitCluster.Status.Network.ID != "" { - networkID := stackitCluster.Status.Network.ID - if err := cloudClient.DeleteNetwork(ctx, networkID); err != nil && !cloud.IsNotFound(err) { + managedNetworkID, err := networkservice.ResolveManagedNetworkID(ctx, cloudClient, stackitCluster) + if err != nil { + log.Error(err, "Failed to resolve managed network for deletion", "cluster", stackitCluster.Name) + record.Warnf(stackitCluster, "FailedResolveNetwork", + "Failed to resolve managed network: %v", err) + return ctrl.Result{}, err + } + if managedNetworkID != "" { + if err := cloudClient.DeleteNetwork(ctx, managedNetworkID); err != nil && !cloud.IsNotFound(err) { if cloud.IsConflict(err) { log.Info("Managed network still has resources attached; requeueing deletion", "cluster", stackitCluster.Name, - "networkID", networkID, + "networkID", managedNetworkID, + ) + clusterScope.SetConditions( + metav1.ConditionFalse, + "NetworkInUse", + err.Error(), + infrav1.ClusterNetworkReadyCondition, ) + record.Warnf(stackitCluster, "NetworkInUse", + "Managed network %s still has active resources attached; retrying deletion: %v", managedNetworkID, err) return ctrl.Result{RequeueAfter: deleteRequeueAfter}, nil } log.Error(err, "Failed to delete managed network", "cluster", stackitCluster.Name, - "networkID", networkID, + "networkID", managedNetworkID, ) record.Warnf(stackitCluster, "FailedDeleteNetwork", - "Failed to delete managed network %s: %v", networkID, err) + "Failed to delete managed network %s: %v", managedNetworkID, err) return ctrl.Result{}, err } - record.Eventf(stackitCluster, "NetworkDeleted", "Deleted managed network %s", networkID) + record.Eventf(stackitCluster, "NetworkDeleted", "Deleted managed network %s", managedNetworkID) stackitCluster.Status.Network = infrav1.StackitNetworkStatus{} } diff --git a/controller/stackitmachine_infrastructure.go b/controller/stackitmachine_infrastructure.go index 2d7e176..10b7010 100644 --- a/controller/stackitmachine_infrastructure.go +++ b/controller/stackitmachine_infrastructure.go @@ -111,13 +111,13 @@ func (r *StackitMachineReconciler) reconcileNormal(ctx context.Context, machineS log.Error(err, "Failed to create server", "machine", stackitMachine.Name, "imageID", stackitMachine.Spec.ImageID, - "networkID", stackitMachine.Spec.Network.ID, + "networkID", machineScope.NetworkID(), "region", machineScope.StackitCluster.Spec.Region, ) record.Warnf( stackitMachine, "FailedCreateInstance", "Failed to create server %s (imageID: %s, networkID: %s, region: %s): %v", - stackitMachine.Name, stackitMachine.Spec.ImageID, stackitMachine.Spec.Network.ID, + stackitMachine.Name, stackitMachine.Spec.ImageID, machineScope.NetworkID(), machineScope.StackitCluster.Spec.Region, err, ) machineScope.SetNotReady( diff --git a/docs/src/getting-started/cloud-resources.md b/docs/src/getting-started/cloud-resources.md index 37961fc..597b182 100644 --- a/docs/src/getting-started/cloud-resources.md +++ b/docs/src/getting-started/cloud-resources.md @@ -1,14 +1,26 @@ # STACKIT Cloud Resources -The provider currently consumes existing STACKIT resources. It creates and -deletes workload-cluster servers and, when enabled, an API server load balancer, -but it does not create networks, security groups, SSH keys, or images. +The provider provisions and manages workload-cluster servers, API server load balancers, +and dedicated cluster networks (when enabled), while supporting pre-existing (BYO) networks, +security groups, SSH keys, and images. ## Network `StackitCluster.spec.network.id` is the STACKIT network ID used by all cluster machines and the provider-managed API server load balancer. +If you don't provide a network ID, CAPS will automatically create a dedicated /24 network for you. + +Planning your networks usually involves platform-level policy such as address +planning, routing, peering, VPN, DNS, and shared-service access, but the default +managed network is built for convenience. A newly created (managed) network +will have an attached tag indicating that it is managed by CAPS. + +Only empty networks can be deleted, so when you delete your CAPS cluster and +all cluster nodes and load balancers are removed, the managed network will be deleted too. + +### Bring-Your-Own (BYO) Network + Choose or create a network in the same region as the workload cluster. The network must provide: @@ -18,17 +30,6 @@ network must provide: container image pulls, DNS, NTP, and STACKIT APIs. - Traffic required by the selected CNI. -If you don't provide a network ID, CAPS will create one /24 Network for you. - -Planning your Networks usually involves platform-level policy such as address -planning and considers routing, peering, VPN, DNS, and shared-service access -but the default is built for convenience and a newly created (managed) network -will a have an attached tag indicating that it is managed by CAPS. - -Only empty Networks can be deleted, so if you delete your CAPS Cluster and you -have no other manually created resource in that cluster, it will be deleted too. - - ### How to create a STACKIT Network manually Create a network with the STACKIT CLI: From 2e8b8bc3c820c82faab9bcc565364352db9ad106 Mon Sep 17 00:00:00 2001 From: Fabian <21021337+fabian-schmitt@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:29:55 +0200 Subject: [PATCH 14/19] Update api/v1alpha1/stackitcluster_types.go Co-authored-by: Jan Larwig --- api/v1alpha1/stackitcluster_types.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 4e61c87..f1e602e 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -47,8 +47,8 @@ type StackitClusterSpec struct { // +required CredentialsSecretRef corev1.SecretReference `json:"credentialsSecretRef"` - // network configures the STACKIT network the cluster will use. - // omitting the ID will create a managed dedicated network + // network references the STACKIT network the cluster will use. + // Omitting the ID will create a managed dedicated network for your cluster. // +optional Network StackitClusterNetworkSpec `json:"network,omitempty"` From 3a34aaab2344052810227aa7f40b63a812a98395 Mon Sep 17 00:00:00 2001 From: Fabian <21021337+fabian-schmitt@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:30:06 +0200 Subject: [PATCH 15/19] Update api/v1alpha1/stackitcluster_types.go Co-authored-by: Jan Larwig --- api/v1alpha1/stackitcluster_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index f1e602e..a8f8143 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -73,7 +73,7 @@ type StackitClusterSpec struct { AdditionalLabels map[string]string `json:"additionalLabels,omitempty"` } -// StackitClusterNetworkSpec configures the network for a StackitCluster. +// StackitClusterNetworkSpec references the network for a StackitCluster. type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. // Omitting the network ID provisions a dedicated managed STACKIT network for the cluster. From e826a81fc33a4eea1403eff65f1db9083b0bf1bc Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:51:47 +0100 Subject: [PATCH 16/19] fix: resolve review suggestions --- cloud/services/bastion/bastion.go | 6 ++++-- cloud/services/loadbalancer/loadbalancer.go | 7 ++++--- .../infrastructure.cluster.x-k8s.io_stackitclusters.yaml | 4 ++-- ...structure.cluster.x-k8s.io_stackitclustertemplates.yaml | 4 ++-- controller/stackitcluster_bastion.go | 2 +- controller/stackitcluster_infrastructure.go | 4 ++-- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cloud/services/bastion/bastion.go b/cloud/services/bastion/bastion.go index 8d5c451..fac16ff 100644 --- a/cloud/services/bastion/bastion.go +++ b/cloud/services/bastion/bastion.go @@ -13,10 +13,12 @@ package bastion import ( infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" "github.com/stackitcloud/cluster-api-provider-stackit/util" ) -func Input(stackitCluster *infrav1.StackitCluster, networkID string, cloudInit []byte) cloud.BastionInput { +func Input(clusterScope *scope.ClusterScope, cloudInit []byte) cloud.BastionInput { + stackitCluster := clusterScope.StackitCluster deleteOnTermination := true if stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination != nil { deleteOnTermination = *stackitCluster.Spec.Bastion.RootVolume.DeleteOnTermination @@ -29,7 +31,7 @@ func Input(stackitCluster *infrav1.StackitCluster, networkID string, cloudInit [ Name: stackitCluster.Name + "-bastion", ProjectID: stackitCluster.Spec.ProjectID, Region: stackitCluster.Spec.Region, - NetworkID: networkID, + NetworkID: clusterScope.NetworkID(), ImageID: stackitCluster.Spec.Bastion.ImageID, MachineType: stackitCluster.Spec.Bastion.MachineType, SSHKeyName: stackitCluster.Spec.Bastion.SSHKeyName, diff --git a/cloud/services/loadbalancer/loadbalancer.go b/cloud/services/loadbalancer/loadbalancer.go index 4d174bf..aa321e1 100644 --- a/cloud/services/loadbalancer/loadbalancer.go +++ b/cloud/services/loadbalancer/loadbalancer.go @@ -22,6 +22,7 @@ import ( infrav1 "github.com/stackitcloud/cluster-api-provider-stackit/api/v1alpha1" "github.com/stackitcloud/cluster-api-provider-stackit/cloud" + "github.com/stackitcloud/cluster-api-provider-stackit/scope" "github.com/stackitcloud/cluster-api-provider-stackit/util" ) @@ -40,15 +41,15 @@ func APIServerTags(stackitCluster *infrav1.StackitCluster) map[string]string { } func APIServerInput( - stackitCluster *infrav1.StackitCluster, - networkID string, + clusterScope *scope.ClusterScope, targets []cloud.LoadBalancerTargetInput, ) cloud.LoadBalancerInput { + stackitCluster := clusterScope.StackitCluster return cloud.LoadBalancerInput{ Name: stackitCluster.Name + "-apiserver", ProjectID: stackitCluster.Spec.ProjectID, Region: stackitCluster.Spec.Region, - NetworkID: networkID, + NetworkID: clusterScope.NetworkID(), Port: defaultAPIServerPort, Tags: APIServerTags(stackitCluster), Targets: targets, diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml index 197cc03..9bc8689 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -200,8 +200,8 @@ spec: x-kubernetes-map-type: atomic network: description: |- - network configures the STACKIT network the cluster will use. - omitting the ID will create a managed dedicated network + network references the STACKIT network the cluster will use. + Omitting the ID will create a managed dedicated network for your cluster. properties: id: description: |- diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml index 33e71f9..178e435 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -236,8 +236,8 @@ spec: x-kubernetes-map-type: atomic network: description: |- - network configures the STACKIT network the cluster will use. - omitting the ID will create a managed dedicated network + network references the STACKIT network the cluster will use. + Omitting the ID will create a managed dedicated network for your cluster. properties: id: description: |- diff --git a/controller/stackitcluster_bastion.go b/controller/stackitcluster_bastion.go index b907563..4ec855a 100644 --- a/controller/stackitcluster_bastion.go +++ b/controller/stackitcluster_bastion.go @@ -43,7 +43,7 @@ func (r *StackitClusterReconciler) reconcileBastion( ) (ctrl.Result, bool, error) { log := logf.FromContext(ctx) stackitCluster := clusterScope.StackitCluster - input := bastionservice.Input(stackitCluster, clusterScope.NetworkID(), nil) + input := bastionservice.Input(clusterScope, nil) status := cloud.Bastion{ ServerID: stackitCluster.Status.Bastion.ServerID, PublicIPID: stackitCluster.Status.Bastion.PublicIPID, diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index 79848f5..db4d051 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -220,7 +220,7 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS } loadBalancer, err := cloudClient.EnsureAPIServerLoadBalancer( ctx, - loadbalancerservice.APIServerInput(stackitCluster, clusterScope.NetworkID(), seed), + loadbalancerservice.APIServerInput(clusterScope, seed), ) if err != nil { log.Error(err, "Failed to create API server load balancer", @@ -508,7 +508,7 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS "Failed to delete bastion node SSH access: %v", err) return ctrl.Result{}, err } - if err := cloudClient.DeleteBastion(ctx, bastionservice.Input(stackitCluster, clusterScope.NetworkID(), nil), cloud.Bastion{ + if err := cloudClient.DeleteBastion(ctx, bastionservice.Input(clusterScope, nil), cloud.Bastion{ ServerID: stackitCluster.Status.Bastion.ServerID, PublicIPID: stackitCluster.Status.Bastion.PublicIPID, PublicIP: stackitCluster.Status.Bastion.PublicIP, From 5a29116d978e4db1f1bd51ff80067c5c819aa711 Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:29:45 +0100 Subject: [PATCH 17/19] docs: add explanation about the current implementation --- docs/src/getting-started/cloud-resources.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/getting-started/cloud-resources.md b/docs/src/getting-started/cloud-resources.md index 597b182..b0c78e1 100644 --- a/docs/src/getting-started/cloud-resources.md +++ b/docs/src/getting-started/cloud-resources.md @@ -19,6 +19,8 @@ will have an attached tag indicating that it is managed by CAPS. Only empty networks can be deleted, so when you delete your CAPS cluster and all cluster nodes and load balancers are removed, the managed network will be deleted too. +> **Note:** Managed network creation is currently supported on the API and controller level. The out-of-the-box cluster templates (`templates/cluster-template*.yaml`) still require `STACKIT_NETWORK_ID` because the STACKIT Cloud Controller Manager (CCM) requires a pre-configured network ID for `Service` type `LoadBalancer`. If you deploy a cluster with a managed network, ensure your CCM configuration is supplied with the network ID from `.status.network.id` after cluster creation. + ### Bring-Your-Own (BYO) Network Choose or create a network in the same region as the workload cluster. The From 0c4ae2127ee94af9850e11cca82ddc1243f2b48c Mon Sep 17 00:00:00 2001 From: Fabian Schmitt <21021337+fabian-schmitt@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:11:53 +0100 Subject: [PATCH 18/19] refactor(controller): extract ensureNetwork helper methods --- controller/stackitcluster_infrastructure.go | 219 ++++++++++++-------- 1 file changed, 128 insertions(+), 91 deletions(-) diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index db4d051..19e9155 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -89,97 +89,9 @@ func (r *StackitClusterReconciler) reconcileNormal(ctx context.Context, clusterS } clusterScope.SetFailureDomains(failureDomainsFromZones(stackitCluster.Spec.Region, zones)) - var network *cloud.Network - if stackitCluster.Spec.Network.ID != "" { - var err error - network, err = cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) - if err != nil { - log.Error(err, "Failed to get network", - "networkID", stackitCluster.Spec.Network.ID, - "region", stackitCluster.Spec.Region, - ) - record.Warnf( - stackitCluster, "FailedGetNetwork", - "Failed to get network %s in region %s: %v", - stackitCluster.Spec.Network.ID, stackitCluster.Spec.Region, err, - ) - stackitCluster.Status.Ready = false - return util.CloudFailureResult( - &stackitCluster.Status.Conditions, - stackitCluster.Generation, - "NetworkNotFound", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterNetworkReadyCondition, - infrav1.ClusterReadyCondition, - ) - } - stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ - ID: network.ID, - Managed: false, - } - } else { - // Managed network mode: resolve or create dedicated network - var err error - if stackitCluster.Status.Network.ID != "" { - network, err = cloudClient.GetNetwork(ctx, stackitCluster.Status.Network.ID) - if err != nil && !cloud.IsNotFound(err) { - log.Error(err, "Failed to get managed network", - "networkID", stackitCluster.Status.Network.ID, - "region", stackitCluster.Spec.Region, - ) - record.Warnf( - stackitCluster, "FailedGetNetwork", - "Failed to get managed network %s in region %s: %v", - stackitCluster.Status.Network.ID, stackitCluster.Spec.Region, err, - ) - stackitCluster.Status.Ready = false - return util.CloudFailureResult( - &stackitCluster.Status.Conditions, - stackitCluster.Generation, - "NetworkError", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterNetworkReadyCondition, - infrav1.ClusterReadyCondition, - ) - } - } - - if network == nil { - networkInput := networkservice.Input(stackitCluster) - network, err = cloudClient.CreateNetwork(ctx, networkInput) - if err != nil { - log.Error(err, "Failed to create managed network", - "networkName", networkInput.Name, - "region", stackitCluster.Spec.Region, - ) - record.Warnf( - stackitCluster, "FailedCreateNetwork", - "Failed to create managed network %s in region %s: %v", - networkInput.Name, stackitCluster.Spec.Region, err, - ) - stackitCluster.Status.Ready = false - return util.CloudFailureResult( - &stackitCluster.Status.Conditions, - stackitCluster.Generation, - "NetworkCreateError", - err, - retryableErrorRequeueAfter, - false, - infrav1.ClusterNetworkReadyCondition, - infrav1.ClusterReadyCondition, - ) - } - record.Eventf(stackitCluster, "NetworkCreated", "Created managed network %s", network.ID) - } - - stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ - ID: network.ID, - Managed: true, - } + network, result, err := r.ensureNetwork(ctx, cloudClient, clusterScope) + if err != nil || !result.IsZero() || network == nil { + return result, err } clusterScope.SetConditions( metav1.ConditionTrue, @@ -431,6 +343,131 @@ func bootstrapTargetIP(network *cloud.Network) string { return "10.0.0.1" } +func (r *StackitClusterReconciler) ensureNetwork( + ctx context.Context, + cloudClient cloud.Client, + clusterScope *scope.ClusterScope, +) (*cloud.Network, ctrl.Result, error) { + if clusterScope.StackitCluster.Spec.Network.ID != "" { + return r.getUnmanagedNetwork(ctx, cloudClient, clusterScope) + } + return r.ensureManagedNetwork(ctx, cloudClient, clusterScope) +} + +func (r *StackitClusterReconciler) getUnmanagedNetwork( + ctx context.Context, + cloudClient cloud.Client, + clusterScope *scope.ClusterScope, +) (*cloud.Network, ctrl.Result, error) { + log := logf.FromContext(ctx) + stackitCluster := clusterScope.StackitCluster + + network, err := cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) + if err != nil { + log.Error(err, "Failed to get network", + "networkID", stackitCluster.Spec.Network.ID, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedGetNetwork", + "Failed to get network %s in region %s: %v", + stackitCluster.Spec.Network.ID, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + res, failureErr := util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkNotFound", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + return nil, res, failureErr + } + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ + ID: network.ID, + Managed: false, + } + + return network, ctrl.Result{}, nil +} + +func (r *StackitClusterReconciler) ensureManagedNetwork( + ctx context.Context, + cloudClient cloud.Client, + clusterScope *scope.ClusterScope, +) (*cloud.Network, ctrl.Result, error) { + log := logf.FromContext(ctx) + stackitCluster := clusterScope.StackitCluster + + var network *cloud.Network + var err error + if stackitCluster.Status.Network.ID != "" { + network, err = cloudClient.GetNetwork(ctx, stackitCluster.Status.Network.ID) + if err != nil && !cloud.IsNotFound(err) { + log.Error(err, "Failed to get managed network", + "networkID", stackitCluster.Status.Network.ID, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedGetNetwork", + "Failed to get managed network %s in region %s: %v", + stackitCluster.Status.Network.ID, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + res, failureErr := util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + return nil, res, failureErr + } + } + + if network == nil { + networkInput := networkservice.Input(stackitCluster) + network, err = cloudClient.CreateNetwork(ctx, networkInput) + if err != nil { + log.Error(err, "Failed to create managed network", + "networkName", networkInput.Name, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedCreateNetwork", + "Failed to create managed network %s in region %s: %v", + networkInput.Name, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + res, failureErr := util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkCreateError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + return nil, res, failureErr + } + record.Eventf(stackitCluster, "NetworkCreated", "Created managed network %s", network.ID) + } + + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ + ID: network.ID, + Managed: true, + } + + return network, ctrl.Result{}, nil +} + func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterScope *scope.ClusterScope) (ctrl.Result, error) { log := logf.FromContext(ctx) stackitCluster := clusterScope.StackitCluster From f7e6095e370efdc13289295d58a4886a0fd77052 Mon Sep 17 00:00:00 2001 From: Jan Larwig Date: Wed, 4 Nov 2026 10:38:26 +0100 Subject: [PATCH 19/19] feat: add a cluster-template for using the managed network flow Signed-off-by: Jan Larwig --- .../cluster-template-managed-network.yaml | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 templates/cluster-template-managed-network.yaml diff --git a/templates/cluster-template-managed-network.yaml b/templates/cluster-template-managed-network.yaml new file mode 100644 index 0000000..31b7224 --- /dev/null +++ b/templates/cluster-template-managed-network.yaml @@ -0,0 +1,255 @@ +apiVersion: cluster.x-k8s.io/v1beta2 +kind: Cluster +metadata: + name: ${CLUSTER_NAME} + namespace: ${NAMESPACE} + labels: + cluster-api-provider-stackit/cloud-provider-stackit: "true" +spec: + clusterNetwork: + pods: + cidrBlocks: + - 192.168.0.0/16 + services: + cidrBlocks: + - 10.128.0.0/12 + infrastructureRef: + apiGroup: infrastructure.cluster.x-k8s.io + kind: StackitCluster + name: ${CLUSTER_NAME} + controlPlaneRef: + apiGroup: controlplane.cluster.x-k8s.io + kind: KubeadmControlPlane + name: ${CLUSTER_NAME}-control-plane +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: StackitCluster +metadata: + name: ${CLUSTER_NAME} + namespace: ${NAMESPACE} +spec: + projectID: ${STACKIT_PROJECT_ID} + region: ${STACKIT_REGION} + credentialsSecretRef: + name: ${STACKIT_CREDENTIALS_SECRET_NAME} + namespace: ${NAMESPACE} + apiServerLoadBalancer: + enabled: true + bastion: + enabled: false +--- +apiVersion: controlplane.cluster.x-k8s.io/v1beta2 +kind: KubeadmControlPlane +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: ${NAMESPACE} +spec: + replicas: ${CONTROL_PLANE_MACHINE_COUNT} + version: ${KUBERNETES_VERSION} + machineTemplate: + spec: + deletion: + nodeDrainTimeoutSeconds: 900 + infrastructureRef: + apiGroup: infrastructure.cluster.x-k8s.io + kind: StackitMachineTemplate + name: ${CLUSTER_NAME}-control-plane + kubeadmConfigSpec: + preKubeadmCommands: + - | + set -eu + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y apt-transport-https ca-certificates conntrack curl gpg containerd + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://pkgs.k8s.io/core:/stable:/${KUBERNETES_APT_REPOSITORY_MINOR}/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg + chmod 0644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg + echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${KUBERNETES_APT_REPOSITORY_MINOR}/deb/ /' > /etc/apt/sources.list.d/kubernetes.list + apt-get update + apt-get install -y kubelet kubeadm kubectl + apt-mark hold kubelet kubeadm kubectl + mkdir -p /etc/containerd + containerd config default > /etc/containerd/config.toml + sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml + systemctl restart containerd + modprobe br_netfilter + printf 'net.bridge.bridge-nf-call-iptables=1\nnet.ipv4.ip_forward=1\n' > /etc/sysctl.d/99-kubernetes-cri.conf + sysctl --system + (journalctl -fu kubelet --no-pager > /dev/console 2>&1 &) + initConfiguration: + nodeRegistration: + name: "{{ ds.meta_data.local_hostname }}" + criSocket: unix:///var/run/containerd/containerd.sock + kubeletExtraArgs: + - name: cloud-provider + value: external + joinConfiguration: + nodeRegistration: + name: "{{ ds.meta_data.local_hostname }}" + criSocket: unix:///var/run/containerd/containerd.sock + kubeletExtraArgs: + - name: cloud-provider + value: external + clusterConfiguration: + controllerManager: + extraArgs: + - name: cloud-provider + value: external +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: StackitMachineTemplate +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: ${NAMESPACE} +spec: + template: + spec: + imageID: ${STACKIT_IMAGE_ID} + machineType: ${STACKIT_MACHINE_TYPE} + rootVolume: + sizeGiB: 50 + performanceClass: storage_premium_perf6 + deleteOnTermination: true +--- +apiVersion: cluster.x-k8s.io/v1beta2 +kind: MachineDeployment +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: ${NAMESPACE} +spec: + clusterName: ${CLUSTER_NAME} + replicas: ${WORKER_MACHINE_COUNT} + remediation: + maxInFlight: 2 + selector: + matchLabels: + cluster.x-k8s.io/cluster-name: ${CLUSTER_NAME} + cluster.x-k8s.io/deployment-name: ${CLUSTER_NAME}-md-0 + template: + metadata: + labels: + cluster.x-k8s.io/cluster-name: ${CLUSTER_NAME} + cluster.x-k8s.io/deployment-name: ${CLUSTER_NAME}-md-0 + spec: + clusterName: ${CLUSTER_NAME} + version: ${KUBERNETES_VERSION} + deletion: + nodeDrainTimeoutSeconds: 900 + bootstrap: + configRef: + apiGroup: bootstrap.cluster.x-k8s.io + kind: KubeadmConfigTemplate + name: ${CLUSTER_NAME}-md-0 + infrastructureRef: + apiGroup: infrastructure.cluster.x-k8s.io + kind: StackitMachineTemplate + name: ${CLUSTER_NAME}-md-0 +--- +apiVersion: cluster.x-k8s.io/v1beta2 +kind: MachineHealthCheck +metadata: + name: ${CLUSTER_NAME}-control-plane + namespace: ${NAMESPACE} +spec: + clusterName: ${CLUSTER_NAME} + selector: + matchLabels: + cluster.x-k8s.io/control-plane: "" + checks: + nodeStartupTimeoutSeconds: 600 + unhealthyNodeConditions: + - type: Ready + status: "False" + timeoutSeconds: 600 + - type: Ready + status: "Unknown" + timeoutSeconds: 600 +--- +apiVersion: cluster.x-k8s.io/v1beta2 +kind: MachineHealthCheck +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: ${NAMESPACE} +spec: + clusterName: ${CLUSTER_NAME} + selector: + matchLabels: + cluster.x-k8s.io/deployment-name: ${CLUSTER_NAME}-md-0 + checks: + nodeStartupTimeoutSeconds: 600 + unhealthyNodeConditions: + - type: Ready + status: "False" + timeoutSeconds: 600 + - type: Ready + status: "Unknown" + timeoutSeconds: 600 + remediation: + triggerIf: + unhealthyLessThanOrEqualTo: 40% +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: StackitMachineTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: ${NAMESPACE} +spec: + template: + spec: + imageID: ${STACKIT_IMAGE_ID} + machineType: ${STACKIT_MACHINE_TYPE} + rootVolume: + sizeGiB: 50 + performanceClass: storage_premium_perf6 + deleteOnTermination: true +--- +apiVersion: bootstrap.cluster.x-k8s.io/v1beta2 +kind: KubeadmConfigTemplate +metadata: + name: ${CLUSTER_NAME}-md-0 + namespace: ${NAMESPACE} +spec: + template: + spec: + preKubeadmCommands: + - | + set -eu + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y apt-transport-https ca-certificates conntrack curl gpg containerd + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://pkgs.k8s.io/core:/stable:/${KUBERNETES_APT_REPOSITORY_MINOR}/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg + chmod 0644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg + echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${KUBERNETES_APT_REPOSITORY_MINOR}/deb/ /' > /etc/apt/sources.list.d/kubernetes.list + apt-get update + apt-get install -y kubelet kubeadm kubectl + apt-mark hold kubelet kubeadm kubectl + mkdir -p /etc/containerd + containerd config default > /etc/containerd/config.toml + sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml + systemctl restart containerd + modprobe br_netfilter + printf 'net.bridge.bridge-nf-call-iptables=1\nnet.ipv4.ip_forward=1\n' > /etc/sysctl.d/99-kubernetes-cri.conf + sysctl --system + (journalctl -fu kubelet --no-pager > /dev/console 2>&1 &) + joinConfiguration: + nodeRegistration: + name: "{{ ds.meta_data.local_hostname }}" + criSocket: unix:///var/run/containerd/containerd.sock + kubeletExtraArgs: + - name: cloud-provider + value: external +--- +apiVersion: addons.cluster.x-k8s.io/v1beta2 +kind: ClusterResourceSet +metadata: + name: ${CLUSTER_NAME}-cloud-provider-stackit + namespace: ${NAMESPACE} +spec: + strategy: Reconcile + clusterSelector: + matchLabels: + cluster-api-provider-stackit/cloud-provider-stackit: "true" + resources: + - kind: Secret + name: ${CLUSTER_NAME}-cloud-provider-stackit