From cfe09fd7872be640c3f68c14e8861ad376df795e Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 7 Jan 2026 14:53:38 +0100 Subject: [PATCH 1/4] add VolumeCreateRequest with support for VolumeSnapshotUUID --- cloudscale.go | 2 +- test/integration/tags_integration_test.go | 6 +++--- .../volume_snapshots_integration_test.go | 4 ++-- test/integration/volumes_integration_test.go | 20 +++++++++---------- volumes.go | 16 ++++++++++++--- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/cloudscale.go b/cloudscale.go index 2deb7b8..1f57487 100644 --- a/cloudscale.go +++ b/cloudscale.go @@ -89,7 +89,7 @@ func NewClient(httpClient *http.Client) *Client { client: c, path: floatingIPsBasePath, } - c.Volumes = GenericServiceOperations[Volume, VolumeRequest, VolumeRequest]{ + c.Volumes = GenericServiceOperations[Volume, VolumeCreateRequest, VolumeUpdateRequest]{ client: c, path: volumeBasePath, } diff --git a/test/integration/tags_integration_test.go b/test/integration/tags_integration_test.go index f76670f..6d9ff59 100644 --- a/test/integration/tags_integration_test.go +++ b/test/integration/tags_integration_test.go @@ -109,7 +109,7 @@ func TestIntegrationTags_Server(t *testing.T) { func TestIntegrationTags_Volume(t *testing.T) { integrationTest(t) - createRequest := cloudscale.VolumeRequest{ + createRequest := cloudscale.VolumeCreateRequest{ Name: testRunPrefix, SizeGB: 3, } @@ -129,7 +129,7 @@ func TestIntegrationTags_Volume(t *testing.T) { t.Errorf("Tagging failed, could not tag, is at %s\n", getResult.Tags) } - updateRequest := cloudscale.VolumeRequest{} + updateRequest := cloudscale.VolumeUpdateRequest{} newTags := getNewTags() updateRequest.Tags = &newTags @@ -177,7 +177,7 @@ func TestIntegrationTags_Volume(t *testing.T) { func TestIntegrationTags_Snapshot(t *testing.T) { integrationTest(t) - createVolumeRequest := cloudscale.VolumeRequest{ + createVolumeRequest := cloudscale.VolumeCreateRequest{ Name: testRunPrefix, SizeGB: 3, } diff --git a/test/integration/volume_snapshots_integration_test.go b/test/integration/volume_snapshots_integration_test.go index a425c29..efac12a 100644 --- a/test/integration/volume_snapshots_integration_test.go +++ b/test/integration/volume_snapshots_integration_test.go @@ -17,7 +17,7 @@ func TestIntegrationVolumeSnapshot_CRUD(t *testing.T) { ctx := context.Background() // A source volume is needed to create a snapshot. - volumeCreateRequest := &cloudscale.VolumeRequest{ + volumeCreateRequest := &cloudscale.VolumeCreateRequest{ Name: "test-volume-for-snapshot", SizeGB: 50, Type: "ssd", @@ -79,7 +79,7 @@ func TestIntegrationVolumeSnapshot_Update(t *testing.T) { ctx := context.Background() // A source volume is needed to create a snapshot. - volumeCreateRequest := &cloudscale.VolumeRequest{ + volumeCreateRequest := &cloudscale.VolumeCreateRequest{ Name: "test-volume-for-snapshot", SizeGB: 50, Type: "ssd", diff --git a/test/integration/volumes_integration_test.go b/test/integration/volumes_integration_test.go index dbb3a5e..a3162f8 100644 --- a/test/integration/volumes_integration_test.go +++ b/test/integration/volumes_integration_test.go @@ -40,7 +40,7 @@ func TestIntegrationVolume_CreateAttached(t *testing.T) { t.Fatalf("Servers.WaitFor returned error %s\n", err) } - createVolumeRequest := &cloudscale.VolumeRequest{ + createVolumeRequest := &cloudscale.VolumeCreateRequest{ Name: testRunPrefix, SizeGB: 50, ServerUUIDs: &[]string{server.UUID}, @@ -56,14 +56,14 @@ func TestIntegrationVolume_CreateAttached(t *testing.T) { } time.Sleep(3 * time.Second) - detachVolumeRequest := &cloudscale.VolumeRequest{ + detachVolumeRequest := &cloudscale.VolumeUpdateRequest{ ServerUUIDs: &[]string{}, } err = client.Volumes.Update(context.TODO(), volume.UUID, detachVolumeRequest) if err != nil { t.Errorf("Volumes.Update returned error %s\n", err) } - attachVolumeRequest := &cloudscale.VolumeRequest{ + attachVolumeRequest := &cloudscale.VolumeUpdateRequest{ ServerUUIDs: &[]string{server.UUID}, } @@ -84,7 +84,7 @@ func TestIntegrationVolume_CreateAttached(t *testing.T) { } func TestIntegrationVolume_CreateWithoutServer(t *testing.T) { - createVolumeRequest := &cloudscale.VolumeRequest{ + createVolumeRequest := &cloudscale.VolumeCreateRequest{ Name: testRunPrefix, SizeGB: 50, } @@ -109,7 +109,7 @@ func TestIntegrationVolume_CreateWithoutServer(t *testing.T) { t.Errorf("Volume %s not found\n", volume.UUID) } - multiUpdateVolumeRequest := &cloudscale.VolumeRequest{ + multiUpdateVolumeRequest := &cloudscale.VolumeUpdateRequest{ SizeGB: 50, Name: testRunPrefix + "Foo", } @@ -133,7 +133,7 @@ func TestIntegrationVolume_CreateWithoutServer(t *testing.T) { const scaleSize = 200 // Try to scale. - scaleVolumeRequest := &cloudscale.VolumeRequest{SizeGB: scaleSize} + scaleVolumeRequest := &cloudscale.VolumeUpdateRequest{SizeGB: scaleSize} err = client.Volumes.Update(context.TODO(), volume.UUID, scaleVolumeRequest) getVolume, err := client.Volumes.Get(context.TODO(), volume.UUID) if err == nil { @@ -151,7 +151,7 @@ func TestIntegrationVolume_CreateWithoutServer(t *testing.T) { } func TestIntegrationVolume_AttachToNewServer(t *testing.T) { - createVolumeRequest := &cloudscale.VolumeRequest{ + createVolumeRequest := &cloudscale.VolumeCreateRequest{ Name: testRunPrefix, SizeGB: 50, } @@ -184,7 +184,7 @@ func TestIntegrationVolume_AttachToNewServer(t *testing.T) { if err != nil { t.Fatalf("Servers.WaitFor returned error %s\n", err) } - volumeAttachRequest := &cloudscale.VolumeRequest{ + volumeAttachRequest := &cloudscale.VolumeUpdateRequest{ ServerUUIDs: &[]string{server.UUID}, } @@ -205,7 +205,7 @@ func TestIntegrationVolume_AttachToNewServer(t *testing.T) { func TestIntegrationVolume_ListByName(t *testing.T) { volumeName := testRunPrefix + "-name-test" - createVolumeRequest := &cloudscale.VolumeRequest{ + createVolumeRequest := &cloudscale.VolumeCreateRequest{ Name: volumeName, SizeGB: 5, } @@ -270,7 +270,7 @@ func TestIntegrationVolume_MultiSite(t *testing.T) { func createVolumeInZoneAndAssert(t *testing.T, zone cloudscale.Zone, wg *sync.WaitGroup) { defer wg.Done() - createVolumeRequest := &cloudscale.VolumeRequest{ + createVolumeRequest := &cloudscale.VolumeCreateRequest{ Name: testRunPrefix, SizeGB: 50, } diff --git a/volumes.go b/volumes.go index ad76fa9..2413c0f 100644 --- a/volumes.go +++ b/volumes.go @@ -22,7 +22,17 @@ type Volume struct { CreatedAt time.Time `json:"created_at"` } -type VolumeRequest struct { +type VolumeCreateRequest struct { + ZonalResourceRequest + TaggedResourceRequest + Name string `json:"name,omitempty"` + SizeGB int `json:"size_gb,omitempty"` + Type string `json:"type,omitempty"` + ServerUUIDs *[]string `json:"server_uuids,omitempty"` + VolumeSnapshotUUID string `json:"volume_snapshot_uuid,omitempty"` +} + +type VolumeUpdateRequest struct { ZonalResourceRequest TaggedResourceRequest Name string `json:"name,omitempty"` @@ -32,10 +42,10 @@ type VolumeRequest struct { } type VolumeService interface { - GenericCreateService[Volume, VolumeRequest] + GenericCreateService[Volume, VolumeCreateRequest] GenericGetService[Volume] GenericListService[Volume] - GenericUpdateService[Volume, VolumeRequest] + GenericUpdateService[Volume, VolumeUpdateRequest] GenericDeleteService[Volume] GenericWaitForService[Volume] } From f3fd0b0c2aa9971847a119df8097158abb236c16 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 7 Jan 2026 15:23:29 +0100 Subject: [PATCH 2/4] add integration test for creating volume from snapshot --- test/integration/volumes_integration_test.go | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/integration/volumes_integration_test.go b/test/integration/volumes_integration_test.go index a3162f8..a7989d0 100644 --- a/test/integration/volumes_integration_test.go +++ b/test/integration/volumes_integration_test.go @@ -5,6 +5,7 @@ package integration import ( "context" + "fmt" "strings" "sync" "testing" @@ -83,6 +84,59 @@ func TestIntegrationVolume_CreateAttached(t *testing.T) { } } +func TestIntegrationVolume_CreateFromSnapshot(t *testing.T) { + integrationTest(t) + ctx := context.Background() + + // volume is need to create a snapshot + createVolumeRequest := &cloudscale.VolumeCreateRequest{ + Name: fmt.Sprintf("%s-source", testRunPrefix), + SizeGB: 10, + } + volume, err := client.Volumes.Create(ctx, createVolumeRequest) + if err != nil { + t.Fatalf("Volumes.Create returned error %s\n", err) + } + + snapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ + Name: testRunPrefix, + SourceVolume: volume.UUID, + } + snapshot, err := client.VolumeSnapshots.Create(ctx, snapshotCreateRequest) + if err != nil { + t.Fatalf("VolumeSnapshots.Create: %v", err) + } + + createVolumeFromSnapshotRequest := &cloudscale.VolumeCreateRequest{ + Name: fmt.Sprintf("%s-from-snapshot", testRunPrefix), + VolumeSnapshotUUID: snapshot.UUID, + } + + volumeCreatedFromSnapshot, err := client.Volumes.Create(ctx, createVolumeFromSnapshotRequest) + if err != nil { + t.Fatalf("Volumes.Create: %v", err) + } + + if err := client.VolumeSnapshots.Delete(ctx, snapshot.UUID); err != nil { + t.Fatalf("Warning: failed to delete snapshot %s: %v", snapshot.UUID, err) + } + + // Wait for snapshot to be fully deleted before deleting volume + // As a volume has been created, deletion can take a few seconds longer + err = waitForSnapshotDeletion(ctx, snapshot.UUID, 30) + if err != nil { + t.Fatalf("Snapshot deletion timeout: %v", err) + } + + if err := client.Volumes.Delete(ctx, volume.UUID); err != nil { + t.Fatalf("Warning: failed to delete volume %s: %v", volume.UUID, err) + } + + if err := client.Volumes.Delete(ctx, volumeCreatedFromSnapshot.UUID); err != nil { + t.Fatalf("Warning: failed to delete volume %s: %v", volume.UUID, err) + } +} + func TestIntegrationVolume_CreateWithoutServer(t *testing.T) { createVolumeRequest := &cloudscale.VolumeCreateRequest{ Name: testRunPrefix, From 68060c37d67f9110785f7b3c11c4dfed8107c709 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Wed, 7 Jan 2026 15:23:52 +0100 Subject: [PATCH 3/4] add integration test tagging a volume created from a snapshot --- test/integration/tags_integration_test.go | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/test/integration/tags_integration_test.go b/test/integration/tags_integration_test.go index 6d9ff59..4364d8d 100644 --- a/test/integration/tags_integration_test.go +++ b/test/integration/tags_integration_test.go @@ -174,6 +174,109 @@ func TestIntegrationTags_Volume(t *testing.T) { } } +func TestIntegrationTags_VolumeFromSnapshot(t *testing.T) { + integrationTest(t) + ctx := context.Background() + + // first create a volume and a snapshot + createVolumeRequest := &cloudscale.VolumeCreateRequest{ + Name: testRunPrefix, + SizeGB: 3, + } + sourceVolume, err := client.Volumes.Create(ctx, createVolumeRequest) + if err != nil { + t.Fatalf("Volumes.Create returned error %s\n", err) + } + snapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ + Name: testRunPrefix, + SourceVolume: sourceVolume.UUID, + } + snapshot, err := client.VolumeSnapshots.Create(ctx, snapshotCreateRequest) + if err != nil { + t.Fatalf("VolumeSnapshots.Create: %v", err) + } + + createVolumeFromSnapshotRequest := &cloudscale.VolumeCreateRequest{ + Name: fmt.Sprintf("%s-from-snapshot", testRunPrefix), + VolumeSnapshotUUID: snapshot.UUID, + } + + initialTags := getInitialTags() + createVolumeFromSnapshotRequest.Tags = &initialTags + + volume, err := client.Volumes.Create(ctx, createVolumeFromSnapshotRequest) + if err != nil { + t.Fatalf("Volumes.Create returned error %s\n", err) + } + + getResult, err := client.Volumes.Get(ctx, volume.UUID) + if err != nil { + t.Errorf("Volumes.Get returned error %s\n", err) + } + if !reflect.DeepEqual(getResult.Tags, initialTags) { + t.Errorf("Tagging failed, could not tag, is at %s\n", getResult.Tags) + } + + updateRequest := cloudscale.VolumeUpdateRequest{} + newTags := getNewTags() + updateRequest.Tags = &newTags + + err = client.Volumes.Update(ctx, volume.UUID, &updateRequest) + if err != nil { + t.Errorf("Volumes.Update returned error: %v", err) + } + getResult2, err := client.Volumes.Get(ctx, volume.UUID) + if err != nil { + t.Errorf("Volumes.Get returned error %s\n", err) + } + if !reflect.DeepEqual(getResult2.Tags, newTags) { + t.Errorf("Tagging failed, could not tag, is at %s\n", getResult.Tags) + } + + // test querying with tags + initialTagsKeyOnly := getInitialTagsKeyOnly() + for _, tags := range []cloudscale.TagMap{initialTags, initialTagsKeyOnly} { + res, err := client.Volumes.List(ctx, cloudscale.WithTagFilter(tags)) + if err != nil { + t.Errorf("Volumes.List returned error %s\n", err) + } + if len(res) > 0 { + t.Errorf("Expected no result when filter with %#v, got: %#v", tags, res) + } + } + + newTagsKeyOnly := getNewTagsKeyOnly() + for _, tags := range []cloudscale.TagMap{newTags, newTagsKeyOnly} { + res, err := client.Volumes.List(ctx, cloudscale.WithTagFilter(tags)) + if err != nil { + t.Errorf("Volumes.List returned error %s\n", err) + } + if len(res) != 1 { + t.Errorf("Expected exactly one result when filter with %#v, got: %#v", tags, len(res)) + } + } + + if err := client.VolumeSnapshots.Delete(ctx, snapshot.UUID); err != nil { + t.Fatalf("Warning: failed to delete snapshot %s: %v", snapshot.UUID, err) + } + + // Wait for snapshot to be fully deleted before deleting volume + // As a volume has been created, deletion can take a few seconds longer + err = waitForSnapshotDeletion(ctx, snapshot.UUID, 30) + if err != nil { + t.Fatalf("Snapshot deletion timeout: %v", err) + } + + if err := client.Volumes.Delete(ctx, sourceVolume.UUID); err != nil { + t.Fatalf("Volumes.Delete returned error %s: %v", sourceVolume.UUID, err) + } + + err = client.Volumes.Delete(ctx, volume.UUID) + if err != nil { + t.Fatalf("Volumes.Delete returned error %s\n", err) + } +} + func TestIntegrationTags_Snapshot(t *testing.T) { integrationTest(t) From d14a0cbe6a326eeb3562007aa70d99595f8e5640 Mon Sep 17 00:00:00 2001 From: Julian Bigler Date: Tue, 13 Jan 2026 14:04:52 +0100 Subject: [PATCH 4/4] improve cleanup of volume snapshots in integration tests --- test/integration/cloudscale_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/integration/cloudscale_test.go b/test/integration/cloudscale_test.go index 4bfd5c1..223d465 100644 --- a/test/integration/cloudscale_test.go +++ b/test/integration/cloudscale_test.go @@ -49,6 +49,7 @@ func TestMain(m *testing.M) { foundResource := false foundResource = foundResource || DeleteRemainingServer() foundResource = foundResource || DeleteRemainingServerGroups() + foundResource = foundResource || DeleteRemainingVolumeSnapshots() foundResource = foundResource || DeleteRemainingVolumes() foundResource = foundResource || DeleteRemainingSubnets() foundResource = foundResource || DeleteRemainingNetworks() @@ -106,6 +107,28 @@ func DeleteRemainingServerGroups() bool { return foundResource } +func DeleteRemainingVolumeSnapshots() bool { + foundResource := false + + snapshots, err := client.VolumeSnapshots.List(context.Background()) + if err != nil { + log.Fatalf("VolumeSnapshots.List returned error %s\n", err) + } + + for _, snapshot := range snapshots { + if strings.HasPrefix(snapshot.Name, testRunPrefix) { + foundResource = true + log.Printf("Found not deleted snapshot: %s (%s)\n", snapshot.Name, snapshot.UUID) + err = client.VolumeSnapshots.Delete(context.Background(), snapshot.UUID) + if err != nil { + log.Fatalf("VolumeSnapshots.Delete returned error %s\n", err) + } + } + } + + return foundResource +} + func DeleteRemainingVolumes() bool { foundResource := false