volume: Migrate 'volume create' to SDK

We need a shim for consistency group support, which we may eventually
port to SDK but have not yet. Otherwise, this is rather straightforward.

Change-Id: Ic880b7a64cde2148c266d549c4768c669ba3f501
Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
Depends-on: https://review.opendev.org/c/openstack/openstacksdk/+/943800
This commit is contained in:
Stephen Finucane
2025-05-19 13:10:33 +01:00
parent 51ecb5f984
commit 504cbd24e2
10 changed files with 1033 additions and 690 deletions

View File

@@ -64,7 +64,7 @@ def list_security_groups(compute_client, all_projects=None):
def find_security_group(compute_client, name_or_id):
"""Find the name for a given security group name or ID
"""Find the security group for a given name or ID
https://docs.openstack.org/api-ref/compute/#show-security-group-details
@@ -240,7 +240,7 @@ def list_networks(compute_client):
def find_network(compute_client, name_or_id):
"""Find the ID for a given network name or ID
"""Find the network for a given name or ID
https://docs.openstack.org/api-ref/compute/#show-network-details

View File

@@ -0,0 +1,60 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Volume v2 API Library
A collection of wrappers for deprecated Block Storage v2 APIs that are not
intentionally supported by SDK.
"""
import http
from openstack import exceptions as sdk_exceptions
from osc_lib import exceptions
# consistency groups
def find_consistency_group(compute_client, name_or_id):
"""Find the consistency group for a given name or ID
https://docs.openstack.org/api-ref/block-storage/v3/#show-a-consistency-group-s-details
:param volume_client: A volume client
:param name_or_id: The name or ID of the consistency group to look up
:returns: A consistency group object
:raises exception.NotFound: If a matching consistency group could not be
found or more than one match was found
"""
response = compute_client.get(f'/consistencygroups/{name_or_id}')
if response.status_code != http.HTTPStatus.NOT_FOUND:
# there might be other, non-404 errors
sdk_exceptions.raise_from_response(response)
return response.json()['consistencygroup']
response = compute_client.get('/consistencygroups')
sdk_exceptions.raise_from_response(response)
found = None
consistency_groups = response.json()['consistencygroups']
for consistency_group in consistency_groups:
if consistency_group['name'] == name_or_id:
if found:
raise exceptions.NotFound(
f'multiple matches found for {name_or_id}'
)
found = consistency_group
if not found:
raise exceptions.NotFound(f'{name_or_id} not found')
return found

View File

@@ -0,0 +1,60 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Volume v3 API Library
A collection of wrappers for deprecated Block Storage v3 APIs that are not
intentionally supported by SDK.
"""
import http
from openstack import exceptions as sdk_exceptions
from osc_lib import exceptions
# consistency groups
def find_consistency_group(compute_client, name_or_id):
"""Find the consistency group for a given name or ID
https://docs.openstack.org/api-ref/block-storage/v3/#show-a-consistency-group-s-details
:param volume_client: A volume client
:param name_or_id: The name or ID of the consistency group to look up
:returns: A consistency group object
:raises exception.NotFound: If a matching consistency group could not be
found or more than one match was found
"""
response = compute_client.get(f'/consistencygroups/{name_or_id}')
if response.status_code != http.HTTPStatus.NOT_FOUND:
# there might be other, non-404 errors
sdk_exceptions.raise_from_response(response)
return response.json()['consistencygroup']
response = compute_client.get('/consistencygroups')
sdk_exceptions.raise_from_response(response)
found = None
consistency_groups = response.json()['consistencygroups']
for consistency_group in consistency_groups:
if consistency_group['name'] == name_or_id:
if found:
raise exceptions.NotFound(
f'multiple matches found for {name_or_id}'
)
found = consistency_group
if not found:
raise exceptions.NotFound(f'{name_or_id} not found')
return found

View File

@@ -124,7 +124,7 @@ class VolumeTests(common.BaseVolumeTests):
cmd_output["properties"],
)
self.assertEqual(
'false',
False,
cmd_output["bootable"],
)
self.wait_for_status("volume", name, "available")

View File

@@ -0,0 +1,124 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Volume v2 API Library Tests"""
import http
from unittest import mock
import uuid
from openstack.block_storage.v2 import _proxy
from osc_lib import exceptions as osc_lib_exceptions
from openstackclient.api import volume_v2 as volume
from openstackclient.tests.unit import fakes
from openstackclient.tests.unit import utils
class TestConsistencyGroup(utils.TestCase):
def setUp(self):
super().setUp()
self.volume_sdk_client = mock.Mock(_proxy.Proxy)
def test_find_consistency_group_by_id(self):
cg_id = uuid.uuid4().hex
cg_name = 'name-' + uuid.uuid4().hex
data = {
'consistencygroup': {
'id': cg_id,
'name': cg_name,
'status': 'available',
'availability_zone': 'az1',
'created_at': '2015-09-16T09:28:52.000000',
'description': 'description-' + uuid.uuid4().hex,
'volume_types': ['123456'],
}
}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(data=data),
]
result = volume.find_consistency_group(self.volume_sdk_client, cg_id)
self.volume_sdk_client.get.assert_has_calls(
[
mock.call(f'/consistencygroups/{cg_id}'),
]
)
self.assertEqual(data['consistencygroup'], result)
def test_find_consistency_group_by_name(self):
cg_id = uuid.uuid4().hex
cg_name = 'name-' + uuid.uuid4().hex
data = {
'consistencygroups': [
{
'id': cg_id,
'name': cg_name,
}
],
}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(status_code=http.HTTPStatus.NOT_FOUND),
fakes.FakeResponse(data=data),
]
result = volume.find_consistency_group(self.volume_sdk_client, cg_name)
self.volume_sdk_client.get.assert_has_calls(
[
mock.call(f'/consistencygroups/{cg_name}'),
mock.call('/consistencygroups'),
]
)
self.assertEqual(data['consistencygroups'][0], result)
def test_find_consistency_group_not_found(self):
data = {'consistencygroups': []}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(status_code=http.HTTPStatus.NOT_FOUND),
fakes.FakeResponse(data=data),
]
self.assertRaises(
osc_lib_exceptions.NotFound,
volume.find_consistency_group,
self.volume_sdk_client,
'invalid-cg',
)
def test_find_consistency_group_by_name_duplicate(self):
cg_name = 'name-' + uuid.uuid4().hex
data = {
'consistencygroups': [
{
'id': uuid.uuid4().hex,
'name': cg_name,
},
{
'id': uuid.uuid4().hex,
'name': cg_name,
},
],
}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(status_code=http.HTTPStatus.NOT_FOUND),
fakes.FakeResponse(data=data),
]
self.assertRaises(
osc_lib_exceptions.NotFound,
volume.find_consistency_group,
self.volume_sdk_client,
cg_name,
)

View File

@@ -0,0 +1,124 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Volume v3 API Library Tests"""
import http
from unittest import mock
import uuid
from openstack.block_storage.v3 import _proxy
from osc_lib import exceptions as osc_lib_exceptions
from openstackclient.api import volume_v3 as volume
from openstackclient.tests.unit import fakes
from openstackclient.tests.unit import utils
class TestConsistencyGroup(utils.TestCase):
def setUp(self):
super().setUp()
self.volume_sdk_client = mock.Mock(_proxy.Proxy)
def test_find_consistency_group_by_id(self):
cg_id = uuid.uuid4().hex
cg_name = 'name-' + uuid.uuid4().hex
data = {
'consistencygroup': {
'id': cg_id,
'name': cg_name,
'status': 'available',
'availability_zone': 'az1',
'created_at': '2015-09-16T09:28:52.000000',
'description': 'description-' + uuid.uuid4().hex,
'volume_types': ['123456'],
}
}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(data=data),
]
result = volume.find_consistency_group(self.volume_sdk_client, cg_id)
self.volume_sdk_client.get.assert_has_calls(
[
mock.call(f'/consistencygroups/{cg_id}'),
]
)
self.assertEqual(data['consistencygroup'], result)
def test_find_consistency_group_by_name(self):
cg_id = uuid.uuid4().hex
cg_name = 'name-' + uuid.uuid4().hex
data = {
'consistencygroups': [
{
'id': cg_id,
'name': cg_name,
}
],
}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(status_code=http.HTTPStatus.NOT_FOUND),
fakes.FakeResponse(data=data),
]
result = volume.find_consistency_group(self.volume_sdk_client, cg_name)
self.volume_sdk_client.get.assert_has_calls(
[
mock.call(f'/consistencygroups/{cg_name}'),
mock.call('/consistencygroups'),
]
)
self.assertEqual(data['consistencygroups'][0], result)
def test_find_consistency_group_not_found(self):
data = {'consistencygroups': []}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(status_code=http.HTTPStatus.NOT_FOUND),
fakes.FakeResponse(data=data),
]
self.assertRaises(
osc_lib_exceptions.NotFound,
volume.find_consistency_group,
self.volume_sdk_client,
'invalid-cg',
)
def test_find_consistency_group_by_name_duplicate(self):
cg_name = 'name-' + uuid.uuid4().hex
data = {
'consistencygroups': [
{
'id': uuid.uuid4().hex,
'name': cg_name,
},
{
'id': uuid.uuid4().hex,
'name': cg_name,
},
],
}
self.volume_sdk_client.get.side_effect = [
fakes.FakeResponse(status_code=http.HTTPStatus.NOT_FOUND),
fakes.FakeResponse(data=data),
]
self.assertRaises(
osc_lib_exceptions.NotFound,
volume.find_consistency_group,
self.volume_sdk_client,
cg_name,
)

View File

@@ -13,6 +13,7 @@
from unittest import mock
from openstack.block_storage.v2 import snapshot as _snapshot
from openstack.block_storage.v2 import volume as _volume
from openstack import exceptions as sdk_exceptions
from openstack.test import fakes as sdk_fakes
@@ -20,6 +21,7 @@ from osc_lib.cli import format_columns
from osc_lib import exceptions
from osc_lib import utils
from openstackclient.api import volume_v2
from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes
from openstackclient.tests.unit.image.v2 import fakes as image_fakes
from openstackclient.tests.unit import utils as test_utils
@@ -50,129 +52,156 @@ class TestVolume(volume_fakes.TestVolume):
self.consistencygroups_mock.reset_mock()
class TestVolumeCreate(TestVolume):
project = identity_fakes.FakeProject.create_one_project()
user = identity_fakes.FakeUser.create_one_user()
class TestVolumeCreate(volume_fakes.TestVolume):
columns = (
'attachments',
'availability_zone',
'bootable',
'consistencygroup_id',
'created_at',
'description',
'encrypted',
'id',
'multiattach',
'name',
'os-vol-host-attr:host',
'os-vol-mig-status-attr:migstat',
'os-vol-mig-status-attr:name_id',
'os-vol-tenant-attr:tenant_id',
'os-volume-replication:driver_data',
'os-volume-replication:extended_status',
'properties',
'replication_status',
'size',
'snapshot_id',
'source_volid',
'status',
'type',
'updated_at',
'user_id',
'volume_image_metadata',
)
def setUp(self):
super().setUp()
self.new_volume = volume_fakes.create_one_volume()
self.volumes_mock.create.return_value = self.new_volume
self.volume = sdk_fakes.generate_fake_resource(_volume.Volume)
self.volume_sdk_client.create_volume.return_value = self.volume
self.datalist = (
self.new_volume.attachments,
self.new_volume.availability_zone,
self.new_volume.bootable,
self.new_volume.description,
self.new_volume.id,
self.new_volume.name,
format_columns.DictColumn(self.new_volume.metadata),
self.new_volume.size,
self.new_volume.snapshot_id,
self.new_volume.status,
self.new_volume.volume_type,
self.volume.attachments,
self.volume.availability_zone,
self.volume.is_bootable,
self.volume.consistency_group_id,
self.volume.created_at,
self.volume.description,
self.volume.is_encrypted,
self.volume.id,
self.volume.is_multiattach,
self.volume.name,
self.volume.host,
self.volume.migration_status,
self.volume.migration_id,
self.volume.project_id,
self.volume.replication_driver_data,
self.volume.extended_replication_status,
format_columns.DictColumn(self.volume.metadata),
self.volume.replication_status,
self.volume.size,
self.volume.snapshot_id,
self.volume.source_volume_id,
self.volume.status,
self.volume.volume_type,
self.volume.updated_at,
self.volume.user_id,
self.volume.volume_image_metadata,
)
# Get the command object to test
self.cmd = volume.CreateVolume(self.app, None)
def test_volume_create_min_options(self):
arglist = [
'--size',
str(self.new_volume.size),
str(self.volume.size),
]
verifylist = [
('size', self.new_volume.size),
('size', self.volume.size),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=None,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
def test_volume_create_options(self):
consistency_group = volume_fakes.create_one_consistency_group()
self.consistencygroups_mock.get.return_value = consistency_group
consistency_group_id = 'cg123'
arglist = [
'--size',
str(self.new_volume.size),
str(self.volume.size),
'--description',
self.new_volume.description,
self.volume.description,
'--type',
self.new_volume.volume_type,
self.volume.volume_type,
'--availability-zone',
self.new_volume.availability_zone,
self.volume.availability_zone,
'--consistency-group',
consistency_group.id,
consistency_group_id,
'--hint',
'k=v',
self.new_volume.name,
self.volume.name,
]
verifylist = [
('size', self.new_volume.size),
('description', self.new_volume.description),
('type', self.new_volume.volume_type),
('availability_zone', self.new_volume.availability_zone),
('consistency_group', consistency_group.id),
('size', self.volume.size),
('description', self.volume.description),
('type', self.volume.volume_type),
('availability_zone', self.volume.availability_zone),
('consistency_group', consistency_group_id),
('hint', {'k': 'v'}),
('name', self.new_volume.name),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
with mock.patch.object(
volume_v2,
'find_consistency_group',
return_value={'id': consistency_group_id},
) as mock_find_cg:
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
description=self.new_volume.description,
volume_type=self.new_volume.volume_type,
availability_zone=self.new_volume.availability_zone,
name=self.volume.name,
description=self.volume.description,
volume_type=self.volume.volume_type,
availability_zone=self.volume.availability_zone,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=consistency_group.id,
image_id=None,
source_volume_id=None,
consistency_group_id=consistency_group_id,
scheduler_hints={'k': 'v'},
)
mock_find_cg.assert_called_once_with(
self.volume_sdk_client, consistency_group_id
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
def test_volume_create_properties(self):
arglist = [
@@ -181,39 +210,36 @@ class TestVolumeCreate(TestVolume):
'--property',
'Beta=b',
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('properties', {'Alpha': 'a', 'Beta': 'b'}),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata={'Alpha': 'a', 'Beta': 'b'},
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
def test_volume_create_image_id(self):
def test_volume_create_image(self):
image = image_fakes.create_one_image()
self.image_client.find_image.return_value = image
@@ -221,152 +247,111 @@ class TestVolumeCreate(TestVolume):
'--image',
image.id,
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('image', image.id),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=image.id,
source_volid=None,
consistencygroup_id=None,
image_id=image.id,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.image_client.find_image.assert_called_once_with(
image.id, ignore_missing=False
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
def test_volume_create_image_name(self):
image = image_fakes.create_one_image()
self.image_client.find_image.return_value = image
arglist = [
'--image',
image.name,
'--size',
str(self.new_volume.size),
self.new_volume.name,
]
verifylist = [
('image', image.name),
('size', self.new_volume.size),
('name', self.new_volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
snapshot_id=None,
name=self.new_volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=image.id,
source_volid=None,
consistencygroup_id=None,
scheduler_hints=None,
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
def test_volume_create_with_snapshot(self):
snapshot = volume_fakes.create_one_snapshot()
self.new_volume.snapshot_id = snapshot.id
snapshot = sdk_fakes.generate_fake_resource(_snapshot.Snapshot)
self.volume_sdk_client.find_snapshot.return_value = snapshot
arglist = [
'--snapshot',
self.new_volume.snapshot_id,
self.new_volume.name,
snapshot.id,
self.volume.name,
]
verifylist = [
('snapshot', self.new_volume.snapshot_id),
('name', self.new_volume.name),
('snapshot', snapshot.id),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.snapshots_mock.get.return_value = snapshot
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_once_with(
self.volume_sdk_client.create_volume.assert_called_with(
size=snapshot.size,
snapshot_id=snapshot.id,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.volume_sdk_client.find_snapshot.assert_called_once_with(
snapshot.id, ignore_missing=False
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
def test_volume_create_with_source_volume(self):
source_vol = "source_vol"
source_volume = sdk_fakes.generate_fake_resource(_volume.Volume)
self.volume_sdk_client.find_volume.return_value = source_volume
arglist = [
'--source',
self.new_volume.id,
source_vol,
source_volume.id,
self.volume.name,
]
verifylist = [
('source', self.new_volume.id),
('name', source_vol),
('source', source_volume.id),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.volumes_mock.get.return_value = self.new_volume
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_once_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=source_volume.size,
snapshot_id=None,
name=source_vol,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=self.new_volume.id,
consistencygroup_id=None,
image_id=None,
source_volume_id=source_volume.id,
consistency_group_id=None,
scheduler_hints=None,
)
self.volume_sdk_client.find_volume.assert_called_once_with(
source_volume.id, ignore_missing=False
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
@mock.patch.object(utils, 'wait_for_status', return_value=True)
def test_volume_create_with_bootable_and_readonly(self, mock_wait):
@@ -374,42 +359,42 @@ class TestVolumeCreate(TestVolume):
'--bootable',
'--read-only',
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('bootable', True),
('read_only', True),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.volume_sdk_client.set_volume_bootable_status.assert_called_once_with(
self.volume, True
)
self.volume_sdk_client.set_volume_readonly.assert_called_once_with(
self.volume, True
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.volumes_mock.set_bootable.assert_called_with(
self.new_volume.id, True
)
self.volumes_mock.update_readonly_flag.assert_called_with(
self.new_volume.id, True
)
self.assertEqual(self.datalist, data)
@mock.patch.object(utils, 'wait_for_status', return_value=True)
def test_volume_create_with_nonbootable_and_readwrite(self, mock_wait):
@@ -417,145 +402,144 @@ class TestVolumeCreate(TestVolume):
'--non-bootable',
'--read-write',
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('bootable', False),
('read_only', False),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.volume_sdk_client.set_volume_bootable_status.assert_called_once_with(
self.volume, False
)
self.volume_sdk_client.set_volume_readonly.assert_called_once_with(
self.volume, False
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.volumes_mock.set_bootable.assert_called_with(
self.new_volume.id, False
)
self.volumes_mock.update_readonly_flag.assert_called_with(
self.new_volume.id, False
)
self.assertEqual(self.datalist, data)
@mock.patch.object(volume.LOG, 'error')
@mock.patch.object(utils, 'wait_for_status', return_value=True)
def test_volume_create_with_bootable_and_readonly_fail(
self, mock_wait, mock_error
):
self.volumes_mock.set_bootable.side_effect = exceptions.CommandError()
self.volumes_mock.update_readonly_flag.side_effect = (
exceptions.CommandError()
self.volume_sdk_client.set_volume_bootable_status.side_effect = (
sdk_exceptions.NotFoundException('foo')
)
self.volume_sdk_client.set_volume_readonly.side_effect = (
sdk_exceptions.NotFoundException('foo')
)
arglist = [
'--bootable',
'--read-only',
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('bootable', True),
('read_only', True),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.volume_sdk_client.set_volume_bootable_status.assert_called_once_with(
self.volume, True
)
self.volume_sdk_client.set_volume_readonly.assert_called_once_with(
self.volume, True
)
self.assertEqual(2, mock_error.call_count)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.volumes_mock.set_bootable.assert_called_with(
self.new_volume.id, True
)
self.volumes_mock.update_readonly_flag.assert_called_with(
self.new_volume.id, True
)
self.assertEqual(self.datalist, data)
@mock.patch.object(volume.LOG, 'error')
@mock.patch.object(utils, 'wait_for_status', return_value=False)
def test_volume_create_non_available_with_readonly(
self,
mock_wait,
mock_error,
self, mock_wait, mock_error
):
arglist = [
'--non-bootable',
'--read-only',
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('bootable', False),
('read_only', True),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints=None,
)
self.assertEqual(2, mock_error.call_count)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
def test_volume_create_without_size(self):
arglist = [
self.new_volume.name,
self.volume.name,
]
verifylist = [
('name', self.new_volume.name),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@@ -572,15 +556,15 @@ class TestVolumeCreate(TestVolume):
'--snapshot',
'source_snapshot',
'--size',
str(self.new_volume.size),
self.new_volume.name,
str(self.volume.size),
self.volume.name,
]
verifylist = [
('image', 'source_image'),
('source', 'source_volume'),
('snapshot', 'source_snapshot'),
('size', self.new_volume.size),
('name', self.new_volume.name),
('size', self.volume.size),
('name', self.volume.name),
]
self.assertRaises(
@@ -599,7 +583,7 @@ class TestVolumeCreate(TestVolume):
"""
arglist = [
'--size',
str(self.new_volume.size),
str(self.volume.size),
'--hint',
'k=v',
'--hint',
@@ -614,10 +598,10 @@ class TestVolumeCreate(TestVolume):
'local_to_instance=v6',
'--hint',
'different_host=v7',
self.new_volume.name,
self.volume.name,
]
verifylist = [
('size', self.new_volume.size),
('size', self.volume.size),
(
'hint',
{
@@ -627,26 +611,23 @@ class TestVolumeCreate(TestVolume):
'different_host': ['v5', 'v7'],
},
),
('name', self.new_volume.name),
('name', self.volume.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
# In base command class ShowOne in cliff, abstract method take_action()
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.volumes_mock.create.assert_called_with(
size=self.new_volume.size,
self.volume_sdk_client.create_volume.assert_called_with(
size=self.volume.size,
snapshot_id=None,
name=self.new_volume.name,
name=self.volume.name,
description=None,
volume_type=None,
availability_zone=None,
metadata=None,
imageRef=None,
source_volid=None,
consistencygroup_id=None,
image_id=None,
source_volume_id=None,
consistency_group_id=None,
scheduler_hints={
'k': 'v2',
'same_host': ['v3', 'v4'],
@@ -656,7 +637,7 @@ class TestVolumeCreate(TestVolume):
)
self.assertEqual(self.columns, columns)
self.assertCountEqual(self.datalist, data)
self.assertEqual(self.datalist, data)
class TestVolumeDelete(volume_fakes.TestVolume):

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,10 @@ import argparse
import copy
import functools
import logging
import typing as ty
from cliff import columns as cliff_columns
from openstack.block_storage.v2 import volume as _volume
from openstack import exceptions as sdk_exceptions
from osc_lib.cli import format_columns
from osc_lib.cli import parseractions
@@ -27,6 +29,7 @@ from osc_lib.command import command
from osc_lib import exceptions
from osc_lib import utils
from openstackclient.api import volume_v2
from openstackclient.common import pagination
from openstackclient.i18n import _
from openstackclient.identity import common as identity_common
@@ -89,6 +92,47 @@ class AttachmentsColumn(cliff_columns.FormattableColumn):
return msg
def _format_volume(volume: _volume.Volume) -> dict[str, ty.Any]:
# Some columns returned by openstacksdk should not be shown because they're
# either irrelevant or duplicates
ignored_columns = {
# computed columns
'location',
# create-only columns
'OS-SCH-HNT:scheduler_hints',
'imageRef',
# unnecessary columns
'links',
}
optional_columns = {
# only present if part of a consistency group
'consistencygroup_id',
# only present if there are image properties associated
'volume_image_metadata',
}
info = volume.to_dict(original_names=True)
data = {}
for key, value in info.items():
if key in ignored_columns:
continue
if key in optional_columns:
if info[key] is None:
continue
data[key] = value
data.update(
{
'properties': format_columns.DictColumn(data.pop('metadata')),
'type': data.pop('volume_type'),
}
)
return data
class CreateVolume(command.ShowOne):
_description = _("Create new volume")
@@ -226,22 +270,22 @@ class CreateVolume(command.ShowOne):
# volume from snapshot or source volume
size = parsed_args.size
volume_client = self.app.client_manager.volume
volume_client = self.app.client_manager.sdk_connection.volume
image_client = self.app.client_manager.image
source_volume = None
if parsed_args.source:
source_volume_obj = utils.find_resource(
volume_client.volumes, parsed_args.source
source_volume_obj = volume_client.find_volume(
parsed_args.source, ignore_missing=False
)
source_volume = source_volume_obj.id
size = max(size or 0, source_volume_obj.size)
consistency_group = None
if parsed_args.consistency_group:
consistency_group = utils.find_resource(
volume_client.consistencygroups, parsed_args.consistency_group
).id
consistency_group = volume_v2.find_consistency_group(
volume_client, parsed_args.consistency_group
)['id']
image = None
if parsed_args.image:
@@ -251,8 +295,8 @@ class CreateVolume(command.ShowOne):
snapshot = None
if parsed_args.snapshot:
snapshot_obj = utils.find_resource(
volume_client.volume_snapshots, parsed_args.snapshot
snapshot_obj = volume_client.find_snapshot(
parsed_args.snapshot, ignore_missing=False
)
snapshot = snapshot_obj.id
# Cinder requires a value for size when creating a volume
@@ -263,7 +307,7 @@ class CreateVolume(command.ShowOne):
# snapshot size.
size = max(size or 0, snapshot_obj.size)
volume = volume_client.volumes.create(
volume = volume_client.create_volume(
size=size,
snapshot_id=snapshot,
name=parsed_args.name,
@@ -271,23 +315,23 @@ class CreateVolume(command.ShowOne):
volume_type=parsed_args.type,
availability_zone=parsed_args.availability_zone,
metadata=parsed_args.properties,
imageRef=image,
source_volid=source_volume,
consistencygroup_id=consistency_group,
image_id=image,
source_volume_id=source_volume,
consistency_group_id=consistency_group,
scheduler_hints=parsed_args.hint,
)
if parsed_args.bootable is not None:
try:
if utils.wait_for_status(
volume_client.volumes.get,
volume_client.get_volume,
volume.id,
success_status=['available'],
error_status=['error'],
sleep_time=1,
):
volume_client.volumes.set_bootable(
volume.id, parsed_args.bootable
volume_client.set_volume_bootable_status(
volume, parsed_args.bootable
)
else:
msg = _(
@@ -300,14 +344,14 @@ class CreateVolume(command.ShowOne):
if parsed_args.read_only is not None:
try:
if utils.wait_for_status(
volume_client.volumes.get,
volume_client.get_volume,
volume.id,
success_status=['available'],
error_status=['error'],
sleep_time=1,
):
volume_client.volumes.update_readonly_flag(
volume.id, parsed_args.read_only
volume_client.set_volume_readonly(
volume, parsed_args.read_only
)
else:
msg = _(
@@ -321,17 +365,8 @@ class CreateVolume(command.ShowOne):
e,
)
# Remove key links from being displayed
volume._info.update(
{
'properties': format_columns.DictColumn(
volume._info.pop('metadata')
),
'type': volume._info.pop('volume_type'),
}
)
volume._info.pop("links", None)
return zip(*sorted(volume._info.items()))
data = _format_volume(volume)
return zip(*sorted(data.items()))
class DeleteVolume(command.Command):

View File

@@ -18,8 +18,10 @@ import argparse
import copy
import functools
import logging
import typing as ty
from cliff import columns as cliff_columns
from openstack.block_storage.v3 import volume as _volume
from openstack import exceptions as sdk_exceptions
from openstack import utils as sdk_utils
from osc_lib.cli import format_columns
@@ -28,6 +30,7 @@ from osc_lib.command import command
from osc_lib import exceptions
from osc_lib import utils
from openstackclient.api import volume_v3
from openstackclient.common import pagination
from openstackclient.i18n import _
from openstackclient.identity import common as identity_common
@@ -90,6 +93,52 @@ class AttachmentsColumn(cliff_columns.FormattableColumn):
return msg
def _format_volume(volume: _volume.Volume) -> dict[str, ty.Any]:
# Some columns returned by openstacksdk should not be shown because they're
# either irrelevant or duplicates
ignored_columns = {
# computed columns
'location',
# create-only columns
'OS-SCH-HNT:scheduler_hints',
'imageRef',
# removed columns
'os-volume-replication:driver_data',
'os-volume-replication:extended_status',
# unnecessary columns
'links',
}
optional_columns = {
# only present if part of a consistency group
'consistencygroup_id',
# only present if the volume is encrypted
'encryption_key_id',
# only present if there are image properties associated
'volume_image_metadata',
}
info = volume.to_dict(original_names=True)
data = {}
for key, value in info.items():
if key in ignored_columns:
continue
if key in optional_columns:
if info[key] is None:
continue
data[key] = value
data.update(
{
'properties': format_columns.DictColumn(data.pop('metadata')),
'type': data.pop('volume_type'),
}
)
return data
class CreateVolume(command.ShowOne):
_description = _("Create new volume")
@@ -272,8 +321,7 @@ class CreateVolume(command.ShowOne):
# volume from snapshot, backup or source volume
size = parsed_args.size
volume_client_sdk = self.app.client_manager.sdk_connection.volume
volume_client = self.app.client_manager.volume
volume_client = self.app.client_manager.sdk_connection.volume
image_client = self.app.client_manager.image
if (
@@ -285,8 +333,8 @@ class CreateVolume(command.ShowOne):
)
raise exceptions.CommandError(msg)
if parsed_args.backup and not (
volume_client.api_version.matches('3.47')
if parsed_args.backup and not sdk_utils.supports_microversion(
volume_client, '3.47'
):
msg = _(
"--os-volume-api-version 3.47 or greater is required "
@@ -308,9 +356,7 @@ class CreateVolume(command.ShowOne):
)
raise exceptions.CommandError(msg)
if parsed_args.cluster:
if not sdk_utils.supports_microversion(
volume_client_sdk, '3.16'
):
if not sdk_utils.supports_microversion(volume_client, '3.16'):
msg = _(
"--os-volume-api-version 3.16 or greater is required "
"to support the cluster parameter."
@@ -328,7 +374,7 @@ class CreateVolume(command.ShowOne):
"manage a volume."
)
raise exceptions.CommandError(msg)
volume = volume_client_sdk.manage_volume(
volume = volume_client.manage_volume(
host=parsed_args.host,
cluster=parsed_args.cluster,
ref=parsed_args.remote_source,
@@ -339,35 +385,22 @@ class CreateVolume(command.ShowOne):
metadata=parsed_args.properties,
bootable=parsed_args.bootable,
)
data = {}
for key, value in volume.to_dict().items():
# FIXME(stephenfin): Stop ignoring these once we bump SDK
# https://review.opendev.org/c/openstack/openstacksdk/+/945836/
if key in (
'cluster_name',
'consumes_quota',
'encryption_key_id',
'service_uuid',
'shared_targets',
'volume_type_id',
):
continue
data[key] = value
data = _format_volume(volume)
return zip(*sorted(data.items()))
source_volume = None
if parsed_args.source:
source_volume_obj = utils.find_resource(
volume_client.volumes, parsed_args.source
source_volume_obj = volume_client.find_volume(
parsed_args.source, ignore_missing=False
)
source_volume = source_volume_obj.id
size = max(size or 0, source_volume_obj.size)
consistency_group = None
if parsed_args.consistency_group:
consistency_group = utils.find_resource(
volume_client.consistencygroups, parsed_args.consistency_group
).id
consistency_group = volume_v3.find_consistency_group(
volume_client, parsed_args.consistency_group
)['id']
image = None
if parsed_args.image:
@@ -377,8 +410,8 @@ class CreateVolume(command.ShowOne):
snapshot = None
if parsed_args.snapshot:
snapshot_obj = utils.find_resource(
volume_client.volume_snapshots, parsed_args.snapshot
snapshot_obj = volume_client.find_snapshot(
parsed_args.snapshot, ignore_missing=False
)
snapshot = snapshot_obj.id
# Cinder requires a value for size when creating a volume
@@ -391,14 +424,14 @@ class CreateVolume(command.ShowOne):
backup = None
if parsed_args.backup:
backup_obj = utils.find_resource(
volume_client.backups, parsed_args.backup
backup_obj = volume_client.find_backup(
parsed_args.backup, ignore_missing=False
)
backup = backup_obj.id
# As above
size = max(size or 0, backup_obj.size)
volume = volume_client.volumes.create(
volume = volume_client.create_volume(
size=size,
snapshot_id=snapshot,
name=parsed_args.name,
@@ -406,9 +439,9 @@ class CreateVolume(command.ShowOne):
volume_type=parsed_args.type,
availability_zone=parsed_args.availability_zone,
metadata=parsed_args.properties,
imageRef=image,
source_volid=source_volume,
consistencygroup_id=consistency_group,
image_id=image,
source_volume_id=source_volume,
consistency_group_id=consistency_group,
scheduler_hints=parsed_args.hint,
backup_id=backup,
)
@@ -416,14 +449,14 @@ class CreateVolume(command.ShowOne):
if parsed_args.bootable is not None:
try:
if utils.wait_for_status(
volume_client.volumes.get,
volume_client.get_volume,
volume.id,
success_status=['available'],
error_status=['error'],
sleep_time=1,
):
volume_client.volumes.set_bootable(
volume.id, parsed_args.bootable
volume_client.set_volume_bootable_status(
volume, parsed_args.bootable
)
else:
msg = _(
@@ -436,14 +469,14 @@ class CreateVolume(command.ShowOne):
if parsed_args.read_only is not None:
try:
if utils.wait_for_status(
volume_client.volumes.get,
volume_client.get_volume,
volume.id,
success_status=['available'],
error_status=['error'],
sleep_time=1,
):
volume_client.volumes.update_readonly_flag(
volume.id, parsed_args.read_only
volume_client.set_volume_readonly(
volume, parsed_args.read_only
)
else:
msg = _(
@@ -457,17 +490,8 @@ class CreateVolume(command.ShowOne):
e,
)
# Remove key links from being displayed
volume._info.update(
{
'properties': format_columns.DictColumn(
volume._info.pop('metadata')
),
'type': volume._info.pop('volume_type'),
}
)
volume._info.pop("links", None)
return zip(*sorted(volume._info.items()))
data = _format_volume(volume)
return zip(*sorted(data.items()))
class DeleteVolume(command.Command):