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
6 changes: 3 additions & 3 deletions cloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ type Client interface {

ListAPIServerLoadBalancersByTags(ctx context.Context, tags map[string]string) ([]*LoadBalancer, error)

EnsureAPIServerLoadBalancerTarget(ctx context.Context, input LoadBalancerTargetInput) error

DeleteAPIServerLoadBalancerTarget(ctx context.Context, input LoadBalancerTargetInput) error
// SetAPIServerLoadBalancerTargets replaces the contents of the API-server
// target pool. STACKIT rejects an empty pool.
SetAPIServerLoadBalancerTargets(ctx context.Context, loadBalancerID string, port int32, targets []LoadBalancerTargetInput) error

DeleteAPIServerLoadBalancer(ctx context.Context, id string) error
}
Expand Down
57 changes: 34 additions & 23 deletions cloud/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ type Client struct {
FailNextFindServer error
FailNextEnsureLB error
FailNextDeleteLB error
FailNextEnsureTarget error
FailNextDeleteTarget error
FailNextSetTargets error
FailNextGetNetwork error
FailNextEnsureBastion error
FailNextDeleteBastion error
Expand Down Expand Up @@ -483,36 +482,37 @@ func (c *Client) ListAPIServerLoadBalancersByTags(
return loadBalancers, nil
}

func (c *Client) EnsureAPIServerLoadBalancerTarget(_ context.Context, input cloud.LoadBalancerTargetInput) error {
func (c *Client) SetAPIServerLoadBalancerTargets(
_ context.Context,
loadBalancerID string,
port int32,
targets []cloud.LoadBalancerTargetInput,
) error {
c.mu.Lock()
defer c.mu.Unlock()

if err := consume(&c.FailNextEnsureTarget); err != nil {
if err := consume(&c.FailNextSetTargets); err != nil {
return err
}
entry, ok := c.loadBalancers[input.LoadBalancerID]
if !ok {
return fmt.Errorf("load balancer %q: %w", input.LoadBalancerID, cloud.ErrNotFound)
}
if entry.targets[bootstrapTargetName] == bootstrapTargetIP {
delete(entry.targets, bootstrapTargetName)
if loadBalancerID == "" || port <= 0 {
return fmt.Errorf("load balancer ID and target port are required: %w", cloud.ErrInvalidInput)
}
entry.targets[input.Name] = input.IP
return nil
}

func (c *Client) DeleteAPIServerLoadBalancerTarget(_ context.Context, input cloud.LoadBalancerTargetInput) error {
c.mu.Lock()
defer c.mu.Unlock()

if err := consume(&c.FailNextDeleteTarget); err != nil {
return err
if len(targets) == 0 {
return fmt.Errorf("at least one target is required: %w", cloud.ErrInvalidInput)
}
entry, ok := c.loadBalancers[input.LoadBalancerID]
entry, ok := c.loadBalancers[loadBalancerID]
if !ok {
return fmt.Errorf("load balancer %q: %w", input.LoadBalancerID, cloud.ErrNotFound)
return fmt.Errorf("load balancer %q: %w", loadBalancerID, cloud.ErrNotFound)
}
delete(entry.targets, input.Name)
replaced := make(map[string]string, len(targets))
for _, target := range targets {
// The real API rejects these; accepting them would hide a regression.
if target.Name == "" || target.IP == "" {
return fmt.Errorf("target name and target IP are required: %w", cloud.ErrInvalidInput)
}
replaced[target.Name] = target.IP
}
entry.targets = replaced
return nil
}

Expand Down Expand Up @@ -604,6 +604,17 @@ func (c *Client) LoadBalancerTargetCount(id string) int {
return len(entry.targets)
}

// LoadBalancerTargetIPs returns one target pool as name to IP (test helper).
func (c *Client) LoadBalancerTargetIPs(id string) map[string]string {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.loadBalancers[id]
if !ok {
return nil
}
return copyTags(entry.targets)
}

func mapContains(haystack, needle map[string]string) bool {
for k, v := range needle {
if haystack[k] != v {
Expand Down
93 changes: 39 additions & 54 deletions cloud/sdk_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"fmt"
"net/http"
"os"
"slices"
"strings"

"github.com/stackitcloud/stackit-sdk-go/core/config"
Expand Down Expand Up @@ -568,11 +569,31 @@ func (c *SDKClient) ListAPIServerLoadBalancersByTags(
return matched, nil
}

func (c *SDKClient) EnsureAPIServerLoadBalancerTarget(ctx context.Context, input LoadBalancerTargetInput) error {
if input.LoadBalancerID == "" || input.Name == "" || input.IP == "" {
return fmt.Errorf("%w: load balancer ID, target name, and target IP are required", ErrInvalidInput)
func (c *SDKClient) SetAPIServerLoadBalancerTargets(
ctx context.Context,
loadBalancerID string,
port int32,
targets []LoadBalancerTargetInput,
) error {
if loadBalancerID == "" || port <= 0 {
return fmt.Errorf("%w: load balancer ID and target port are required", ErrInvalidInput)
}
// STACKIT NLB target pools must contain at least one target.
if len(targets) == 0 {
return fmt.Errorf("%w: at least one target is required", ErrInvalidInput)
}
desired := make([]lb.Target, 0, len(targets))
for _, targetInput := range targets {
if targetInput.Name == "" || targetInput.IP == "" {
return fmt.Errorf("%w: target name and target IP are required", ErrInvalidInput)
}
target := lb.NewTarget()
target.SetDisplayName(targetInput.Name)
target.SetIp(targetInput.IP)
desired = append(desired, *target)
}
loadBalancer, err := c.lbClient.DefaultAPI.GetLoadBalancer(ctx, c.projectID, c.region, input.LoadBalancerID).Execute()

loadBalancer, err := c.lbClient.DefaultAPI.GetLoadBalancer(ctx, c.projectID, c.region, loadBalancerID).Execute()
if err != nil {
return classifySDKError("get load balancer", err)
}
Expand All @@ -581,68 +602,32 @@ func (c *SDKClient) EnsureAPIServerLoadBalancerTarget(ctx context.Context, input
return fmt.Errorf(
"%w: load balancer %q has no %q target pool",
ErrNotFound,
input.LoadBalancerID,
loadBalancerID,
apiserverTargetPoolName,
)
}

targets := withoutBootstrapTarget(targetPool.GetTargets())
for i := range targets {
if targets[i].GetDisplayName() == input.Name || targets[i].GetIp() == input.IP {
targets[i].SetDisplayName(input.Name)
targets[i].SetIp(input.IP)
return c.updateAPIServerTargetPool(ctx, input.LoadBalancerID, targetPool, targets, input.Port)
}
// Control plane status updates are frequent, and each one reaches this path.
if targetPool.GetTargetPort() == port && sameTargets(targetPool.GetTargets(), desired) {
Comment on lines +610 to +611

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this comment supposed to tell me? so yes this happens frequently but why do i need to know this?

return nil
}

target := lb.NewTarget()
target.SetDisplayName(input.Name)
target.SetIp(input.IP)
targets = append(targets, *target)
return c.updateAPIServerTargetPool(ctx, input.LoadBalancerID, targetPool, targets, input.Port)
return c.updateAPIServerTargetPool(ctx, loadBalancerID, targetPool, desired, port)
}

func withoutBootstrapTarget(targets []lb.Target) []lb.Target {
out := targets[:0]
for _, target := range targets {
if target.GetDisplayName() == bootstrapTargetName {
continue
}
out = append(out, target)
func sameTargets(current, desired []lb.Target) bool {
if len(current) != len(desired) {
return false
}
return out
return slices.Equal(sortedTargetKeys(current), sortedTargetKeys(desired))
}

func (c *SDKClient) DeleteAPIServerLoadBalancerTarget(ctx context.Context, input LoadBalancerTargetInput) error {
if input.LoadBalancerID == "" || input.Name == "" {
return fmt.Errorf("%w: load balancer ID and target name are required", ErrInvalidInput)
}
loadBalancer, err := c.lbClient.DefaultAPI.GetLoadBalancer(ctx, c.projectID, c.region, input.LoadBalancerID).Execute()
if err != nil {
return classifySDKError("get load balancer", err)
}
targetPool := apiServerTargetPool(loadBalancer)
if targetPool == nil {
return nil
}

targets := targetPool.GetTargets()
out := make([]lb.Target, 0, len(targets))
func sortedTargetKeys(targets []lb.Target) []string {
keys := make([]string, 0, len(targets))
for _, target := range targets {
if target.GetDisplayName() == input.Name {
continue
}
out = append(out, target)
}
if len(out) == len(targets) {
return nil
}
if len(out) == 0 {
// STACKIT NLB target pools must contain at least one target. Leave the
// last target in place; deleting the load balancer removes it.
return nil
keys = append(keys, target.GetDisplayName()+"\x00"+target.GetIp())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So whats the "magic" nullbyte for? This requires a comment or documentation link or something

}
return c.updateAPIServerTargetPool(ctx, input.LoadBalancerID, targetPool, out, input.Port)
slices.Sort(keys)
return keys
}

func (c *SDKClient) findLoadBalancerByTags(ctx context.Context, tags map[string]string) (*LoadBalancer, error) {
Expand Down
21 changes: 9 additions & 12 deletions cloud/sdk_client_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ func TestSDKClientLoadBalancerCreateDeleteIntegration(t *testing.T) {
loadBalancer := createIntegrationLoadBalancer(t, client, networkID, LoadBalancerTargetInput{
Name: "capistackit-initial-target",
IP: targetIP,
Port: 6443,
})

if err := client.DeleteAPIServerLoadBalancer(context.Background(), loadBalancer.ID); err != nil {
Expand All @@ -81,28 +80,26 @@ func TestSDKClientLoadBalancerTargetIntegration(t *testing.T) {
client := newIntegrationClient(t)
networkID := requiredIntegrationEnv(t, envIntegrationNetworkID)
targetIP := requiredIntegrationEnv(t, envIntegrationTargetIP)
initialIP := integrationInitialTargetIP(targetIP)
loadBalancer := createIntegrationLoadBalancer(t, client, networkID, LoadBalancerTargetInput{
Name: "capistackit-initial-target",
IP: integrationInitialTargetIP(targetIP),
Port: 6443,
IP: initialIP,
})
t.Cleanup(func() {
if err := client.DeleteAPIServerLoadBalancer(context.Background(), loadBalancer.ID); err != nil && !IsNotFound(err) {
t.Logf("DeleteAPIServerLoadBalancer() cleanup error = %v", err)
}
})

target := LoadBalancerTargetInput{
LoadBalancerID: loadBalancer.ID,
Name: "capistackit-integration-target",
IP: targetIP,
Port: 6443,
targets := []LoadBalancerTargetInput{
{Name: "capistackit-initial-target", IP: initialIP},
{Name: "capistackit-integration-target", IP: targetIP},
}
if err := client.EnsureAPIServerLoadBalancerTarget(context.Background(), target); err != nil {
t.Fatalf("EnsureAPIServerLoadBalancerTarget() error = %v", err)
if err := client.SetAPIServerLoadBalancerTargets(context.Background(), loadBalancer.ID, 6443, targets); err != nil {
t.Fatalf("SetAPIServerLoadBalancerTargets() error = %v", err)
}
if err := client.DeleteAPIServerLoadBalancerTarget(context.Background(), target); err != nil {
t.Fatalf("DeleteAPIServerLoadBalancerTarget() error = %v", err)
if err := client.SetAPIServerLoadBalancerTargets(context.Background(), loadBalancer.ID, 6443, targets[:1]); err != nil {
t.Fatalf("SetAPIServerLoadBalancerTargets() shrink error = %v", err)
}
}

Expand Down
53 changes: 40 additions & 13 deletions cloud/sdk_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ func TestSDKClientEnsureAPIServerLoadBalancerUsesBootstrapTargetWhenInitialTarge
assertNestedStringField(t, createPayload, []string{"listeners", "0", "targetPool"}, apiserverTargetPoolName)
}

func TestSDKClientLoadBalancerTargetUpdates(t *testing.T) {
func TestSDKClientSetsAPIServerTargetPool(t *testing.T) {
var mu sync.Mutex
targets := []any{
map[string]any{"displayName": "cp-0", "ip": "10.0.0.10"},
Expand Down Expand Up @@ -247,30 +247,57 @@ func TestSDKClientLoadBalancerTargetUpdates(t *testing.T) {
}))

client := newTestSDKClient(t, server.URL)
input := LoadBalancerTargetInput{
LoadBalancerID: "apiserver-test",
Name: "cp-1",
IP: "10.0.0.11",
Port: 6443,
desired := []LoadBalancerTargetInput{
{Name: "cp-0", IP: "10.0.0.10"},
{Name: "cp-1", IP: "10.0.0.11"},
}
if err := client.EnsureAPIServerLoadBalancerTarget(context.Background(), input); err != nil {
t.Fatalf("EnsureAPIServerLoadBalancerTarget() error = %v", err)
if err := client.SetAPIServerLoadBalancerTargets(context.Background(), "apiserver-test", 6443, desired); err != nil {
t.Fatalf("SetAPIServerLoadBalancerTargets() error = %v", err)
}
if err := client.DeleteAPIServerLoadBalancerTarget(context.Background(), input); err != nil {
t.Fatalf("DeleteAPIServerLoadBalancerTarget() error = %v", err)
if len(updatePayloads) != 1 {
t.Fatalf("got %d update payloads, want 1", len(updatePayloads))
}
assertNestedStringField(t, updatePayloads[0], []string{"targets", "0", "displayName"}, "cp-0")
assertNestedStringField(t, updatePayloads[0], []string{"targets", "1", "displayName"}, "cp-1")
assertNestedStringField(t, updatePayloads[0], []string{"targets", "1", "ip"}, "10.0.0.11")

// An unchanged pool must not turn into a write.
if err := client.SetAPIServerLoadBalancerTargets(context.Background(), "apiserver-test", 6443, desired); err != nil {
t.Fatalf("SetAPIServerLoadBalancerTargets() repeat error = %v", err)
}
if len(updatePayloads) != 1 {
t.Fatalf("got %d update payloads after an unchanged set, want 1", len(updatePayloads))
}

if err := client.SetAPIServerLoadBalancerTargets(
context.Background(),
"apiserver-test",
6443,
desired[:1],
); err != nil {
t.Fatalf("SetAPIServerLoadBalancerTargets() shrink error = %v", err)
}
if len(updatePayloads) != 2 {
t.Fatalf("got %d update payloads, want 2", len(updatePayloads))
}
assertNestedStringField(t, updatePayloads[0], []string{"targets", "1", "displayName"}, "cp-1")
assertNestedStringField(t, updatePayloads[0], []string{"targets", "1", "ip"}, "10.0.0.11")
if got := nestedValue(t, updatePayloads[1], []string{"targets"}).([]any); len(got) != 1 {
t.Fatalf("delete target payload targets = %#v, want one remaining target", got)
t.Fatalf("shrunk target payload targets = %#v, want one remaining target", got)
}
assertNestedStringField(t, updatePayloads[1], []string{"targets", "0", "displayName"}, "cp-0")
}

func TestSDKClientRejectsEmptyAPIServerTargetPool(t *testing.T) {
server := newSDKTestServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}))

client := newTestSDKClient(t, server.URL)
err := client.SetAPIServerLoadBalancerTargets(context.Background(), "apiserver-test", 6443, nil)
if !IsInvalidInput(err) {
t.Fatalf("SetAPIServerLoadBalancerTargets() error = %v, want ErrInvalidInput", err)
}
}

func TestSDKClientClassifiesHTTPStatusCodes(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading