diff --git a/api/v1alpha1/stackitcluster_types.go b/api/v1alpha1/stackitcluster_types.go index 413452e..a8f8143 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 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"` // apiServerLoadBalancer configures the load balancer in front of the // Kubernetes API server. @@ -73,13 +73,13 @@ type StackitClusterSpec struct { AdditionalLabels map[string]string `json:"additionalLabels,omitempty"` } -// StackitClusterNetworkSpec references an existing STACKIT network. +// StackitClusterNetworkSpec references the network for a StackitCluster. type StackitClusterNetworkSpec struct { // id is the STACKIT network ID. - // +required - // +kubebuilder:validation:MinLength=1 + // Omitting the network ID provisions a dedicated managed STACKIT network for the cluster. // +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,16 @@ type StackitBastionStatus struct { CloudInitHash string `json:"cloudInitHash,omitempty"` } +// StackitNetworkStatus tracks the network used by the cluster (BYO or managed). +type StackitNetworkStatus struct { + // 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 + Managed bool `json:"managed,omitempty"` +} + // Condition types maintained by the StackitClusterReconciler. const ( ClusterReadyCondition = "Ready" @@ -234,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/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/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 a3ab870..d48ac99 100644 --- a/cloud/client.go +++ b/cloud/client.go @@ -23,6 +23,16 @@ 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) + + 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 + GetServer(ctx context.Context, id string) (*Server, error) FindServerByTags(ctx context.Context, tags map[string]string) (*Server, error) @@ -33,8 +43,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..39128f0 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,109 @@ 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) 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() + + 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 { + 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 + } + } + + 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..bf3a17c 100644 --- a/cloud/sdk_client.go +++ b/cloud/sdk_client.go @@ -234,6 +234,88 @@ 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 { + if existing, err := c.FindNetworkByTags(ctx, input.Tags); err == nil { + return existing, nil + } else if !IsNotFound(err) { + return nil, err + } + } + + 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..b38f621 100644 --- a/cloud/sdk_client_test.go +++ b/cloud/sdk_client_test.go @@ -728,3 +728,95 @@ 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/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 new file mode 100644 index 0000000..50b5633 --- /dev/null +++ b/cloud/services/network/network.go @@ -0,0 +1,64 @@ +/* +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 ( + "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" +) + +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), + } +} + +// 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 new file mode 100644 index 0000000..015b9f5 --- /dev/null +++ b/cloud/services/network/network_test.go @@ -0,0 +1,134 @@ +/* +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 ( + "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" +) + +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") + } +} + +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/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..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 @@ -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. + omitting 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 managed STACKIT network for the cluster. 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,17 @@ 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: + description: managed indicates whether the network is managed + (created and owned) by CAPSTK. + 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..33e71f9 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. + omitting 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 managed STACKIT network for the cluster. 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_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..a92ee67 100644 --- a/controller/stackitcluster_controller_test.go +++ b/controller/stackitcluster_controller_test.go @@ -97,6 +97,109 @@ 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.CredentialsSecretRef.Name = credentials + 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("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() { diff --git a/controller/stackitcluster_infrastructure.go b/controller/stackitcluster_infrastructure.go index f4c4f8e..79848f5 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,28 +89,97 @@ 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, - ) + var network *cloud.Network + if stackitCluster.Spec.Network.ID != "" { + var err error + network, err = cloudClient.GetNetwork(ctx, stackitCluster.Spec.Network.ID) + if err != nil { + log.Error(err, "Failed to get network", + "networkID", stackitCluster.Spec.Network.ID, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedGetNetwork", + "Failed to get network %s in region %s: %v", + stackitCluster.Spec.Network.ID, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkNotFound", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ + ID: network.ID, + Managed: false, + } + } else { + // Managed network mode: resolve or create dedicated network + var err error + if stackitCluster.Status.Network.ID != "" { + network, err = cloudClient.GetNetwork(ctx, stackitCluster.Status.Network.ID) + if err != nil && !cloud.IsNotFound(err) { + log.Error(err, "Failed to get managed network", + "networkID", stackitCluster.Status.Network.ID, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedGetNetwork", + "Failed to get managed network %s in region %s: %v", + stackitCluster.Status.Network.ID, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + } + + if network == nil { + networkInput := networkservice.Input(stackitCluster) + network, err = cloudClient.CreateNetwork(ctx, networkInput) + if err != nil { + log.Error(err, "Failed to create managed network", + "networkName", networkInput.Name, + "region", stackitCluster.Spec.Region, + ) + record.Warnf( + stackitCluster, "FailedCreateNetwork", + "Failed to create managed network %s in region %s: %v", + networkInput.Name, stackitCluster.Spec.Region, err, + ) + stackitCluster.Status.Ready = false + return util.CloudFailureResult( + &stackitCluster.Status.Conditions, + stackitCluster.Generation, + "NetworkCreateError", + err, + retryableErrorRequeueAfter, + false, + infrav1.ClusterNetworkReadyCondition, + infrav1.ClusterReadyCondition, + ) + } + record.Eventf(stackitCluster, "NetworkCreated", "Created managed network %s", network.ID) + } + + stackitCluster.Status.Network = infrav1.StackitNetworkStatus{ + ID: network.ID, + Managed: true, + } } clusterScope.SetConditions( metav1.ConditionTrue, @@ -150,7 +220,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", @@ -438,7 +508,7 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS "Failed to delete bastion node SSH access: %v", err) return ctrl.Result{}, err } - if err := cloudClient.DeleteBastion(ctx, bastionservice.Input(stackitCluster, 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, @@ -453,6 +523,42 @@ func (r *StackitClusterReconciler) reconcileDelete(ctx context.Context, clusterS clusterScope.ClearBastionStatus() record.Event(stackitCluster, "BastionDeleted", "Deleted bastion") } + 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", 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", managedNetworkID, + ) + record.Warnf(stackitCluster, "FailedDeleteNetwork", + "Failed to delete managed network %s: %v", managedNetworkID, err) + return ctrl.Result{}, err + } + record.Eventf(stackitCluster, "NetworkDeleted", "Deleted managed network %s", managedNetworkID) + 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..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( @@ -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/docs/src/getting-started/cloud-resources.md b/docs/src/getting-started/cloud-resources.md index 100f569..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,9 +30,7 @@ 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. +### How to create a STACKIT Network manually Create a network with the STACKIT CLI: 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) + } +} 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() {