Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions api/v1alpha1/stackitcluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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"
Expand All @@ -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

Expand Down
9 changes: 4 additions & 5 deletions api/v1alpha1/stackitmachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions cloud/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "; "))
}
Expand Down
20 changes: 20 additions & 0 deletions cloud/cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
}
}
12 changes: 10 additions & 2 deletions cloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
114 changes: 109 additions & 5 deletions cloud/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -49,6 +49,8 @@ type Client struct {
FailNextDeleteLB error
FailNextSetTargets error
FailNextGetNetwork error
FailNextCreateNetwork error
FailNextDeleteNetwork error
FailNextEnsureBastion error
FailNextDeleteBastion error
FailNextEnsureNodeSSH error
Expand Down Expand Up @@ -100,18 +102,25 @@ 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{
servers: make(map[string]*serverEntry),
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
}
Expand Down Expand Up @@ -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, &copy)
}
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()
Expand Down
Loading
Loading