diff --git a/releasenotes/notes/add_magnum_baymodel_support-e35e5aab0b14ff75.yaml b/releasenotes/notes/add_magnum_baymodel_support-e35e5aab0b14ff75.yaml new file mode 100644 index 000000000..9c4f9b015 --- /dev/null +++ b/releasenotes/notes/add_magnum_baymodel_support-e35e5aab0b14ff75.yaml @@ -0,0 +1,4 @@ +--- +features: + - Add support for Magnum baymodels, with the + usual methods (search/list/get/create/update/delete). diff --git a/shade/_tasks.py b/shade/_tasks.py index 6ddb8f57a..4783c04c0 100644 --- a/shade/_tasks.py +++ b/shade/_tasks.py @@ -907,3 +907,24 @@ class CinderQuotasGet(task_manager.Task): class CinderQuotasDelete(task_manager.Task): def main(self, client): return client.cinder_client.quotas.delete(**self.args) + + +class BaymodelList(task_manager.Task): + def main(self, client): + return client.magnum_client.baymodels.list(**self.args) + + +class BaymodelCreate(task_manager.Task): + def main(self, client): + return client.magnum_client.baymodels.create(**self.args) + + +class BaymodelDelete(task_manager.Task): + def main(self, client): + return client.magnum_client.baymodels.delete(self.args['id']) + + +class BaymodelUpdate(task_manager.Task): + def main(self, client): + return client.magnum_client.baymodels.update( + self.args['id'], self.args['patch']) diff --git a/shade/_utils.py b/shade/_utils.py index 851598f46..0e1f622a9 100644 --- a/shade/_utils.py +++ b/shade/_utils.py @@ -495,6 +495,13 @@ def normalize_flavors(flavors): return flavors +def normalize_baymodels(baymodels): + """Normalize Magnum baymodels.""" + for baymodel in baymodels: + baymodel['id'] = baymodel['uuid'] + return baymodels + + def valid_kwargs(*valid_args): # This decorator checks if argument passed as **kwargs to a function are # present in valid_args. @@ -759,3 +766,21 @@ def range_filter(data, key, range_exp): if int(d[key]) == val_range[1]: filtered.append(d) return filtered + + +def generate_patches_from_kwargs(operation, **kwargs): + """Given a set of parameters, returns a list with the + valid patch values. + + :param string operation: The operation to perform. + :param list kwargs: Dict of parameters. + + :returns: A list with the right patch values. + """ + patches = [] + for k, v in kwargs.items(): + patch = {'op': operation, + 'value': v, + 'path': '/%s' % k} + patches.append(patch) + return patches diff --git a/shade/openstackcloud.py b/shade/openstackcloud.py index cfbe2caa4..23f32c08b 100644 --- a/shade/openstackcloud.py +++ b/shade/openstackcloud.py @@ -30,6 +30,7 @@ import cinderclient.exceptions as cinder_exceptions import glanceclient import glanceclient.exc import heatclient.client +import magnumclient.exceptions as magnum_exceptions from heatclient.common import event_utils from heatclient.common import template_utils from heatclient import exc as heat_exceptions @@ -5662,3 +5663,158 @@ class OpenStackCloud(object): _tasks.RecordSetDelete(zone=zone['id'], recordset=name_or_id)) return True + + @_utils.cache_on_arguments() + def list_baymodels(self, detail=False): + """List Magnum baymodels. + + :param bool detail. Flag to control if we need summarized or + detailed output. + + :returns: a list of dicts containing the baymodel details. + + :raises: ``OpenStackCloudException``: if something goes wrong during + the openstack API call. + """ + with _utils.shade_exceptions("Error fetching baymodel list"): + baymodels = self.manager.submitTask( + _tasks.BaymodelList(detail=detail)) + return _utils.normalize_baymodels(baymodels) + + def search_baymodels(self, name_or_id=None, filters=None, detail=False): + """Search Magnum baymodels. + + :param name_or_id: baymodel name or ID. + :param filters: a dict containing additional filters to use. + :param detail: a boolean to control if we need summarized or + detailed output. + + :returns: a list of dict containing the baymodels + + :raises: ``OpenStackCloudException``: if something goes wrong during + the openstack API call. + """ + baymodels = self.list_baymodels(detail=detail) + return _utils._filter_list( + baymodels, name_or_id, filters) + + def get_baymodel(self, name_or_id, filters=None, detail=False): + """Get a baymodel by name or ID. + + :param name_or_id: Name or ID of the baymodel. + :param dict filters: + A dictionary of meta data to use for further filtering. Elements + of this dictionary may, themselves, be dictionaries. Example:: + + { + 'last_name': 'Smith', + 'other': { + 'gender': 'Female' + } + } + + :returns: A baymodel dict or None if no matching baymodel is + found. + + """ + return _utils._get_entity(self.search_baymodels, name_or_id, + filters=filters, detail=detail) + + def create_baymodel(self, name, image_id=None, keypair_id=None, + coe=None, **kwargs): + """Create a Magnum baymodel. + + :param string name: Name of the baymodel. + :param string image_id: Name or ID of the image to use. + :param string keypair_id: Name or ID of the keypair to use. + :param string coe: Name of the coe for the baymodel. + + Other arguments will be passed in kwargs. + + :returns: a dict containing the baymodel description + + :raises: ``OpenStackCloudException`` if something goes wrong during + the openstack API call + """ + with _utils.shade_exceptions( + "Error creating baymodel of name {baymodel_name}".format( + baymodel_name=name)): + baymodel = self.manager.submitTask( + _tasks.BaymodelCreate( + name=name, image_id=image_id, + keypair_id=keypair_id, coe=coe, **kwargs)) + + self.list_baymodels.invalidate(self) + return baymodel + + def delete_baymodel(self, name_or_id): + """Delete a baymodel. + + :param name_or_id: Name or unique ID of the baymodel. + :returns: True if the delete succeeded, False if the + baymodel was not found. + + :raises: OpenStackCloudException on operation error. + """ + + self.list_baymodels.invalidate(self) + baymodel = self.get_baymodel(name_or_id) + + if not baymodel: + self.log.debug( + "Baymodel {name_or_id} does not exist".format( + name_or_id=name_or_id), + exc_info=True) + return False + + with _utils.shade_exceptions("Error in deleting baymodel"): + try: + self.manager.submitTask( + _tasks.BaymodelDelete(id=baymodel['id'])) + except magnum_exceptions.NotFound: + self.log.debug( + "Baymodel {id} not found when deleting. Ignoring.".format( + id=baymodel['id'])) + return False + + self.list_baymodels.invalidate(self) + return True + + @_utils.valid_kwargs('name', 'image_id', 'flavor_id', 'master_flavor_id', + 'keypair_id', 'external_network_id', 'fixed_network', + 'dns_nameserver', 'docker_volume_size', 'labels', + 'coe', 'http_proxy', 'https_proxy', 'no_proxy', + 'network_driver', 'tls_disabled', 'public', + 'registry_enabled', 'volume_driver') + def update_baymodel(self, name_or_id, operation, **kwargs): + """Update a Magnum baymodel. + + :param name_or_id: Name or ID of the baymodel being updated. + :param operation: Operation to perform - add, remove, replace. + + Other arguments will be passed with kwargs. + + :returns: a dict representing the updated baymodel. + + :raises: OpenStackCloudException on operation error. + """ + self.list_baymodels.invalidate(self) + baymodel = self.get_baymodel(name_or_id) + if not baymodel: + raise OpenStackCloudException( + "Baymodel %s not found." % name_or_id) + + if operation not in ['add', 'replace', 'remove']: + raise TypeError( + "%s operation not in 'add', 'replace', 'remove'" % operation) + + patches = _utils.generate_patches_from_kwargs(operation, **kwargs) + + with _utils.shade_exceptions( + "Error updating baymodel {0}".format(name_or_id)): + self.manager.submitTask( + _tasks.BaymodelUpdate( + id=baymodel['id'], patch=patches)) + + new_baymodel = self.get_baymodel(name_or_id) + return new_baymodel diff --git a/shade/tests/functional/test_baymodels.py b/shade/tests/functional/test_baymodels.py new file mode 100644 index 000000000..c3d7e0283 --- /dev/null +++ b/shade/tests/functional/test_baymodels.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- + +# 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. + +""" +test_baymodels +---------------------------------- + +Functional tests for `shade` baymodel methods. +""" + +from testtools import content + +from shade.tests.functional import base + +import os +import subprocess + + +class TestBaymodel(base.BaseFunctionalTestCase): + + def setUp(self): + super(TestBaymodel, self).setUp() + if not self.demo_cloud.has_service('container'): + self.skipTest('Container service not supported by cloud') + self.baymodel = None + + def test_baymodels(self): + '''Test baymodels functionality''' + name = 'fake-baymodel' + server_type = 'vm' + public = False + image_id = 'fedora-atomic-f23-dib' + tls_disabled = False + registry_enabled = False + coe = 'kubernetes' + keypair_id = 'testkey' + + self.addDetail('baymodel', content.text_content(name)) + self.addCleanup(self.cleanup, name) + + # generate a keypair to add to nova + ssh_directory = '/tmp/.ssh' + if not os.path.isdir(ssh_directory): + os.mkdir(ssh_directory) + subprocess.call( + ['ssh-keygen', '-t', 'rsa', '-N', '', '-f', + '%s/id_rsa_shade' % ssh_directory]) + + # add keypair to nova + with open('%s/id_rsa_shade.pub' % ssh_directory) as f: + key_content = f.read() + self.demo_cloud.create_keypair('testkey', key_content) + + # Test we can create a baymodel and we get it returned + self.baymodel = self.demo_cloud.create_baymodel( + name=name, image_id=image_id, + keypair_id=keypair_id, coe=coe) + self.assertEquals(self.baymodel['name'], name) + self.assertEquals(self.baymodel['image_id'], image_id) + self.assertEquals(self.baymodel['keypair_id'], keypair_id) + self.assertEquals(self.baymodel['coe'], coe) + self.assertEquals(self.baymodel['registry_enabled'], registry_enabled) + self.assertEquals(self.baymodel['tls_disabled'], tls_disabled) + self.assertEquals(self.baymodel['public'], public) + self.assertEquals(self.baymodel['server_type'], server_type) + + # Test that we can list baymodels + baymodels = self.demo_cloud.list_baymodels() + self.assertIsNotNone(baymodels) + + # Test we get the same baymodel with the get_baymodel method + baymodel_get = self.demo_cloud.get_baymodel(self.baymodel['uuid']) + self.assertEquals(baymodel_get['uuid'], self.baymodel['uuid']) + + # Test the get method also works by name + baymodel_get = self.demo_cloud.get_baymodel(name) + self.assertEquals(baymodel_get['name'], self.baymodel['name']) + + # Test we can update a field on the baymodel and only that field + # is updated + baymodel_update = self.demo_cloud.update_baymodel( + self.baymodel['uuid'], 'replace', tls_disabled=True) + self.assertEquals(baymodel_update['uuid'], + self.baymodel['uuid']) + self.assertEquals(baymodel_update['tls_disabled'], True) + + # Test we can delete and get True returned + baymodel_delete = self.demo_cloud.delete_baymodel( + self.baymodel['uuid']) + self.assertTrue(baymodel_delete) + + def cleanup(self, name): + if self.baymodel: + try: + self.demo_cloud.delete_baymodel(self.baymodel['name']) + except: + pass + + # delete keypair + self.demo_cloud.delete_keypair('testkey') + os.unlink('/tmp/.ssh/id_rsa_shade') + os.unlink('/tmp/.ssh/id_rsa_shade.pub') diff --git a/shade/tests/unit/test_baymodels.py b/shade/tests/unit/test_baymodels.py new file mode 100644 index 000000000..04d9c8fc8 --- /dev/null +++ b/shade/tests/unit/test_baymodels.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- + +# 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. + + +import mock +import munch + +import shade +import testtools +from shade.tests.unit import base + + +baymodel_obj = munch.Munch( + apiserver_port=None, + uuid='fake-uuid', + human_id=None, + name='fake-baymodel', + server_type='vm', + public=False, + image_id='fake-image', + tls_disabled=False, + registry_enabled=False, + coe='fake-coe', + keypair_id='fake-key', +) + +baymodel_detail_obj = munch.Munch( + links={}, + labels={}, + apiserver_port=None, + uuid='fake-uuid', + human_id=None, + name='fake-baymodel', + server_type='vm', + public=False, + image_id='fake-image', + tls_disabled=False, + registry_enabled=False, + coe='fake-coe', + created_at='fake-date', + updated_at=None, + master_flavor_id=None, + no_proxy=None, + https_proxy=None, + keypair_id='fake-key', + docker_volume_size=1, + external_network_id='public', + cluster_distro='fake-distro', + volume_driver=None, + network_driver='fake-driver', + fixed_network=None, + flavor_id='fake-flavor', + dns_nameserver='8.8.8.8', +) + + +class TestBaymodels(base.TestCase): + + def setUp(self): + super(TestBaymodels, self).setUp() + self.cloud = shade.openstack_cloud(validate=False) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_list_baymodels_without_detail(self, mock_magnum): + mock_magnum.baymodels.list.return_value = [baymodel_obj, ] + baymodels_list = self.cloud.list_baymodels() + mock_magnum.baymodels.list.assert_called_with(detail=False) + self.assertEqual(baymodels_list[0], baymodel_obj) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_list_baymodels_with_detail(self, mock_magnum): + mock_magnum.baymodels.list.return_value = [baymodel_detail_obj, ] + baymodels_list = self.cloud.list_baymodels(detail=True) + mock_magnum.baymodels.list.assert_called_with(detail=True) + self.assertEqual(baymodels_list[0], baymodel_detail_obj) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_search_baymodels_by_name(self, mock_magnum): + mock_magnum.baymodels.list.return_value = [baymodel_obj, ] + + baymodels = self.cloud.search_baymodels(name_or_id='fake-baymodel') + mock_magnum.baymodels.list.assert_called_with(detail=False) + + self.assertEquals(1, len(baymodels)) + self.assertEquals('fake-uuid', baymodels[0]['uuid']) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_search_baymodels_not_found(self, mock_magnum): + mock_magnum.baymodels.list.return_value = [baymodel_obj, ] + + baymodels = self.cloud.search_baymodels(name_or_id='non-existent') + + mock_magnum.baymodels.list.assert_called_with(detail=False) + self.assertEquals(0, len(baymodels)) + + @mock.patch.object(shade.OpenStackCloud, 'search_baymodels') + def test_get_baymodel(self, mock_search): + mock_search.return_value = [baymodel_obj, ] + r = self.cloud.get_baymodel('fake-baymodel') + self.assertIsNotNone(r) + self.assertDictEqual(baymodel_obj, r) + + @mock.patch.object(shade.OpenStackCloud, 'search_baymodels') + def test_get_baymodel_not_found(self, mock_search): + mock_search.return_value = [] + r = self.cloud.get_baymodel('doesNotExist') + self.assertIsNone(r) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_create_baymodel(self, mock_magnum): + self.cloud.create_baymodel( + name=baymodel_obj.name, image_id=baymodel_obj.image_id, + keypair_id=baymodel_obj.keypair_id, coe=baymodel_obj.coe) + mock_magnum.baymodels.create.assert_called_once_with( + name=baymodel_obj.name, image_id=baymodel_obj.image_id, + keypair_id=baymodel_obj.keypair_id, coe=baymodel_obj.coe + ) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_create_baymodel_exception(self, mock_magnum): + mock_magnum.baymodels.create.side_effect = Exception() + with testtools.ExpectedException( + shade.OpenStackCloudException, + "Error creating baymodel of name fake-baymodel" + ): + self.cloud.create_baymodel('fake-baymodel') + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_delete_baymodel(self, mock_magnum): + mock_magnum.baymodels.list.return_value = [baymodel_obj] + self.cloud.delete_baymodel('fake-uuid') + mock_magnum.baymodels.delete.assert_called_once_with( + 'fake-uuid' + ) + + @mock.patch.object(shade.OpenStackCloud, 'magnum_client') + def test_update_baymodel(self, mock_magnum): + new_name = 'new-baymodel' + mock_magnum.baymodels.list.return_value = [baymodel_obj] + self.cloud.update_baymodel('fake-uuid', 'replace', name=new_name) + mock_magnum.baymodels.update.assert_called_once_with( + 'fake-uuid', [{'path': '/name', 'op': 'replace', + 'value': 'new-baymodel'}] + )