Skip to content
5 changes: 5 additions & 0 deletions cloudscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Client struct {
Regions RegionService
Servers ServerService
Volumes VolumeService
VolumeSnapshots VolumeSnapshotService
Networks NetworkService
Subnets SubnetService
FloatingIPs FloatingIPsService
Expand Down Expand Up @@ -92,6 +93,10 @@ func NewClient(httpClient *http.Client) *Client {
client: c,
path: volumeBasePath,
}
c.VolumeSnapshots = GenericServiceOperations[VolumeSnapshot, VolumeSnapshotRequest, VolumeSnapshotUpdateRequest]{
client: c,
path: volumeSnapshotsBasePath,
}
c.ServerGroups = GenericServiceOperations[ServerGroup, ServerGroupRequest, ServerGroupRequest]{
client: c,
path: serverGroupsBasePath,
Expand Down
88 changes: 88 additions & 0 deletions test/integration/tags_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,94 @@ func TestIntegrationTags_Volume(t *testing.T) {
}
}

func TestIntegrationTags_Snapshot(t *testing.T) {
integrationTest(t)

createVolumeRequest := cloudscale.VolumeRequest{
Name: testRunPrefix,
SizeGB: 3,
}

volume, err := client.Volumes.Create(context.Background(), &createVolumeRequest)
if err != nil {
t.Fatalf("Volumes.Create returned error %s\n", err)
}

snapshotCreateRequest := &cloudscale.VolumeSnapshotRequest{
Name: "test-snapshot",
SourceVolume: volume.UUID,
}
initialTags := getInitialTags()
snapshotCreateRequest.Tags = &initialTags

snapshot, err := client.VolumeSnapshots.Create(context.Background(), snapshotCreateRequest)
if err != nil {
t.Fatalf("VolumeSnapshots.Create: %v", err)
}

getResult, err := client.VolumeSnapshots.Get(context.Background(), snapshot.UUID)
if err != nil {
t.Errorf("VolumeSnapshots.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.VolumeSnapshotUpdateRequest{}
newTags := getNewTags()
updateRequest.Tags = &newTags

err = client.VolumeSnapshots.Update(context.Background(), snapshot.UUID, &updateRequest)
if err != nil {
t.Errorf("VolumeSnapshots.Update returned error: %v", err)
}
getResult2, err := client.VolumeSnapshots.Get(context.Background(), snapshot.UUID)
if err != nil {
t.Errorf("VolumeSnapshots.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.VolumeSnapshots.List(context.Background(), cloudscale.WithTagFilter(tags))
if err != nil {
t.Errorf("VolumeSnapshots.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.VolumeSnapshots.List(context.Background(), cloudscale.WithTagFilter(tags))
if err != nil {
t.Errorf("VolumeSnapshots.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))
}
}

err = client.VolumeSnapshots.Delete(context.Background(), snapshot.UUID)
if err != nil {
t.Fatalf("VolumeSnapshots.Delete returned error %s\n", err)
}

// Wait for snapshot to be fully deleted before deleting volume
err = waitForSnapshotDeletion(context.Background(), snapshot.UUID, 10)
if err != nil {
t.Fatalf("Snapshot deletion timeout: %v", err)
}

if err := client.Volumes.Delete(context.Background(), volume.UUID); err != nil {
t.Fatalf("Warning: failed to delete volume %s: %v", volume.UUID, err)
}
}

func TestIntegrationTags_FloatingIP(t *testing.T) {
integrationTest(t)

Expand Down
165 changes: 165 additions & 0 deletions test/integration/volume_snapshots_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//go:build integration

package integration

import (
"context"
"fmt"
"testing"
"time"

"github.com/cloudscale-ch/cloudscale-go-sdk/v6"
)

func TestIntegrationVolumeSnapshot_CRUD(t *testing.T) {
integrationTest(t)

ctx := context.Background()

// A source volume is needed to create a snapshot.
volumeCreateRequest := &cloudscale.VolumeRequest{
Name: "test-volume-for-snapshot",
SizeGB: 50,
Type: "ssd",
ZonalResourceRequest: cloudscale.ZonalResourceRequest{
Zone: testZone,
},
}
volume, err := client.Volumes.Create(ctx, volumeCreateRequest)
if err != nil {
t.Fatalf("Volume.Create: %v", err)
}

snapshotCreateRequest := &cloudscale.VolumeSnapshotRequest{
Name: "test-snapshot",
SourceVolume: volume.UUID,
}
snapshot, err := client.VolumeSnapshots.Create(ctx, snapshotCreateRequest)
if err != nil {
t.Fatalf("VolumeSnapshots.Create: %v", err)
}

retrieved, err := client.VolumeSnapshots.Get(ctx, snapshot.UUID)
if err != nil {
t.Fatalf("VolumeSnapshots.Get: %v", err)
}
if retrieved.UUID != snapshot.UUID {
t.Errorf("Expected UUID %s, got %s", snapshot.UUID, retrieved.UUID)
}
if retrieved.Name != "test-snapshot" {
t.Errorf("Expected retrieved snapshot name 'test-snapshot', got '%s'", retrieved.Name)
}

snapshots, err := client.VolumeSnapshots.List(ctx)
if err != nil {
t.Fatalf("VolumeSnapshots.List: %v", err)
}
if len(snapshots) == 0 {
t.Error("Expected at least one snapshot")
}

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
err = waitForSnapshotDeletion(ctx, snapshot.UUID, 10)
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)
}
}

func TestIntegrationVolumeSnapshot_Update(t *testing.T) {
integrationTest(t)

ctx := context.Background()

// A source volume is needed to create a snapshot.
volumeCreateRequest := &cloudscale.VolumeRequest{
Name: "test-volume-for-snapshot",
SizeGB: 50,
Type: "ssd",
ZonalResourceRequest: cloudscale.ZonalResourceRequest{
Zone: testZone,
},
}
volume, err := client.Volumes.Create(ctx, volumeCreateRequest)
if err != nil {
t.Fatalf("Volume.Create: %v", err)
}

snapshotCreateRequest := &cloudscale.VolumeSnapshotRequest{
Name: "test-snapshot",
SourceVolume: volume.UUID,
}
snapshot, err := client.VolumeSnapshots.Create(ctx, snapshotCreateRequest)
if err != nil {
t.Fatalf("VolumeSnapshots.Create: %v", err)
}

snapshotUpdateRequest := &cloudscale.VolumeSnapshotUpdateRequest{
Name: "updated-snapshot",
}
err = client.VolumeSnapshots.Update(ctx, snapshot.UUID, snapshotUpdateRequest)
if err != nil {
t.Fatalf("VolumeSnapshots.Update: %v", err)
}

// Get snapshot again to verify the update
updatedSnapshot, err := client.VolumeSnapshots.Get(ctx, snapshot.UUID)
if err != nil {
t.Fatalf("VolumeSnapshots.Get after update: %v", err)
}
if updatedSnapshot.Name != "updated-snapshot" {
t.Errorf("Expected updated snapshot name 'updated-snapshot', got '%s'", updatedSnapshot.Name)
}

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
err = waitForSnapshotDeletion(ctx, snapshot.UUID, 10)
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)
}
}

// waitForSnapshotDeletion polls the API until the snapshot no longer exists
func waitForSnapshotDeletion(ctx context.Context, snapshotUUID string, maxWaitSeconds int) error {
for i := 0; i < maxWaitSeconds; i++ {
snapshot, err := client.VolumeSnapshots.Get(ctx, snapshotUUID)
if err != nil {

if apiErr, ok := err.(*cloudscale.ErrorResponse); ok {
if apiErr.StatusCode == 404 {
// if we get a 404 error, snapshot is gone, deletion completed
return nil
}
}
// some other error occurred
return err
}

// if snapshot still exists, it must be in state deleting
if snapshot.Status != "deleting" {
return fmt.Errorf(
"snapshot %s exists but is in unexpected state %q while waiting for deletion",
snapshotUUID,
snapshot.Status,
)
}

// snapshot still exists, wait 1 second and try again
time.Sleep(1 * time.Second)
}
return fmt.Errorf("snapshot %s still exists after %d seconds", snapshotUUID, maxWaitSeconds)
}
35 changes: 35 additions & 0 deletions volume_snapshots.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package cloudscale

const volumeSnapshotsBasePath = "v1/volume-snapshots"

type VolumeSnapshot struct {
ZonalResource
TaggedResource
HREF string `json:"href,omitempty"`
UUID string `json:"uuid,omitempty"`
Name string `json:"name,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
Volume VolumeStub `json:"volume,omitempty"`
Status string `json:"status,omitempty"`
}

type VolumeSnapshotRequest struct {
TaggedResourceRequest
Name string `json:"name,omitempty"`
SourceVolume string `json:"source_volume,omitempty"`
}

type VolumeSnapshotUpdateRequest struct {
TaggedResourceRequest
Name string `json:"name,omitempty"`
}

type VolumeSnapshotService interface {
GenericCreateService[VolumeSnapshot, VolumeSnapshotRequest]
GenericGetService[VolumeSnapshot]
GenericListService[VolumeSnapshot]
GenericUpdateService[VolumeSnapshot, VolumeSnapshotUpdateRequest]
GenericDeleteService[VolumeSnapshot]
GenericWaitForService[VolumeSnapshot]
}