diff --git a/doc/source/command-objects/image.rst b/doc/source/command-objects/image.rst index d9b77266f..63b55b89a 100644 --- a/doc/source/command-objects/image.rst +++ b/doc/source/command-objects/image.rst @@ -139,12 +139,21 @@ List available images os image list [--page-size ] + [--public|--private] [--long] .. option:: --page-size Number of images to request in each paginated request +.. option:: --public + + List only public images + +.. option:: --private + + List only private images + .. option:: --long List additional fields in output diff --git a/openstackclient/api/api.py b/openstackclient/api/api.py index 90b4e9c38..ba83ce4d5 100644 --- a/openstackclient/api/api.py +++ b/openstackclient/api/api.py @@ -161,7 +161,7 @@ class BaseAPI(KeystoneSession): ): """Return a list of resources - GET ${ENDPOINT}/${PATH} + GET ${ENDPOINT}/${PATH}?${PARAMS} path is often the object's plural resource type diff --git a/openstackclient/api/image_v1.py b/openstackclient/api/image_v1.py new file mode 100644 index 000000000..f9c780a42 --- /dev/null +++ b/openstackclient/api/image_v1.py @@ -0,0 +1,68 @@ +# 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. +# + +"""Image v1 API Library""" + +from openstackclient.api import api + + +class APIv1(api.BaseAPI): + """Image v1 API""" + + def __init__(self, endpoint=None, **kwargs): + super(APIv1, self).__init__(endpoint=endpoint, **kwargs) + + # Hack this until discovery is up + self.endpoint = '/'.join([self.endpoint.rstrip('/'), 'v1']) + + def image_list( + self, + detailed=False, + public=False, + private=False, + **filter + ): + """Get available images + + :param detailed: + Retrieve detailed response from server if True + :param public: + Return public images if True + :param private: + Return private images if True + + If public and private are both True or both False then all images are + returned. Both arguments False is equivalent to no filter and all + images are returned. Both arguments True is a filter that includes + both public and private images which is the same set as all images. + + http://docs.openstack.org/api/openstack-image-service/1.1/content/requesting-a-list-of-public-vm-images.html + http://docs.openstack.org/api/openstack-image-service/1.1/content/requesting-detailed-metadata-on-public-vm-images.html + http://docs.openstack.org/api/openstack-image-service/1.1/content/filtering-images-returned-via-get-images-and-get-imagesdetail.html + + TODO(dtroyer): Implement filtering + """ + + url = "/images" + if detailed or public or private: + # Because we can't all use /details + url += "/detail" + + image_list = self.list(url, **filter)['images'] + + if public != private: + # One is True and one is False, so public represents the filter + # state in either case + image_list = [i for i in image_list if i['is_public'] == public] + + return image_list diff --git a/openstackclient/api/image_v2.py b/openstackclient/api/image_v2.py new file mode 100644 index 000000000..c5c78431f --- /dev/null +++ b/openstackclient/api/image_v2.py @@ -0,0 +1,69 @@ +# 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. +# + +"""Image v2 API Library""" + +from openstackclient.api import image_v1 + + +class APIv2(image_v1.APIv1): + """Image v2 API""" + + def __init__(self, endpoint=None, **kwargs): + super(APIv2, self).__init__(endpoint=endpoint, **kwargs) + + # Hack this until discovery is up, and ignore parent endpoint setting + self.endpoint = '/'.join([endpoint.rstrip('/'), 'v2']) + + def image_list( + self, + detailed=False, + public=False, + private=False, + **filter + ): + """Get available images + + can add limit/marker + + :param detailed: + For v1 compatibility only, ignored as v2 is always 'detailed' + :param public: + Return public images if True + :param private: + Return private images if True + + If public and private are both True or both False then all images are + returned. Both arguments False is equivalent to no filter and all + images are returned. Both arguments True is a filter that includes + both public and private images which is the same set as all images. + + http://docs.openstack.org/api/openstack-image-service/2.0/content/list-images.html + + TODO(dtroyer): Implement filtering + """ + + if public == private: + # No filtering for both False and both True cases + filter.pop('visibility', None) + elif public: + filter['visibility'] = 'public' + elif private: + filter['visibility'] = 'private' + + url = "/images" + if detailed: + # Because we can't all use /details + url += "/detail" + + return self.list(url, **filter)['images'] diff --git a/openstackclient/image/client.py b/openstackclient/image/client.py index c55ff8536..357796648 100644 --- a/openstackclient/image/client.py +++ b/openstackclient/image/client.py @@ -31,9 +31,15 @@ API_VERSIONS = { "2": "glanceclient.v2.client.Client", } +IMAGE_API_TYPE = 'image' +IMAGE_API_VERSIONS = { + '1': 'openstackclient.api.image_v1.APIv1', + '2': 'openstackclient.api.image_v2.APIv2', +} + def make_client(instance): - """Returns an image service client.""" + """Returns an image service client""" image_client = utils.get_client_class( API_NAME, instance._api_version[API_NAME], @@ -45,13 +51,31 @@ def make_client(instance): region_name=instance._region_name, ) - return image_client( + client = image_client( endpoint, token=instance.auth.get_token(instance.session), cacert=instance._cacert, insecure=instance._insecure, ) + # Create the low-level API + + image_api = utils.get_client_class( + API_NAME, + instance._api_version[API_NAME], + IMAGE_API_VERSIONS) + LOG.debug('Instantiating image api: %s', image_api) + + client.api = image_api( + session=instance.session, + endpoint=instance.get_endpoint_for_service_type( + IMAGE_API_TYPE, + region_name=instance._region_name, + ) + ) + + return client + def build_option_parser(parser): """Hook to add global options""" diff --git a/openstackclient/image/v1/image.py b/openstackclient/image/v1/image.py index d7ece2546..fc70000d5 100644 --- a/openstackclient/image/v1/image.py +++ b/openstackclient/image/v1/image.py @@ -300,6 +300,21 @@ class ListImage(lister.Lister): metavar="", help="Number of images to request in each paginated request", ) + public_group = parser.add_mutually_exclusive_group() + public_group.add_argument( + "--public", + dest="public", + action="store_true", + default=False, + help="List only public images", + ) + public_group.add_argument( + "--private", + dest="private", + action="store_true", + default=False, + help="List only private images", + ) parser.add_argument( '--long', action='store_true', @@ -316,15 +331,21 @@ class ListImage(lister.Lister): kwargs = {} if parsed_args.page_size is not None: kwargs["page_size"] = parsed_args.page_size + if parsed_args.public: + kwargs['public'] = True + if parsed_args.private: + kwargs['private'] = True + kwargs['detailed'] = parsed_args.long - data = image_client.images.list(**kwargs) if parsed_args.long: columns = ('ID', 'Name', 'Disk Format', 'Container Format', 'Size', 'Status') else: columns = ("ID", "Name") - return (columns, (utils.get_item_properties(s, columns) for s in data)) + data = image_client.api.image_list(**kwargs) + + return (columns, (utils.get_dict_properties(s, columns) for s in data)) class SaveImage(command.Command): diff --git a/openstackclient/image/v2/image.py b/openstackclient/image/v2/image.py index d5ee692ce..2e0fd393e 100644 --- a/openstackclient/image/v2/image.py +++ b/openstackclient/image/v2/image.py @@ -65,6 +65,21 @@ class ListImage(lister.Lister): metavar="", help="Number of images to request in each paginated request", ) + public_group = parser.add_mutually_exclusive_group() + public_group.add_argument( + "--public", + dest="public", + action="store_true", + default=False, + help="List only public images", + ) + public_group.add_argument( + "--private", + dest="private", + action="store_true", + default=False, + help="List only private images", + ) parser.add_argument( '--long', action='store_true', @@ -81,15 +96,21 @@ class ListImage(lister.Lister): kwargs = {} if parsed_args.page_size is not None: kwargs["page_size"] = parsed_args.page_size + if parsed_args.public: + kwargs['public'] = True + if parsed_args.private: + kwargs['private'] = True + kwargs['detailed'] = parsed_args.long - data = image_client.images.list(**kwargs) if parsed_args.long: columns = ('ID', 'Name', 'Disk Format', 'Container Format', 'Size', 'Status') else: columns = ("ID", "Name") - return (columns, (utils.get_item_properties(s, columns) for s in data)) + data = image_client.api.image_list(**kwargs) + + return (columns, (utils.get_dict_properties(s, columns) for s in data)) class SaveImage(command.Command): diff --git a/openstackclient/tests/api/test_image_v1.py b/openstackclient/tests/api/test_image_v1.py new file mode 100644 index 000000000..34fcfca44 --- /dev/null +++ b/openstackclient/tests/api/test_image_v1.py @@ -0,0 +1,98 @@ +# 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. +# + +"""Image v1 API Library Tests""" + +from requests_mock.contrib import fixture + +from keystoneclient import session +from openstackclient.api import image_v1 +from openstackclient.tests import utils + + +FAKE_PROJECT = 'xyzpdq' +FAKE_URL = 'http://gopher.com' + + +class TestImageAPIv1(utils.TestCase): + + def setUp(self): + super(TestImageAPIv1, self).setUp() + + sess = session.Session() + self.api = image_v1.APIv1(session=sess, endpoint=FAKE_URL) + self.requests_mock = self.useFixture(fixture.Fixture()) + + +class TestImage(TestImageAPIv1): + + PUB_PROT = { + 'id': '1', + 'name': 'pub1', + 'is_public': True, + 'protected': True, + } + PUB_NOPROT = { + 'id': '2', + 'name': 'pub2-noprot', + 'is_public': True, + 'protected': False, + } + NOPUB_PROT = { + 'id': '3', + 'name': 'priv3', + 'is_public': False, + 'protected': True, + } + NOPUB_NOPROT = { + 'id': '4', + 'name': 'priv4-noprot', + 'is_public': False, + 'protected': False, + } + LIST_IMAGE_RESP = [ + PUB_PROT, + PUB_NOPROT, + NOPUB_PROT, + NOPUB_NOPROT, + ] + + def test_image_list_no_options(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + '/v1/images', + json={'images': self.LIST_IMAGE_RESP}, + status_code=200, + ) + ret = self.api.image_list() + self.assertEqual(self.LIST_IMAGE_RESP, ret) + + def test_image_list_public(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + '/v1/images/detail', + json={'images': self.LIST_IMAGE_RESP}, + status_code=200, + ) + ret = self.api.image_list(public=True) + self.assertEqual([self.PUB_PROT, self.PUB_NOPROT], ret) + + def test_image_list_private(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + '/v1/images/detail', + json={'images': self.LIST_IMAGE_RESP}, + status_code=200, + ) + ret = self.api.image_list(private=True) + self.assertEqual([self.NOPUB_PROT, self.NOPUB_NOPROT], ret) diff --git a/openstackclient/tests/api/test_image_v2.py b/openstackclient/tests/api/test_image_v2.py new file mode 100644 index 000000000..ddb160eea --- /dev/null +++ b/openstackclient/tests/api/test_image_v2.py @@ -0,0 +1,98 @@ +# 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. +# + +"""Image v2 API Library Tests""" + +from requests_mock.contrib import fixture + +from keystoneclient import session +from openstackclient.api import image_v2 +from openstackclient.tests import utils + + +FAKE_PROJECT = 'xyzpdq' +FAKE_URL = 'http://gopher.com' + + +class TestImageAPIv2(utils.TestCase): + + def setUp(self): + super(TestImageAPIv2, self).setUp() + + sess = session.Session() + self.api = image_v2.APIv2(session=sess, endpoint=FAKE_URL) + self.requests_mock = self.useFixture(fixture.Fixture()) + + +class TestImage(TestImageAPIv2): + + PUB_PROT = { + 'id': '1', + 'name': 'pub1', + 'visibility': 'public', + 'protected': True, + } + PUB_NOPROT = { + 'id': '2', + 'name': 'pub2-noprot', + 'visibility': 'public', + 'protected': False, + } + NOPUB_PROT = { + 'id': '3', + 'name': 'priv3', + 'visibility': 'private', + 'protected': True, + } + NOPUB_NOPROT = { + 'id': '4', + 'name': 'priv4-noprot', + 'visibility': 'private', + 'protected': False, + } + LIST_IMAGE_RESP = [ + PUB_PROT, + PUB_NOPROT, + NOPUB_PROT, + NOPUB_NOPROT, + ] + + def test_image_list_no_options(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + '/v2/images', + json={'images': self.LIST_IMAGE_RESP}, + status_code=200, + ) + ret = self.api.image_list() + self.assertEqual(self.LIST_IMAGE_RESP, ret) + + def test_image_list_public(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + '/v2/images', + json={'images': [self.PUB_PROT, self.PUB_NOPROT]}, + status_code=200, + ) + ret = self.api.image_list(public=True) + self.assertEqual([self.PUB_PROT, self.PUB_NOPROT], ret) + + def test_image_list_private(self): + self.requests_mock.register_uri( + 'GET', + FAKE_URL + '/v2/images', + json={'images': [self.NOPUB_PROT, self.NOPUB_NOPROT]}, + status_code=200, + ) + ret = self.api.image_list(public=True) + self.assertEqual([self.NOPUB_PROT, self.NOPUB_NOPROT], ret) diff --git a/openstackclient/tests/image/v1/test_image.py b/openstackclient/tests/image/v1/test_image.py index 4d21ac485..95126c2de 100644 --- a/openstackclient/tests/image/v1/test_image.py +++ b/openstackclient/tests/image/v1/test_image.py @@ -300,6 +300,128 @@ class TestImageDelete(TestImage): ) +class TestImageList(TestImage): + + def setUp(self): + super(TestImageList, self).setUp() + + self.api_mock = mock.Mock() + self.api_mock.image_list.return_value = [ + copy.deepcopy(image_fakes.IMAGE), + ] + self.app.client_manager.image.api = self.api_mock + + # Get the command object to test + self.cmd = image.ListImage(self.app, None) + + def test_image_list_no_options(self): + arglist = [] + verifylist = [ + ('public', False), + ('private', False), + ('long', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=False, + ) + + collist = ('ID', 'Name') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + ), ) + self.assertEqual(datalist, tuple(data)) + + def test_image_list_public_option(self): + arglist = [ + '--public', + ] + verifylist = [ + ('public', True), + ('private', False), + ('long', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=False, + public=True, + ) + + collist = ('ID', 'Name') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + ), ) + self.assertEqual(datalist, tuple(data)) + + def test_image_list_private_option(self): + arglist = [ + '--private', + ] + verifylist = [ + ('public', False), + ('private', True), + ('long', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=False, + private=True, + ) + + collist = ('ID', 'Name') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + ), ) + self.assertEqual(datalist, tuple(data)) + + def test_image_list_long_option(self): + arglist = [ + '--long', + ] + verifylist = [ + ('long', True), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=True, + ) + + collist = ('ID', 'Name', 'Disk Format', 'Container Format', + 'Size', 'Status') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + '', + '', + '', + '', + ), ) + self.assertEqual(datalist, tuple(data)) + + class TestImageSet(TestImage): def setUp(self): @@ -453,48 +575,3 @@ class TestImageSet(TestImage): image_fakes.image_id, **kwargs ) - - -class TestImageList(TestImage): - - def setUp(self): - super(TestImageList, self).setUp() - - # This is the return value for utils.find_resource() - self.images_mock.list.return_value = [ - fakes.FakeResource( - None, - copy.deepcopy(image_fakes.IMAGE), - loaded=True, - ), - ] - - # Get the command object to test - self.cmd = image.ListImage(self.app, None) - - def test_image_list_long_option(self): - arglist = [ - '--long', - ] - verifylist = [ - ('long', True), - ] - parsed_args = self.check_parser(self.cmd, arglist, verifylist) - - # DisplayCommandBase.take_action() returns two tuples - columns, data = self.cmd.take_action(parsed_args) - self.images_mock.list.assert_called_with() - - collist = ('ID', 'Name', 'Disk Format', 'Container Format', - 'Size', 'Status') - - self.assertEqual(columns, collist) - datalist = (( - image_fakes.image_id, - image_fakes.image_name, - '', - '', - '', - '', - ), ) - self.assertEqual(datalist, tuple(data)) diff --git a/openstackclient/tests/image/v2/test_image.py b/openstackclient/tests/image/v2/test_image.py index bc61a89fb..8fb31caaa 100644 --- a/openstackclient/tests/image/v2/test_image.py +++ b/openstackclient/tests/image/v2/test_image.py @@ -14,6 +14,7 @@ # import copy +import mock from openstackclient.image.v2 import image from openstackclient.tests import fakes @@ -68,18 +69,93 @@ class TestImageList(TestImage): def setUp(self): super(TestImageList, self).setUp() - # This is the return value for utils.find_resource() - self.images_mock.list.return_value = [ - fakes.FakeResource( - None, - copy.deepcopy(image_fakes.IMAGE), - loaded=True, - ), + self.api_mock = mock.Mock() + self.api_mock.image_list.return_value = [ + copy.deepcopy(image_fakes.IMAGE), ] + self.app.client_manager.image.api = self.api_mock # Get the command object to test self.cmd = image.ListImage(self.app, None) + def test_image_list_no_options(self): + arglist = [] + verifylist = [ + ('public', False), + ('private', False), + ('long', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=False, + ) + + collist = ('ID', 'Name') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + ), ) + self.assertEqual(datalist, tuple(data)) + + def test_image_list_public_option(self): + arglist = [ + '--public', + ] + verifylist = [ + ('public', True), + ('private', False), + ('long', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=False, + public=True, + ) + + collist = ('ID', 'Name') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + ), ) + self.assertEqual(datalist, tuple(data)) + + def test_image_list_private_option(self): + arglist = [ + '--private', + ] + verifylist = [ + ('public', False), + ('private', True), + ('long', False), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + self.api_mock.image_list.assert_called_with( + detailed=False, + private=True, + ) + + collist = ('ID', 'Name') + + self.assertEqual(columns, collist) + datalist = (( + image_fakes.image_id, + image_fakes.image_name, + ), ) + self.assertEqual(datalist, tuple(data)) + def test_image_list_long_option(self): arglist = [ '--long', @@ -91,7 +167,9 @@ class TestImageList(TestImage): # DisplayCommandBase.take_action() returns two tuples columns, data = self.cmd.take_action(parsed_args) - self.images_mock.list.assert_called_with() + self.api_mock.image_list.assert_called_with( + detailed=True, + ) collist = ('ID', 'Name', 'Disk Format', 'Container Format', 'Size', 'Status')