diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 413452e..287074e 100644 --- a/api/v1alpha1/stackitcluster_types.go +++ b/api/v1alpha1/stackitcluster_types.go @@ -47,10 +47,10 @@ 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. - // +required - Network StackitClusterNetworkSpec `json:"network"` + // network configures the STACKIT network the cluster will use. + // omiting the ID will create a managed dedicated network + // +optional + Network StackitClusterNetworkSpec `json:"network,omitempty"` // apiServerLoadBalancer configures the load balancer in front of the // Kubernetes API server. @@ -76,10 +76,10 @@ type StackitClusterSpec struct { // StackitClusterNetworkSpec references an existing STACKIT network. type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. - // +required - // +kubebuilder:validation:MinLength=1 + // 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}$` - ID string `json:"id"` + // +optional + ID string `json:"id,omitempty"` } // StackitAPIServerLoadBalancerSpec configures an optional load balancer for @@ -160,6 +160,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 +221,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" 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/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/fake/client.go b/cloud/fake/client.go index 06465cc..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,14 +252,66 @@ 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, 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, 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) { c.mu.Lock() defer c.mu.Unlock() diff --git a/cloud/sdk_client.go b/cloud/sdk_client.go index 433e22f..806775b 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -234,6 +234,64 @@ func (c *SDKClient) GetNetwork(ctx context.Context, id string) (*Network, error) }, 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 + } + } + } + } + + 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 { + 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) { if input.Name == "" || input.NetworkID == "" || input.ImageID == "" || input.MachineType == "" || input.SSHKeyName == "" { 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) + } +} + 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 ac12a64..16de6b8 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclusters.yaml @@ -200,16 +200,15 @@ 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. - minLength: 1 + 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 pattern: ^[0-9a-fA-F-]{36}$ type: string - required: - - id type: object projectID: description: projectID is the STACKIT project that owns the cluster @@ -225,7 +224,6 @@ spec: type: string required: - credentialsSecretRef - - network - projectID - region type: object @@ -396,6 +394,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..84a0fff 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_stackitclustertemplates.yaml @@ -236,16 +236,15 @@ 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. - minLength: 1 + 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 pattern: ^[0-9a-fA-F-]{36}$ type: string - required: - - id type: object projectID: description: projectID is the STACKIT project that owns the @@ -261,7 +260,6 @@ spec: type: string required: - credentialsSecretRef - - network - projectID - region type: object 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 diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index f4c4f8e..e3e5fbc 100644 --- a/controller/stackitcluster_infrastructure.go +++ b/controller/stackitcluster_infrastructure.go @@ -89,27 +89,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 != "" { + 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 +458,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 } 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: 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"))...) 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() {