diff --git a/releasenotes/notes/add_host_aggregate_support-471623faf45ec3c3.yaml b/releasenotes/notes/add_host_aggregate_support-471623faf45ec3c3.yaml new file mode 100644 index 000000000..6a6ff37a1 --- /dev/null +++ b/releasenotes/notes/add_host_aggregate_support-471623faf45ec3c3.yaml @@ -0,0 +1,4 @@ +--- +features: + - Add support for host aggregates and host aggregate + membership. diff --git a/shade/_tasks.py b/shade/_tasks.py index 8c2e10821..10fbacb52 100644 --- a/shade/_tasks.py +++ b/shade/_tasks.py @@ -177,6 +177,41 @@ class HypervisorList(task_manager.Task): return client.nova_client.hypervisors.list(**self.args) +class AggregateList(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.list(**self.args) + + +class AggregateCreate(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.create(**self.args) + + +class AggregateUpdate(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.update(**self.args) + + +class AggregateDelete(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.delete(**self.args) + + +class AggregateAddHost(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.add_host(**self.args) + + +class AggregateRemoveHost(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.remove_host(**self.args) + + +class AggregateSetMetadata(task_manager.Task): + def main(self, client): + return client.nova_client.aggregates.set_metadata(**self.args) + + class KeypairList(task_manager.Task): def main(self, client): return client.nova_client.keypairs.list() diff --git a/shade/operatorcloud.py b/shade/operatorcloud.py index 017ffd437..7fb79c293 100644 --- a/shade/operatorcloud.py +++ b/shade/operatorcloud.py @@ -1764,3 +1764,169 @@ class OperatorCloud(openstackcloud.OpenStackCloud): with _utils.shade_exceptions("Error fetching hypervisor list"): return self.manager.submitTask(_tasks.HypervisorList()) + + def search_aggregates(self, name_or_id=None, filters=None): + """Seach host aggregates. + + :param name: aggregate name or id. + :param filters: a dict containing additional filters to use. + + :returns: a list of dicts containing the aggregates + + :raises: ``OpenStackCloudException``: if something goes wrong during + the openstack API call. + """ + aggregates = self.list_aggregates() + return _utils._filter_list(aggregates, name_or_id, filters) + + def list_aggregates(self): + """List all available host aggregates. + + :returns: A list of aggregate dicts. + + """ + with _utils.shade_exceptions("Error fetching aggregate list"): + return self.manager.submitTask(_tasks.AggregateList()) + + def get_aggregate(self, name_or_id, filters=None): + """Get an aggregate by name or ID. + + :param name_or_id: Name or ID of the aggregate. + :param dict filters: + A dictionary of meta data to use for further filtering. Elements + of this dictionary may, themselves, be dictionaries. Example:: + + { + 'availability_zone': 'nova', + 'metadata': { + 'cpu_allocation_ratio': '1.0' + } + } + + :returns: An aggregate dict or None if no matching aggregate is + found. + + """ + return _utils._get_entity(self.search_aggregates, name_or_id, filters) + + def create_aggregate(self, name, availability_zone=None): + """Create a new host aggregate. + + :param name: Name of the host aggregate being created + :param availability_zone: Availability zone to assign hosts + + :returns: a dict representing the new host aggregate. + + :raises: OpenStackCloudException on operation error. + """ + with _utils.shade_exceptions( + "Unable to create host aggregate {name}".format( + name=name)): + return self.manager.submitTask(_tasks.AggregateCreate( + name=name, availability_zone=availability_zone)) + + @_utils.valid_kwargs('name', 'availability_zone') + def update_aggregate(self, name_or_id, **kwargs): + """Update a host aggregate. + + :param name_or_id: Name or ID of the aggregate being updated. + :param name: New aggregate name + :param availability_zone: Availability zone to assign to hosts + + :returns: a dict representing the updated host aggregate. + + :raises: OpenStackCloudException on operation error. + """ + aggregate = self.get_aggregate(name_or_id) + if not aggregate: + raise OpenStackCloudException( + "Host aggregate %s not found." % name_or_id) + + with _utils.shade_exceptions( + "Error updating aggregate {name}".format(name=name_or_id)): + new_aggregate = self.manager.submitTask( + _tasks.AggregateUpdate( + aggregate=aggregate['id'], values=kwargs)) + + return new_aggregate + + def delete_aggregate(self, name_or_id): + """Delete a host aggregate. + + :param name_or_id: Name or ID of the host aggregate to delete. + + :returns: True if delete succeeded, False otherwise. + + :raises: OpenStackCloudException on operation error. + """ + aggregate = self.get_aggregate(name_or_id) + if not aggregate: + self.log.debug("Aggregate %s not found for deleting" % name_or_id) + return False + + with _utils.shade_exceptions( + "Error deleting aggregate {name}".format(name=name_or_id)): + self.manager.submitTask( + _tasks.AggregateDelete(aggregate=aggregate['id'])) + + return True + + def set_aggregate_metadata(self, name_or_id, metadata): + """Set aggregate metadata, replacing the existing metadata. + + :param name_or_id: Name of the host aggregate to update + :param metadata: Dict containing metadata to replace (Use + {'key': None} to remove a key) + + :returns: a dict representing the new host aggregate. + + :raises: OpenStackCloudException on operation error. + """ + aggregate = self.get_aggregate(name_or_id) + if not aggregate: + raise OpenStackCloudException( + "Host aggregate %s not found." % name_or_id) + + with _utils.shade_exceptions( + "Unable to set metadata for host aggregate {name}".format( + name=name_or_id)): + return self.manager.submitTask(_tasks.AggregateSetMetadata( + aggregate=aggregate['id'], metadata=metadata)) + + def add_host_to_aggregate(self, name_or_id, host_name): + """Add a host to an aggregate. + + :param name_or_id: Name or ID of the host aggregate. + :param host_name: Host to add. + + :raises: OpenStackCloudException on operation error. + """ + aggregate = self.get_aggregate(name_or_id) + if not aggregate: + raise OpenStackCloudException( + "Host aggregate %s not found." % name_or_id) + + with _utils.shade_exceptions( + "Unable to add host {host} to aggregate {name}".format( + name=name_or_id, host=host_name)): + return self.manager.submitTask(_tasks.AggregateAddHost( + aggregate=aggregate['id'], host=host_name)) + + def remove_host_from_aggregate(self, name_or_id, host_name): + """Remove a host from an aggregate. + + :param name_or_id: Name or ID of the host aggregate. + :param host_name: Host to remove. + + :raises: OpenStackCloudException on operation error. + """ + aggregate = self.get_aggregate(name_or_id) + if not aggregate: + raise OpenStackCloudException( + "Host aggregate %s not found." % name_or_id) + + with _utils.shade_exceptions( + "Unable to remove host {host} from aggregate {name}".format( + name=name_or_id, host=host_name)): + return self.manager.submitTask(_tasks.AggregateRemoveHost( + aggregate=aggregate['id'], host=host_name)) diff --git a/shade/tests/fakes.py b/shade/tests/fakes.py index 01cb822fb..53e068590 100644 --- a/shade/tests/fakes.py +++ b/shade/tests/fakes.py @@ -272,3 +272,17 @@ class FakeRecordset(object): self.description = description self.ttl = ttl self.records = records + + +class FakeAggregate(object): + def __init__(self, id, name, availability_zone=None, metadata=None, + hosts=None): + self.id = id + self.name = name + self.availability_zone = availability_zone + if not metadata: + metadata = {} + self.metadata = metadata + if not hosts: + hosts = [] + self.hosts = hosts diff --git a/shade/tests/functional/test_aggregate.py b/shade/tests/functional/test_aggregate.py new file mode 100644 index 000000000..7e734e43c --- /dev/null +++ b/shade/tests/functional/test_aggregate.py @@ -0,0 +1,63 @@ +# -*- 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_aggregate +---------------------------------- + +Functional tests for `shade` aggregate resource. +""" + +from shade.tests.functional import base + + +class TestAggregate(base.BaseFunctionalTestCase): + + def setUp(self): + super(TestAggregate, self).setUp() + + def test_aggregates(self): + aggregate_name = self.getUniqueString() + availability_zone = self.getUniqueString() + self.addCleanup(self.cleanup, aggregate_name) + aggregate = self.operator_cloud.create_aggregate(aggregate_name) + + aggregate_ids = [v['id'] + for v in self.operator_cloud.list_aggregates()] + self.assertIn(aggregate['id'], aggregate_ids) + + aggregate = self.operator_cloud.update_aggregate( + aggregate_name, + availability_zone=availability_zone + ) + self.assertEqual(availability_zone, aggregate['availability_zone']) + + aggregate = self.operator_cloud.set_aggregate_metadata( + aggregate_name, + {'key': 'value'} + ) + self.assertIn('key', aggregate['metadata']) + + aggregate = self.operator_cloud.set_aggregate_metadata( + aggregate_name, + {'key': None} + ) + self.assertNotIn('key', aggregate['metadata']) + + self.operator_cloud.delete_aggregate(aggregate_name) + + def cleanup(self, aggregate_name): + aggregate = self.operator_cloud.get_aggregate(aggregate_name) + if aggregate: + self.operator_cloud.delete_aggregate(aggregate_name) diff --git a/shade/tests/unit/test_aggregate.py b/shade/tests/unit/test_aggregate.py new file mode 100644 index 000000000..02f8859df --- /dev/null +++ b/shade/tests/unit/test_aggregate.py @@ -0,0 +1,116 @@ +# -*- 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 shade +from shade.tests.unit import base +from shade.tests import fakes + + +class TestAggregate(base.TestCase): + + def setUp(self): + super(TestAggregate, self).setUp() + self.cloud = shade.operator_cloud(validate=False) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_create_aggregate(self, mock_nova): + aggregate_name = 'aggr1' + self.cloud.create_aggregate(name=aggregate_name) + + mock_nova.aggregates.create.assert_called_once_with( + name=aggregate_name, availability_zone=None + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_create_aggregate_with_az(self, mock_nova): + aggregate_name = 'aggr1' + availability_zone = 'az1' + self.cloud.create_aggregate(name=aggregate_name, + availability_zone=availability_zone) + + mock_nova.aggregates.create.assert_called_once_with( + name=aggregate_name, availability_zone=availability_zone + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_delete_aggregate(self, mock_nova): + mock_nova.aggregates.list.return_value = [ + fakes.FakeAggregate('1234', 'name') + ] + self.assertTrue(self.cloud.delete_aggregate('1234')) + mock_nova.aggregates.list.assert_called_once_with() + mock_nova.aggregates.delete.assert_called_once_with( + aggregate='1234' + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_update_aggregate_set_az(self, mock_nova): + mock_nova.aggregates.list.return_value = [ + fakes.FakeAggregate('1234', 'name') + ] + self.cloud.update_aggregate('1234', availability_zone='az') + mock_nova.aggregates.update.assert_called_once_with( + aggregate='1234', + values={'availability_zone': 'az'}, + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_update_aggregate_unset_az(self, mock_nova): + mock_nova.aggregates.list.return_value = [ + fakes.FakeAggregate('1234', 'name', availability_zone='az') + ] + self.cloud.update_aggregate('1234', availability_zone=None) + mock_nova.aggregates.update.assert_called_once_with( + aggregate='1234', + values={'availability_zone': None}, + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_set_aggregate_metadata(self, mock_nova): + metadata = {'key', 'value'} + mock_nova.aggregates.list.return_value = [ + fakes.FakeAggregate('1234', 'name') + ] + self.cloud.set_aggregate_metadata('1234', metadata) + mock_nova.aggregates.set_metadata.assert_called_once_with( + aggregate='1234', + metadata=metadata + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_add_host_to_aggregate(self, mock_nova): + hostname = 'host1' + mock_nova.aggregates.list.return_value = [ + fakes.FakeAggregate('1234', 'name') + ] + self.cloud.add_host_to_aggregate('1234', hostname) + mock_nova.aggregates.add_host.assert_called_once_with( + aggregate='1234', + host=hostname + ) + + @mock.patch.object(shade.OpenStackCloud, 'nova_client') + def test_remove_host_from_aggregate(self, mock_nova): + hostname = 'host1' + mock_nova.aggregates.list.return_value = [ + fakes.FakeAggregate('1234', 'name', hosts=[hostname]) + ] + self.cloud.remove_host_from_aggregate('1234', hostname) + mock_nova.aggregates.remove_host.assert_called_once_with( + aggregate='1234', + host=hostname + )