diff --git a/internal/acceptance/openstack/blockstorage/v3/blockstorage.go b/internal/acceptance/openstack/blockstorage/v3/blockstorage.go index 56cf612abf..62e11af9b1 100644 --- a/internal/acceptance/openstack/blockstorage/v3/blockstorage.go +++ b/internal/acceptance/openstack/blockstorage/v3/blockstorage.go @@ -14,6 +14,7 @@ import ( "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/internal/acceptance/tools" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/backups" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/manageablevolumes" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/qos" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/snapshots" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" @@ -779,3 +780,78 @@ func ReImage(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Vo return nil } + +func Unmanage(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) error { + t.Logf("Attempting to unmanage volume %s", volume.ID) + + err := volumes.Unmanage(context.TODO(), client, volume.ID).ExtractErr() + if err != nil { + return err + } + + err = gophercloud.WaitFor(context.TODO(), func(ctx context.Context) (bool, error) { + if _, err := volumes.Get(ctx, client, volume.ID).Extract(); err != nil { + if errCode, ok := err.(gophercloud.ErrUnexpectedResponseCode); ok { + if errCode.Actual == 404 { + return true, nil + } + } + return false, err + } + return false, nil + }) + if err != nil { + return fmt.Errorf("error waiting for volume %s to be unmanaged: %v", volume.ID, err) + } + + t.Logf("Successfully unmanaged volume %s", volume.ID) + + return nil +} + +func ManageExisting(t *testing.T, client *gophercloud.ServiceClient, volume *volumes.Volume) (*volumes.Volume, error) { + t.Logf("Attempting to manage existing volume %s", volume.Name) + + manageOpts := manageablevolumes.ManageExistingOpts{ + Host: volume.Host, + Ref: map[string]string{ + "source-name": fmt.Sprintf("volume-%s", volume.ID), + }, + Name: volume.Name, + AvailabilityZone: volume.AvailabilityZone, + Description: volume.Description, + VolumeType: volume.VolumeType, + Bootable: volume.Bootable == "true", + Metadata: volume.Metadata, + } + + managed, err := manageablevolumes.ManageExisting(context.TODO(), client, manageOpts).Extract() + if err != nil { + return managed, err + } + + ctx, cancel := context.WithTimeout(context.TODO(), 60*time.Second) + defer cancel() + + if err := volumes.WaitForStatus(ctx, client, managed.ID, "available"); err != nil { + return managed, err + } + + managed, err = volumes.Get(context.TODO(), client, managed.ID).Extract() + if err != nil { + return managed, err + } + + tools.PrintResource(t, managed) + th.AssertEquals(t, managed.Host, volume.Host) + th.AssertEquals(t, managed.Name, volume.Name) + th.AssertEquals(t, managed.AvailabilityZone, volume.AvailabilityZone) + th.AssertEquals(t, managed.Description, volume.Description) + th.AssertEquals(t, managed.VolumeType, volume.VolumeType) + th.AssertEquals(t, managed.Bootable, volume.Bootable) + th.AssertDeepEquals(t, managed.Metadata, volume.Metadata) + + t.Logf("Successfully managed existing volume %s", managed.ID) + + return managed, nil +} diff --git a/internal/acceptance/openstack/blockstorage/v3/manageablevolumes_test.go b/internal/acceptance/openstack/blockstorage/v3/manageablevolumes_test.go new file mode 100644 index 0000000000..a555e706cf --- /dev/null +++ b/internal/acceptance/openstack/blockstorage/v3/manageablevolumes_test.go @@ -0,0 +1,57 @@ +//go:build acceptance || blockstorage || volumes + +package v3 + +import ( + "context" + "testing" + + "github.com/gophercloud/gophercloud/v2/internal/acceptance/clients" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + th "github.com/gophercloud/gophercloud/v2/testhelper" +) + +func TestManageableVolumes(t *testing.T) { + clients.RequireLong(t) + + client, err := clients.NewBlockStorageV3Client() + th.AssertNoErr(t, err) + + client.Microversion = "3.8" + + volume1, err := CreateVolume(t, client) + th.AssertNoErr(t, err) + + err = Unmanage(t, client, volume1) + if err != nil { + DeleteVolume(t, client, volume1) + } + th.AssertNoErr(t, err) + + managed1, err := ManageExisting(t, client, volume1) + th.AssertNoErr(t, err) + defer DeleteVolume(t, client, managed1) + + th.CheckEquals(t, volume1.Host, managed1.Host) + th.AssertEquals(t, volume1.Name, managed1.Name) + th.AssertEquals(t, volume1.AvailabilityZone, managed1.AvailabilityZone) + th.AssertEquals(t, volume1.Description, managed1.Description) + th.AssertEquals(t, volume1.VolumeType, managed1.VolumeType) + th.AssertEquals(t, volume1.Bootable, managed1.Bootable) + th.AssertDeepEquals(t, volume1.Metadata, managed1.Metadata) + th.AssertEquals(t, volume1.Size, managed1.Size) + + allPages, err := volumes.List(client, volumes.ListOpts{}).AllPages(context.TODO()) + th.AssertNoErr(t, err) + allVolumes, err := volumes.ExtractVolumes(allPages) + th.AssertNoErr(t, err) + + var found bool + for _, v := range allVolumes { + if v.ID == managed1.ID { + found = true + break + } + } + th.AssertEquals(t, true, found) +} diff --git a/openstack/blockstorage/v3/manageablevolumes/doc.go b/openstack/blockstorage/v3/manageablevolumes/doc.go new file mode 100644 index 0000000000..a2217fc0fb --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/doc.go @@ -0,0 +1,32 @@ +/* +Package manageablevolumes information and interaction with manageable volumes +for the OpenStack Block Storage service. + +NOTE: Requires at least microversion 3.8 + +Example to manage an existing volume + + manageOpts := manageablevolumes.ManageExistingOpts{ + Host: "host@lvm#LVM", + Ref: map[string]string{ + "source-name": "volume-73796b96-169f-4675-a5bc-73fc0f8f9a17", + }, + Name: "New Volume", + AvailabilityZone: "nova", + Description: "Volume imported from existingLV", + VolumeType: "lvm", + Bootable: true, + Metadata: map[string]string{ + "key1": "value1", + "key2": "value2" + }, + } + + managedVolume, err := manageablevolumes.ManageExisting(context.TODO(), client, manageOpts).Extract() + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Managed volume: %+v\n", managedVolume) +*/ +package manageablevolumes diff --git a/openstack/blockstorage/v3/manageablevolumes/requests.go b/openstack/blockstorage/v3/manageablevolumes/requests.go new file mode 100644 index 0000000000..42c5593cda --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/requests.go @@ -0,0 +1,61 @@ +package manageablevolumes + +import ( + "context" + + "github.com/gophercloud/gophercloud/v2" +) + +// ManageExistingOptsBuilder allows extentions to add additional parameters to the ManageExisting request. +type ManageExistingOptsBuilder interface { + ToManageExistingMap() (map[string]any, error) +} + +// ManageExistingOpts contains options for managing a existing volume. +// This object is passed to the volumes.ManageExisting function. +// For more information about the parameters, see the Volume object and OpenStack BlockStorage API Guide. +type ManageExistingOpts struct { + // The OpenStack Block Storage host where the existing resource resides. + // Optional only if cluster field is provided. + Host string `json:"host,omitempty"` + // The OpenStack Block Storage cluster where the resource resides. + // Optional only if host field is provided. + Cluster string `json:"cluster,omitempty"` + // A reference to the existing volume. + // The internal structure of this reference depends on the volume driver implementation. + // For details about the required elements in the structure, see the documentation for the volume driver. + Ref map[string]string `json:"ref,omitempty"` + // Human-readable display name for the volume. + Name string `json:"name,omitempty"` + // The availability zone. + AvailabilityZone string `json:"availability_zone,omitempty"` + // Human-readable description for the volume. + Description string `json:"description,omitempty"` + // The associated volume type + VolumeType string `json:"volume_type,omitempty"` + // Indicates whether this is a bootable volume. + Bootable bool `json:"bootable,omitempty"` + // One or more metadata key and value pairs to associate with the volume. + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ToManageExistingMap assembles a request body based on the contents of a ManageExistingOpts. +func (opts ManageExistingOpts) ToManageExistingMap() (map[string]any, error) { + return gophercloud.BuildRequestBody(opts, "volume") +} + +// ManageExisting will manage an existing volume based on the values in ManageExistingOpts. +// To extract the Volume object from response, call the Extract method on the ManageExistingResult. +func ManageExisting(ctx context.Context, client *gophercloud.ServiceClient, opts ManageExistingOptsBuilder) (r ManageExistingResult) { + b, err := opts.ToManageExistingMap() + if err != nil { + r.Err = err + return + } + + resp, err := client.Post(ctx, createURL(client), b, &r.Body, &gophercloud.RequestOpts{ + OkCodes: []int{202}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} diff --git a/openstack/blockstorage/v3/manageablevolumes/results.go b/openstack/blockstorage/v3/manageablevolumes/results.go new file mode 100644 index 0000000000..317ed21fb3 --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/results.go @@ -0,0 +1,22 @@ +package manageablevolumes + +import ( + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" +) + +type ManageExistingResult struct { + gophercloud.Result +} + +// Extract will get the Volume object out of the ManageExistingResult object. +func (r ManageExistingResult) Extract() (*volumes.Volume, error) { + var s volumes.Volume + err := r.ExtractInto(&s) + return &s, err +} + +// ExtractInto converts our response data into a volume struct +func (r ManageExistingResult) ExtractInto(v any) error { + return r.Result.ExtractIntoStructPtr(v, "volume") +} diff --git a/openstack/blockstorage/v3/manageablevolumes/testing/doc.go b/openstack/blockstorage/v3/manageablevolumes/testing/doc.go new file mode 100644 index 0000000000..4acd665887 --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/testing/doc.go @@ -0,0 +1,2 @@ +// manageablevolumes unit tests +package testing diff --git a/openstack/blockstorage/v3/manageablevolumes/testing/fixtures_test.go b/openstack/blockstorage/v3/manageablevolumes/testing/fixtures_test.go new file mode 100644 index 0000000000..5fdc8fb995 --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/testing/fixtures_test.go @@ -0,0 +1,93 @@ +package testing + +import ( + "fmt" + "net/http" + "testing" + + th "github.com/gophercloud/gophercloud/v2/testhelper" + fake "github.com/gophercloud/gophercloud/v2/testhelper/client" +) + +func MockManageExistingResponse(t *testing.T) { + th.Mux.HandleFunc("/manageable_volumes", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "volume": { + "host": "host@lvm#LVM", + "ref": { + "source-name": "volume-73796b96-169f-4675-a5bc-73fc0f8f9a17" + }, + "name": "New Volume", + "availability_zone": "nova", + "description": "Volume imported from existingLV", + "volume_type": "lvm", + "bootable": true, + "metadata": { + "key1": "value1", + "key2": "value2" + } + } +} + `) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + + fmt.Fprint(w, ` +{ + "volume": { + "id": "23cf872b-c781-4cd4-847d-5f2ec8cbd91c", + "status": "creating", + "size": 0, + "availability_zone": "nova", + "created_at": "2025-03-20T11:58:05.000000", + "updated_at": "2025-03-20T11:58:05.000000", + "name": "New Volume", + "description": "Volume imported from existingLV", + "volume_type": "lvm", + "snapshot_id": null, + "source_volid": null, + "metadata": { + "key1": "value1", + "key2": "value2" + }, + "links": [ + { + "href": "http://10.0.2.15:8776/v3/87c8522052ca4eed98bc672b4c1a3ddb/volumes/23cf872b-c781-4cd4-847d-5f2ec8cbd91c", + "rel": "self" + }, + { + "href": "http://10.0.2.15:8776/87c8522052ca4eed98bc672b4c1a3ddb/volumes/23cf872b-c781-4cd4-847d-5f2ec8cbd91c", + "rel": "bookmark" + } + ], + "user_id": "eae1472b5fc5496998a3d06550929e7e", + "bootable": "true", + "encrypted": false, + "replication_status": null, + "consistencygroup_id": null, + "multiattach": false, + "attachments": [], + "created_at": "2014-07-18T00:12:54.000000", + "migration_status": null, + "group_id": null, + "provider_id": null, + "shared_targets": true, + "service_uuid": null, + "cluster_name": null, + "volume_type_id": "a218796e-605b-4b6f-9dfc-8be95a0d7d03", + "consumes_quota": true, + "os-vol-mig-status-attr:migstat": null, + "os-vol-mig-status-attr:name_id": null, + "os-vol-tenant-attr:tenant_id": "87c8522052ca4eed98bc672b4c1a3ddb", + "os-vol-host-attr:host": "host@lvm#LVM" + } +} + `) + }) +} diff --git a/openstack/blockstorage/v3/manageablevolumes/testing/requests_test.go b/openstack/blockstorage/v3/manageablevolumes/testing/requests_test.go new file mode 100644 index 0000000000..cabbfde4ea --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/testing/requests_test.go @@ -0,0 +1,44 @@ +package testing + +import ( + "context" + "testing" + + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/manageablevolumes" + th "github.com/gophercloud/gophercloud/v2/testhelper" + "github.com/gophercloud/gophercloud/v2/testhelper/client" +) + +func TestManageExisting(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockManageExistingResponse(t) + + options := &manageablevolumes.ManageExistingOpts{ + Host: "host@lvm#LVM", + Ref: map[string]string{"source-name": "volume-73796b96-169f-4675-a5bc-73fc0f8f9a17"}, + Name: "New Volume", + AvailabilityZone: "nova", + Description: "Volume imported from existingLV", + VolumeType: "lvm", + Bootable: true, + Metadata: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + } + n, err := manageablevolumes.ManageExisting(context.TODO(), client.ServiceClient(), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, n.Host, "host@lvm#LVM") + th.AssertEquals(t, n.Name, "New Volume") + th.AssertEquals(t, n.AvailabilityZone, "nova") + th.AssertEquals(t, n.Description, "Volume imported from existingLV") + th.AssertEquals(t, n.Bootable, "true") + th.AssertDeepEquals(t, n.Metadata, map[string]string{ + "key1": "value1", + "key2": "value2", + }) + th.AssertEquals(t, n.ID, "23cf872b-c781-4cd4-847d-5f2ec8cbd91c") +} diff --git a/openstack/blockstorage/v3/manageablevolumes/urls.go b/openstack/blockstorage/v3/manageablevolumes/urls.go new file mode 100644 index 0000000000..c58c94a396 --- /dev/null +++ b/openstack/blockstorage/v3/manageablevolumes/urls.go @@ -0,0 +1,7 @@ +package manageablevolumes + +import "github.com/gophercloud/gophercloud/v2" + +func createURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("manageable_volumes") +} diff --git a/openstack/blockstorage/v3/volumes/doc.go b/openstack/blockstorage/v3/volumes/doc.go index 2ab4af93ee..e018b57a8d 100644 --- a/openstack/blockstorage/v3/volumes/doc.go +++ b/openstack/blockstorage/v3/volumes/doc.go @@ -157,5 +157,12 @@ Example of Attaching a Volume to an Instance if err != nil { panic(err) } + +Example of Unmanaging a Volume + + err := volumes.Unmanage(context.TODO(), client, volume.ID).ExtractErr() + if err != nil { + panic(err) + } */ package volumes diff --git a/openstack/blockstorage/v3/volumes/requests.go b/openstack/blockstorage/v3/volumes/requests.go index 4dc2c1be7e..1026d1ecaa 100644 --- a/openstack/blockstorage/v3/volumes/requests.go +++ b/openstack/blockstorage/v3/volumes/requests.go @@ -775,3 +775,14 @@ func ResetStatus(ctx context.Context, client *gophercloud.ServiceClient, id stri _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) return } + +// Unmanage removes a volume from Block Storage management without +// removing the back-end storage object that is associated with it. +func Unmanage(ctx context.Context, client *gophercloud.ServiceClient, id string) (r UnmanageResult) { + body := map[string]any{"os-unmanage": make(map[string]any)} + resp, err := client.Post(ctx, actionURL(client, id), body, nil, &gophercloud.RequestOpts{ + OkCodes: []int{202}, + }) + _, r.Header, r.Err = gophercloud.ParseResponse(resp, err) + return +} diff --git a/openstack/blockstorage/v3/volumes/results.go b/openstack/blockstorage/v3/volumes/results.go index 3f184b398e..e99ef5e197 100644 --- a/openstack/blockstorage/v3/volumes/results.go +++ b/openstack/blockstorage/v3/volumes/results.go @@ -399,3 +399,8 @@ type ReImageResult struct { type ResetStatusResult struct { gophercloud.ErrResult } + +// UnmanageResult contains the response error from a Unmanage request. +type UnmanageResult struct { + gophercloud.ErrResult +} diff --git a/openstack/blockstorage/v3/volumes/testing/fixtures_test.go b/openstack/blockstorage/v3/volumes/testing/fixtures_test.go index 9b4630a978..3e156aca1e 100644 --- a/openstack/blockstorage/v3/volumes/testing/fixtures_test.go +++ b/openstack/blockstorage/v3/volumes/testing/fixtures_test.go @@ -648,3 +648,19 @@ func MockResetStatusResponse(t *testing.T) { w.WriteHeader(http.StatusAccepted) }) } + +func MockUnmanageResponse(t *testing.T) { + th.Mux.HandleFunc("/volumes/cd281d77-8217-4830-be95-9528227c105c/action", + func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestJSONRequest(t, r, ` +{ + "os-unmanage": {} +} + `) + + w.WriteHeader(http.StatusAccepted) + }) +} diff --git a/openstack/blockstorage/v3/volumes/testing/requests_test.go b/openstack/blockstorage/v3/volumes/testing/requests_test.go index 5e32346747..4fbd79f00c 100644 --- a/openstack/blockstorage/v3/volumes/testing/requests_test.go +++ b/openstack/blockstorage/v3/volumes/testing/requests_test.go @@ -538,3 +538,13 @@ func TestResetStatus(t *testing.T) { err := volumes.ResetStatus(context.TODO(), client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c", options).ExtractErr() th.AssertNoErr(t, err) } + +func TestUnmanage(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockUnmanageResponse(t) + + err := volumes.Unmanage(context.TODO(), client.ServiceClient(), "cd281d77-8217-4830-be95-9528227c105c").ExtractErr() + th.AssertNoErr(t, err) +}