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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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.