From d849c35d7848772acd0fb374e1a2521bd6ae986f Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Tue, 30 Jun 2026 13:29:23 +0200 Subject: [PATCH 1/7] secondary networks for servers Signed-off-by: Lukas Hoehl --- docs/machine-class.md | 1 + pkg/client/mock/client.go | 23 ++++++++++++++++------ pkg/client/sdk.go | 4 ++++ pkg/client/stackit.go | 2 ++ pkg/provider/apis/provider_spec.go | 3 +++ pkg/provider/apis/validation/validation.go | 6 ++++++ pkg/provider/create.go | 21 ++++++++++++++++++++ 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/machine-class.md b/docs/machine-class.md index 6be679ef..2a56f0f0 100644 --- a/docs/machine-class.md +++ b/docs/machine-class.md @@ -40,6 +40,7 @@ Exactly one of the following must be set: - `networkId` (string): UUID of the network to attach. - `nicIds` ([]string): UUIDs of pre-created NICs. +- `secondaryNetworkIds` ([]string): UUID of additional networks to attach. ## BootVolumeSpec diff --git a/pkg/client/mock/client.go b/pkg/client/mock/client.go index 43a7fbf2..6112663c 100644 --- a/pkg/client/mock/client.go +++ b/pkg/client/mock/client.go @@ -8,15 +8,26 @@ import ( api "github.com/stackitcloud/machine-controller-manager-provider-stackit/pkg/provider/apis" ) +var _ client.StackitClient = (*StackitClient)(nil) + // StackitClient is a mock implementation of StackitClient for testing // Note: Single-tenant design - each client is bound to one set of credentials type StackitClient struct { - CreateServerFunc func(ctx context.Context, projectID, region string, req *client.CreateServerRequest) (*client.Server, error) - GetServerFunc func(ctx context.Context, projectID, region, serverID string) (*client.Server, error) - DeleteServerFunc func(ctx context.Context, projectID, region, serverID string) error - ListServersFunc func(ctx context.Context, projectID, region string, labelSelector map[string]string) ([]*client.Server, error) - GetNICsFunc func(ctx context.Context, projectID, region, serverID string) ([]*client.NIC, error) - UpdateNICFunc func(ctx context.Context, projectID, region, networkID, nicID string, allowedAddresses []string) (*client.NIC, error) + CreateServerFunc func(ctx context.Context, projectID, region string, req *client.CreateServerRequest) (*client.Server, error) + GetServerFunc func(ctx context.Context, projectID, region, serverID string) (*client.Server, error) + DeleteServerFunc func(ctx context.Context, projectID, region, serverID string) error + ListServersFunc func(ctx context.Context, projectID, region string, labelSelector map[string]string) ([]*client.Server, error) + GetNICsFunc func(ctx context.Context, projectID, region, serverID string) ([]*client.NIC, error) + UpdateNICFunc func(ctx context.Context, projectID, region, networkID, nicID string, allowedAddresses []string) (*client.NIC, error) + AttachNetworkToServerFunc func(ctx context.Context, projectID, region, networkID, serverID string) error +} + +// AttachServerToNetwork implements [client.StackitClient]. +func (m *StackitClient) AttachServerToNetwork(ctx context.Context, projectID, region, networkID, serverID string) error { + if m.AttachNetworkToServerFunc != nil { + return m.AttachNetworkToServerFunc(ctx, projectID, region, networkID, serverID) + } + return nil } func (m *StackitClient) CreateServer(ctx context.Context, projectID, region string, req *client.CreateServerRequest) (*client.Server, error) { diff --git a/pkg/client/sdk.go b/pkg/client/sdk.go index a306ac85..f4f7e57c 100644 --- a/pkg/client/sdk.go +++ b/pkg/client/sdk.go @@ -318,6 +318,10 @@ func (c *SdkStackitClient) UpdateNIC(ctx context.Context, projectID, region, net return convertSDKNICtoNIC(sdkNic), nil } +func (c *SdkStackitClient) AttachServerToNetwork(ctx context.Context, projectID, region, networkID, serverID string) error { + return c.iaasClient.DefaultAPI.AddNetworkToServer(ctx, projectID, region, serverID, networkID).Execute() +} + // Helper functions func convertSDKNICtoNIC(nic *iaas.NIC) *NIC { diff --git a/pkg/client/stackit.go b/pkg/client/stackit.go index 41b4810a..aa37eb4e 100644 --- a/pkg/client/stackit.go +++ b/pkg/client/stackit.go @@ -27,6 +27,8 @@ type StackitClient interface { GetNICsForServer(ctx context.Context, projectID, region, serverID string) ([]*NIC, error) // UpdateNIC updates a network interface UpdateNIC(ctx context.Context, projectID, region, networkID, nicID string, allowedAddresses []string) (*NIC, error) + // AttachServerToNetwork attaches a server to a network + AttachServerToNetwork(ctx context.Context, projectID, region, networkID, serverID string) error } // CreateServerRequest represents the request to create a server diff --git a/pkg/provider/apis/provider_spec.go b/pkg/provider/apis/provider_spec.go index 4a227696..f16aa534 100644 --- a/pkg/provider/apis/provider_spec.go +++ b/pkg/provider/apis/provider_spec.go @@ -103,6 +103,9 @@ type NetworkingSpec struct { // Advanced variant: Allows fine-grained control over NICs, IPs, and security groups // Mutually exclusive with NetworkID NICIDs []string `json:"nicIds,omitempty"` + + // SecondaryNetworkIDs can be used to attach additional networks to the server + SecondaryNetworkIDs []string `json:"secondaryNetworkIds,omitempty"` } // BootVolumeSpec defines the boot disk configuration for a server diff --git a/pkg/provider/apis/validation/validation.go b/pkg/provider/apis/validation/validation.go index 517998c8..227cae63 100644 --- a/pkg/provider/apis/validation/validation.go +++ b/pkg/provider/apis/validation/validation.go @@ -246,6 +246,12 @@ func validateNetworking(networking *api.NetworkingSpec) []error { } } + for i, secondaryNet := range networking.SecondaryNetworkIDs { + if !isValidUUID(secondaryNet) { + errors = append(errors, fmt.Errorf("providerSpec.networking.secondaryNetworkIds[%d] must be a valid UUID", i)) + } + } + return errors } diff --git a/pkg/provider/create.go b/pkg/provider/create.go index 930600b5..c349b882 100644 --- a/pkg/provider/create.go +++ b/pkg/provider/create.go @@ -100,6 +100,12 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR return nil, status.Error(codes.Unavailable, fmt.Sprintf("failed to patch NICs for server: %v", err)) } + if providerSpec.Networking != nil && len(providerSpec.Networking.SecondaryNetworkIDs) > 0 { + if err := p.ensureAdditionalNetworks(ctx, projectID, providerSpec.Region, server.ID, providerSpec.Networking, nics); err != nil { + return nil, status.Error(codes.Unavailable, fmt.Sprintf("failed to ensure additional networks for server: %v", err)) + } + } + // Generate ProviderID in format: stackit:/// providerID := fmt.Sprintf("%s://%s/%s", StackitProviderName, projectID, server.ID) klog.V(2).Infof("Successfully created server %q with ID %q for machine %q", server.Name, server.ID, req.Machine.Name) @@ -111,6 +117,21 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR }, nil } +func (p *Provider) ensureAdditionalNetworks(ctx context.Context, projectID, region, serverID string, networkingSpec *api.NetworkingSpec, nics []*client.NIC) error { + for _, networkID := range networkingSpec.SecondaryNetworkIDs { + exists := slices.ContainsFunc(nics, func(nic *client.NIC) bool { + return nic.NetworkID == networkID + }) + if exists { + continue + } + if err := p.client.AttachServerToNetwork(ctx, projectID, region, networkID, serverID); err != nil { + return fmt.Errorf("attaching server %s to network %s: %w", serverID, networkID, err) + } + } + return nil +} + // nolint: gocyclo // this function is already pretty simple func (p *Provider) createServerRequest(req *driver.CreateMachineRequest, providerSpec *api.ProviderSpec) *client.CreateServerRequest { // Build labels: merge ProviderSpec labels with MCM-specific labels From 1a8da19a39e0eef691ee17254645cea385ba4c48 Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Tue, 7 Jul 2026 13:33:49 +0200 Subject: [PATCH 2/7] tests Signed-off-by: Lukas Hoehl --- pkg/provider/create_networking_test.go | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/pkg/provider/create_networking_test.go b/pkg/provider/create_networking_test.go index 87be95b8..73165cb8 100644 --- a/pkg/provider/create_networking_test.go +++ b/pkg/provider/create_networking_test.go @@ -465,3 +465,61 @@ var _ = Describe("CreateMachine - Networking", func() { }) }) }) + +var _ = Describe("#ensureAdditionalNetworks", func() { + var ( + provider *Provider + mockClient *mock.StackitClient + projectID = "123" + region = "eu01" + serverID = "server" + spec = api.NetworkingSpec{ + SecondaryNetworkIDs: []string{"my-secondary-network"}, + } + ) + + BeforeEach(func() { + mockClient = &mock.StackitClient{} + provider = &Provider{ + client: mockClient, + } + }) + + It("should attach the server to network if no NIC of network is found", func(ctx context.Context) { + var called bool + mockClient.AttachNetworkToServerFunc = func(_ context.Context, _, _, networkID, _ string) error { + called = true + Expect(networkID).To(Equal("my-secondary-network")) + return nil + } + + existingNics := []*client.NIC{ + { + NetworkID: "not-my-network", + }, + } + + Expect(provider.ensureAdditionalNetworks(ctx, projectID, region, serverID, &spec, existingNics)).To(Succeed()) + Expect(called).To(BeTrue(), "AttachNetworkToServer function called") + }) + + It("should not attach the server to network if NIC of network is already present", func(ctx context.Context) { + var called bool + mockClient.AttachNetworkToServerFunc = func(_ context.Context, _, _, _, _ string) error { + called = true + return nil + } + + existingNics := []*client.NIC{ + { + NetworkID: "my-secondary-network", + }, + { + NetworkID: "not-my-network", + }, + } + + Expect(provider.ensureAdditionalNetworks(ctx, projectID, region, serverID, &spec, existingNics)).To(Succeed()) + Expect(called).To(BeFalse(), "AttachNetworkToServer function called") + }) +}) From 2685e2ddd93b41f0ca233bd511fe324929d9a6e7 Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Mon, 3 Aug 2026 13:05:17 +0200 Subject: [PATCH 3/7] docs: rephrase networkingSpec Signed-off-by: Lukas Hoehl --- docs/machine-class.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/machine-class.md b/docs/machine-class.md index 2a56f0f0..63512627 100644 --- a/docs/machine-class.md +++ b/docs/machine-class.md @@ -36,7 +36,7 @@ A MachineClass defines how STACKIT servers should be created. The ProviderSpec i ## NetworkingSpec -Exactly one of the following must be set: +`networkId` / `nicIds` is mutually exclusive and required while `secondaryNetworkIds` is additional. - `networkId` (string): UUID of the network to attach. - `nicIds` ([]string): UUIDs of pre-created NICs. From fa72f5a29a66bb635a3da8ad28266f00eb2ca025 Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Mon, 3 Aug 2026 13:05:30 +0200 Subject: [PATCH 4/7] validate secondary network != networkId Signed-off-by: Lukas Hoehl --- pkg/provider/apis/validation/validation.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/provider/apis/validation/validation.go b/pkg/provider/apis/validation/validation.go index 227cae63..0f528583 100644 --- a/pkg/provider/apis/validation/validation.go +++ b/pkg/provider/apis/validation/validation.go @@ -250,6 +250,9 @@ func validateNetworking(networking *api.NetworkingSpec) []error { if !isValidUUID(secondaryNet) { errors = append(errors, fmt.Errorf("providerSpec.networking.secondaryNetworkIds[%d] must be a valid UUID", i)) } + if secondaryNet == networking.NetworkID { + errors = append(errors, fmt.Errorf("providerSpec.networking.secondaryNetworkIds[%d] has the same ID as providerSpec.networking.networkId", i)) + } } return errors From 941a1a14cb9668dc113b3fc922b379e564964483 Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Mon, 10 Aug 2026 14:05:52 +0200 Subject: [PATCH 5/7] validate network exists Signed-off-by: Lukas Hoehl --- pkg/client/sdk.go | 12 +++++++++++- pkg/provider/create.go | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/client/sdk.go b/pkg/client/sdk.go index f4f7e57c..6c4e1075 100644 --- a/pkg/client/sdk.go +++ b/pkg/client/sdk.go @@ -37,6 +37,8 @@ func NewStackitClient(serviceAccountKey string) (*SdkStackitClient, error) { var ( // ErrServerNotFound indicates the server was not found (404) ErrServerNotFound = errors.New("server not found") + // ErrNetworkNotFound indicates the network was not found (404) + ErrNetworkNotFound = errors.New("network not found") ) // createIAASClient creates a new STACKIT SDK IAAS API client @@ -319,7 +321,15 @@ func (c *SdkStackitClient) UpdateNIC(ctx context.Context, projectID, region, net } func (c *SdkStackitClient) AttachServerToNetwork(ctx context.Context, projectID, region, networkID, serverID string) error { - return c.iaasClient.DefaultAPI.AddNetworkToServer(ctx, projectID, region, serverID, networkID).Execute() + if err := c.iaasClient.DefaultAPI.AddNetworkToServer(ctx, projectID, region, serverID, networkID).Execute(); err != nil { + // Check if error is 404 Not Found + if isNotFoundError(err) { + return fmt.Errorf("%w: %v", ErrNetworkNotFound, err) + } + return err + } + return nil + } // Helper functions diff --git a/pkg/provider/create.go b/pkg/provider/create.go index c349b882..6749f99b 100644 --- a/pkg/provider/create.go +++ b/pkg/provider/create.go @@ -3,6 +3,7 @@ package provider import ( "context" "encoding/base64" + "errors" "fmt" "maps" "slices" @@ -102,7 +103,11 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR if providerSpec.Networking != nil && len(providerSpec.Networking.SecondaryNetworkIDs) > 0 { if err := p.ensureAdditionalNetworks(ctx, projectID, providerSpec.Region, server.ID, providerSpec.Networking, nics); err != nil { - return nil, status.Error(codes.Unavailable, fmt.Sprintf("failed to ensure additional networks for server: %v", err)) + code := codes.Unavailable + if errors.Is(err, client.ErrNetworkNotFound) { + code = codes.FailedPrecondition + } + return nil, status.Error(code, fmt.Sprintf("failed to ensure additional networks for server: %v", err)) } } @@ -118,6 +123,7 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR } func (p *Provider) ensureAdditionalNetworks(ctx context.Context, projectID, region, serverID string, networkingSpec *api.NetworkingSpec, nics []*client.NIC) error { + var errs error for _, networkID := range networkingSpec.SecondaryNetworkIDs { exists := slices.ContainsFunc(nics, func(nic *client.NIC) bool { return nic.NetworkID == networkID @@ -126,10 +132,10 @@ func (p *Provider) ensureAdditionalNetworks(ctx context.Context, projectID, regi continue } if err := p.client.AttachServerToNetwork(ctx, projectID, region, networkID, serverID); err != nil { - return fmt.Errorf("attaching server %s to network %s: %w", serverID, networkID, err) + errs = errors.Join(errs, fmt.Errorf("attaching server %s to network %s: %w", serverID, networkID, err)) } } - return nil + return errs } // nolint: gocyclo // this function is already pretty simple From 31579e4f21fb084f58525b3021fae4a00578dd23 Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Mon, 10 Aug 2026 14:11:22 +0200 Subject: [PATCH 6/7] comments on NIC addresses Signed-off-by: Lukas Hoehl --- pkg/provider/create.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/provider/create.go b/pkg/provider/create.go index 6749f99b..28329344 100644 --- a/pkg/provider/create.go +++ b/pkg/provider/create.go @@ -118,7 +118,10 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR return &driver.CreateMachineResponse{ ProviderID: providerID, NodeName: req.Machine.Name, - Addresses: nicAddresses(nics), + // We exclude the IPs of NICs from secondary networks to ensure, + // if MCM runs without a target cluster, + // consumers of Machine.Status.Addresses always use the IP from the default network. + Addresses: nicAddresses(nics), }, nil } @@ -282,6 +285,8 @@ func (p *Provider) getServerByName(ctx context.Context, projectID, region, serve return nil, nil } +// patchNetworkInterfaces updates the primary network interfaces of the server that belong to the primary network with AllowedAddresses. +// NICs of secondary networks are excluded. func (p *Provider) patchNetworkInterfaces(ctx context.Context, projectID, serverID string, providerSpec *api.ProviderSpec) ([]*client.NIC, error) { nics, err := p.client.GetNICsForServer(ctx, projectID, providerSpec.Region, serverID) if err != nil { From 1cb370afdaa061336bb816b4b8d9e2e71802b009 Mon Sep 17 00:00:00 2001 From: Lukas Hoehl Date: Mon, 10 Aug 2026 15:56:01 +0200 Subject: [PATCH 7/7] remove complexity of create function Signed-off-by: Lukas Hoehl --- pkg/client/sdk.go | 1 - pkg/provider/create.go | 36 ++++++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/pkg/client/sdk.go b/pkg/client/sdk.go index 6c4e1075..1b0091fb 100644 --- a/pkg/client/sdk.go +++ b/pkg/client/sdk.go @@ -329,7 +329,6 @@ func (c *SdkStackitClient) AttachServerToNetwork(ctx context.Context, projectID, return err } return nil - } // Helper functions diff --git a/pkg/provider/create.go b/pkg/provider/create.go index 28329344..fadf9d71 100644 --- a/pkg/provider/create.go +++ b/pkg/provider/create.go @@ -95,20 +95,9 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR return nil, status.Error(codes.DeadlineExceeded, fmt.Sprintf("failed waiting for server to be ACTIVE: %v", err)) } - nics, err := p.patchNetworkInterfaces(ctx, projectID, server.ID, providerSpec) + addrs, err := p.setupNetworking(ctx, projectID, providerSpec, server) if err != nil { - klog.Errorf("Failed to patch NICs for server %q: %v", req.Machine.Name, err) - return nil, status.Error(codes.Unavailable, fmt.Sprintf("failed to patch NICs for server: %v", err)) - } - - if providerSpec.Networking != nil && len(providerSpec.Networking.SecondaryNetworkIDs) > 0 { - if err := p.ensureAdditionalNetworks(ctx, projectID, providerSpec.Region, server.ID, providerSpec.Networking, nics); err != nil { - code := codes.Unavailable - if errors.Is(err, client.ErrNetworkNotFound) { - code = codes.FailedPrecondition - } - return nil, status.Error(code, fmt.Sprintf("failed to ensure additional networks for server: %v", err)) - } + return nil, fmt.Errorf("setup networking: %w", err) } // Generate ProviderID in format: stackit:/// @@ -121,10 +110,29 @@ func (p *Provider) CreateMachine(ctx context.Context, req *driver.CreateMachineR // We exclude the IPs of NICs from secondary networks to ensure, // if MCM runs without a target cluster, // consumers of Machine.Status.Addresses always use the IP from the default network. - Addresses: nicAddresses(nics), + Addresses: addrs, }, nil } +func (p *Provider) setupNetworking(ctx context.Context, projectID string, providerSpec *api.ProviderSpec, server *client.Server) ([]corev1.NodeAddress, error) { + nics, err := p.patchNetworkInterfaces(ctx, projectID, server.ID, providerSpec) + if err != nil { + klog.Errorf("Failed to patch NICs for server %q: %v", server.Name, err) + return nil, status.Error(codes.Unavailable, fmt.Sprintf("failed to patch NICs for server: %v", err)) + } + + if providerSpec.Networking != nil && len(providerSpec.Networking.SecondaryNetworkIDs) > 0 { + if err := p.ensureAdditionalNetworks(ctx, projectID, providerSpec.Region, server.ID, providerSpec.Networking, nics); err != nil { + code := codes.Unavailable + if errors.Is(err, client.ErrNetworkNotFound) { + code = codes.FailedPrecondition + } + return nil, status.Error(code, fmt.Sprintf("failed to ensure additional networks for server: %v", err)) + } + } + return nicAddresses(nics), nil +} + func (p *Provider) ensureAdditionalNetworks(ctx context.Context, projectID, region, serverID string, networkingSpec *api.NetworkingSpec, nics []*client.NIC) error { var errs error for _, networkID := range networkingSpec.SecondaryNetworkIDs {