diff --git a/cloudscale.go b/cloudscale.go index 823421a..2deb7b8 100644 --- a/cloudscale.go +++ b/cloudscale.go @@ -37,6 +37,7 @@ type Client struct { Regions RegionService Servers ServerService Volumes VolumeService + VolumeSnapshots VolumeSnapshotService Networks NetworkService Subnets SubnetService FloatingIPs FloatingIPsService @@ -92,6 +93,10 @@ func NewClient(httpClient *http.Client) *Client { client: c, path: volumeBasePath, } + c.VolumeSnapshots = GenericServiceOperations[VolumeSnapshot, VolumeSnapshotCreateRequest, VolumeSnapshotUpdateRequest]{ + client: c, + path: volumeSnapshotsBasePath, + } c.ServerGroups = GenericServiceOperations[ServerGroup, ServerGroupRequest, ServerGroupRequest]{ client: c, path: serverGroupsBasePath, diff --git a/test/integration/tags_integration_test.go b/test/integration/tags_integration_test.go index 1233e8f..0269bd3 100644 --- a/test/integration/tags_integration_test.go +++ b/test/integration/tags_integration_test.go @@ -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.VolumeSnapshotCreateRequest{ + Name: testRunPrefix, + 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) diff --git a/test/integration/volume_snapshots_integration_test.go b/test/integration/volume_snapshots_integration_test.go new file mode 100644 index 0000000..94e6d57 --- /dev/null +++ b/test/integration/volume_snapshots_integration_test.go @@ -0,0 +1,168 @@ +//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: testRunPrefix, + 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) + } + + volumeName := fmt.Sprintf("%s-snapshot", testRunPrefix) + snapshotCreateRequest := &cloudscale.VolumeSnapshotCreateRequest{ + Name: volumeName, + 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 != volumeName { + t.Errorf("Expected snapshot name '%s', got '%s'", volumeName, 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: testRunPrefix, + 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.VolumeSnapshotCreateRequest{ + Name: testRunPrefix, + SourceVolume: volume.UUID, + } + snapshot, err := client.VolumeSnapshots.Create(ctx, snapshotCreateRequest) + if err != nil { + t.Fatalf("VolumeSnapshots.Create: %v", err) + } + + updatedName := fmt.Sprintf("%s-updated", testRunPrefix) + + snapshotUpdateRequest := &cloudscale.VolumeSnapshotUpdateRequest{ + Name: updatedName, + } + 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 != updatedName { + t.Errorf("Expected updated snapshot name '%s', got '%s'", updatedName, 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) +} diff --git a/volume_snapshots.go b/volume_snapshots.go new file mode 100644 index 0000000..6bcc3d1 --- /dev/null +++ b/volume_snapshots.go @@ -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 VolumeSnapshotCreateRequest 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, VolumeSnapshotCreateRequest] + GenericGetService[VolumeSnapshot] + GenericListService[VolumeSnapshot] + GenericUpdateService[VolumeSnapshot, VolumeSnapshotUpdateRequest] + GenericDeleteService[VolumeSnapshot] + GenericWaitForService[VolumeSnapshot] +}