Skip to content
Merged
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: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ envfile
.idea
.run
.vscode
.devcontainer
*.swp
*.swo
*~
Expand All @@ -55,3 +56,8 @@ clusterctl-settings.json
templates/clusterclass-template-replaced.yaml
templates/cluster-template-topology-replaced.yaml
output/

# ai stuff
.claude
CLAUDE.md
.cursor
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ linters:
- stylecheck
- tagalign
- testifylint
- tenv
Comment thread
piepmatz marked this conversation as resolved.
- typecheck
- usetesting
- unconvert
- unparam
- unused
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ If you need help with CAPIC, please visit the [#cluster-api-ionoscloud][slack] c

## Compatibility

### Go Version

This provider requires **Go 1.25 or newer**. The exact version is specified in `go.mod`.
Comment thread
piepmatz marked this conversation as resolved.

### Cluster API Versions

This provider's versions are compatible with the following versions of Cluster API:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/ionos-cloud/cluster-api-provider-ionoscloud

go 1.24.13
go 1.25

require (
github.com/go-logr/logr v1.4.3
Expand Down
1 change: 0 additions & 1 deletion internal/ionoscloud/clienttest/mock_client.go

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

3 changes: 1 addition & 2 deletions internal/service/cloud/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ limitations under the License.
package cloud

import (
"context"
"fmt"
"slices"
"testing"
Expand Down Expand Up @@ -184,7 +183,7 @@ func TestFilterImagesByName(t *testing.T) {
}

func TestLookupImagesBySelector(t *testing.T) {
ctx := context.Background()
ctx := t.Context()
ionosClient := clienttest.NewMockClient(t)
ionosClient.EXPECT().ListLabels(ctx).Return([]sdk.Label{
// wrong resource type
Expand Down
2 changes: 1 addition & 1 deletion internal/service/cloud/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ func (s *Service) removeNICFromFailoverGroup(

// Found the NIC, remove it from the failover group
log.V(4).Info("Found NIC in failover group", "nicID", nicID)
ipFailoverConfig = append(ipFailoverConfig[:index], ipFailoverConfig[index+1:]...)
ipFailoverConfig = slices.Delete(ipFailoverConfig, index, index+1)
props := sdk.LanProperties{IpFailover: &ipFailoverConfig}

log.V(4).Info("Patching LAN failover group to remove NIC", "nicID", nicID)
Expand Down
200 changes: 115 additions & 85 deletions internal/util/locker/locker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,14 @@ package locker

import (
"context"
"sync"
"testing"
"testing/synctest"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func withTimeout(t *testing.T, f func()) {
t.Helper()
done := make(chan struct{})
go func() {
f()
close(done)
}()
select {
case <-time.After(1 * time.Second):
t.Fatal("timed out")
case <-done:
}
}

func TestNew(t *testing.T) {
locker := New()
require.NotNil(t, locker)
Expand All @@ -58,105 +44,149 @@ func TestLockWithCounter(t *testing.T) {
}

func TestLockerLock(t *testing.T) {
l := New()
require.NoError(t, l.Lock(context.Background(), "test"))
lwc := l.locks["test"]

require.EqualValues(t, 0, lwc.count())

chDone := make(chan struct{})
go func(t *testing.T) {
assert.NoError(t, l.Lock(context.Background(), "test"))
close(chDone)
}(t)

chWaiting := make(chan struct{})
go func() {
for range time.Tick(1 * time.Millisecond) {
if lwc.count() == 1 {
close(chWaiting)
break
}
}
}()

withTimeout(t, func() {
<-chWaiting
})

select {
case <-chDone:
t.Fatal("lock should not have returned while it was still held")
default:
}

l.Unlock("test")
synctest.Test(t, func(t *testing.T) {
l := New()
require.NoError(t, l.Lock(t.Context(), "test"))
lockState := l.locks["test"]

require.EqualValues(t, 0, lockState.count())

// Start a goroutine that will wait for the lock
lockAcquired := false
go func() {
assert.NoError(t, l.Lock(t.Context(), "test"))
lockAcquired = true
}()

// Wait for goroutine to enter waiting state
synctest.Wait()
require.EqualValues(t, 1, lockState.count(), "should have one waiter")
require.False(t, lockAcquired, "lock should not have been acquired while still held")

// Release the lock - waiting goroutine should now acquire it
l.Unlock("test")
synctest.Wait()
Comment thread
piepmatz marked this conversation as resolved.

withTimeout(t, func() {
<-chDone
require.True(t, lockAcquired)
require.EqualValues(t, 0, lockState.count())
})

require.EqualValues(t, 0, lwc.count())
}

func TestLockerUnlock(t *testing.T) {
l := New()
synctest.Test(t, func(t *testing.T) {
l := New()

require.NoError(t, l.Lock(context.Background(), "test"))
l.Unlock("test")

require.PanicsWithValue(t, "no such lock: test", func() {
require.NoError(t, l.Lock(t.Context(), "test"))
l.Unlock("test")
})

withTimeout(t, func() {
require.NoError(t, l.Lock(context.Background(), "test"))
require.PanicsWithValue(t, "no such lock: test", func() {
l.Unlock("test")
})

require.NoError(t, l.Lock(t.Context(), "test"))
})
}

func TestLockerConcurrency(t *testing.T) {
l := New()

var wg sync.WaitGroup
for range 10_000 {
wg.Add(1)
go func(t *testing.T) {
assert.NoError(t, l.Lock(context.Background(), "test"))
// If there is a concurrency issue, it will very likely become visible here.
l.Unlock("test")
wg.Done()
}(t)
}
synctest.Test(t, func(t *testing.T) {
l := New()

const numWorkers = 10_000
results := make([]bool, numWorkers)

for i := range numWorkers {
go func() {
assert.NoError(t, l.Lock(t.Context(), "test"))
results[i] = true
l.Unlock("test")
}()
}

synctest.Wait()

withTimeout(t, wg.Wait)
for i := range numWorkers {
require.True(t, results[i], "worker %d should have acquired lock", i)
}

// Since everything has unlocked the map should be empty.
require.Empty(t, l.locks)
// Since everything has unlocked the map should be empty.
require.Empty(t, l.locks)
})
Comment thread
piepmatz marked this conversation as resolved.
}

func TestLockerContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()

l := New()
l := New()

withTimeout(t, func() {
err := l.Lock(ctx, "test")
require.ErrorIs(t, context.Canceled, err)
})
}

func TestLockerContextDeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 1*time.Millisecond)
defer cancel()

l := New()
require.NoError(t, l.Lock(ctx, "test"))
l := New()
require.NoError(t, l.Lock(ctx, "test"))

withTimeout(t, func() {
err := l.Lock(ctx, "test")
require.ErrorIs(t, context.DeadlineExceeded, err)

require.NotPanics(t, func() { l.Unlock("test") })
})
}

func TestLockerMultipleKeysIsolation(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
l := New()

require.NoError(t, l.Lock(t.Context(), "key1"))
require.EqualValues(t, 0, l.locks["key1"].count())

// Should be able to lock key2 immediately (different key)
require.NoError(t, l.Lock(t.Context(), "key2"))
require.EqualValues(t, 0, l.locks["key2"].count())

l.Unlock("key1")
l.Unlock("key2")

require.Empty(t, l.locks)
})
}

func TestLockerContextCanceledWhileWaiting(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
l := New()
require.NoError(t, l.Lock(t.Context(), "test"))

ctx, cancel := context.WithCancel(t.Context())

lockCanceled := false
go func() {
err := l.Lock(ctx, "test")
assert.ErrorIs(t, err, context.Canceled)
lockCanceled = true
}()

// Wait for goroutine to enter waiting state
synctest.Wait()
require.EqualValues(t, 1, l.locks["test"].count(), "should have one waiter")

require.NotPanics(t, func() { l.Unlock("test") })
// Cancel while waiting
cancel()
synctest.Wait()

require.True(t, lockCanceled)

// Lock should still exist (held by main goroutine)
require.EqualValues(t, 0, l.locks["test"].count())

// Unlock and verify cleanup
l.Unlock("test")
require.Empty(t, l.locks)
})
}
14 changes: 6 additions & 8 deletions scope/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func TestCluster_GetControlPlaneEndpointIP(t *testing.T) {
},
},
}
got, err := c.GetControlPlaneEndpointIP(context.Background())
got, err := c.GetControlPlaneEndpointIP(t.Context())
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
Expand Down Expand Up @@ -272,7 +272,7 @@ func TestClusterListMachines(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, cs)

machines, err := cs.ListMachines(context.Background(), test.searchLabels)
machines, err := cs.ListMachines(t.Context(), test.searchLabels)
require.NoError(t, err)
require.Len(t, machines, len(test.expectedNames))

Expand Down Expand Up @@ -365,10 +365,8 @@ func TestCurrentRequestByDatacenterAccessors(t *testing.T) {
// If there is a concurrency issue, it will very likely become visible here.
var wg sync.WaitGroup
for i := range 10_000 {
wg.Add(1)
go func(t *testing.T, id string) {
defer wg.Done()

id := strconv.Itoa(i)
wg.Go(func() {
req, exists := cluster.GetCurrentRequestByDatacenter(id)
assert.False(t, exists)
assert.Zero(t, req)
Expand All @@ -386,15 +384,15 @@ func TestCurrentRequestByDatacenterAccessors(t *testing.T) {
req, exists = cluster.GetCurrentRequestByDatacenter(id)
assert.False(t, exists)
assert.Zero(t, req)
}(t, strconv.Itoa(i))
})
}

wg.Wait()

lockKey := cluster.currentRequestByDatacenterLockKey()
require.Equal(t, "uid/currentRequestByDatacenter", lockKey)

_ = cluster.Locker.Lock(context.Background(), lockKey)
_ = cluster.Locker.Lock(t.Context(), lockKey)
require.Empty(t, cluster.IonosCluster.Status.CurrentRequestByDatacenter)
cluster.Locker.Unlock(lockKey)
}
Loading
Loading