From ebe62dcd33b54165a0e06340295c4d832f89d3dc Mon Sep 17 00:00:00 2001 From: Miguel Lavalle Date: Sat, 24 Dec 2016 18:38:57 -0600 Subject: [PATCH] Add a ReST client for placement API This patchset adds a ReST client for the placement API. This client is used to update the IPv4 inventories associated with routed networks segments. This information is used by the Nova scheduler to decide the placement of instances in hosts, based on the availability of IPv4 addresses in routed networks segments DocImpact: Adds [placement] section to neutron.conf with two options: region_name and endpoint_type Change-Id: I2aa614d4e6229161047b08c8bdcbca0e2e5d1f0b Partially-Implements: blueprint routed-networks --- neutron/common/config.py | 7 + neutron/common/exceptions.py | 19 ++ neutron/conf/common.py | 19 ++ neutron/services/segments/placement_client.py | 163 ++++++++++++++++++ neutron/tests/unit/extensions/test_segment.py | 137 +++++++++++++++ ...onfiguration-options-f1611d0909bf6166.yaml | 14 ++ 6 files changed, 359 insertions(+) create mode 100644 neutron/services/segments/placement_client.py create mode 100644 releasenotes/notes/add-placement-api-configuration-options-f1611d0909bf6166.yaml diff --git a/neutron/common/config.py b/neutron/common/config.py index 5ee2644d82c..92b22be4a83 100644 --- a/neutron/common/config.py +++ b/neutron/common/config.py @@ -72,6 +72,13 @@ ks_loading.register_session_conf_options(cfg.CONF, NOVA_CONF_SECTION) # Register the nova configuration options common_config.register_nova_opts() +ks_loading.register_auth_conf_options(cfg.CONF, + common_config.PLACEMENT_CONF_SECTION) + + +# Register the placement configuration options +common_config.register_placement_opts() + logging.register_options(cfg.CONF) diff --git a/neutron/common/exceptions.py b/neutron/common/exceptions.py index a1ef235f8a8..81151579baa 100644 --- a/neutron/common/exceptions.py +++ b/neutron/common/exceptions.py @@ -41,6 +41,20 @@ class NetworkQosBindingNotFound(e.NotFound): "could not be found.") +class PlacementResourceProviderNotFound(e.NotFound): + message = _("Placement resource provider not found %(resource_provider)s.") + + +class PlacementInventoryNotFound(e.NotFound): + message = _("Placement inventory not found for resource provider " + "%(resource_provider)s, resource class %(resource_class)s.") + + +class PlacementAggregateNotFound(e.NotFound): + message = _("Aggregate not found for resource provider " + "%(resource_provider)s.") + + class PolicyRemoveAuthorizationError(e.NotAuthorized): message = _("Failed to remove provided policy %(policy_id)s " "because you are not authorized.") @@ -103,6 +117,11 @@ class OverlappingAllocationPools(e.Conflict): "%(pool_1)s %(pool_2)s for subnet %(subnet_cidr)s.") +class PlacementInventoryUpdateConflict(e.Conflict): + message = _("Placement inventory update conflict for resource provider " + "%(resource_provider)s, resource class %(resource_class)s.") + + class OutOfBoundsAllocationPool(e.BadRequest): message = _("The allocation pool %(pool)s spans " "beyond the subnet cidr %(subnet_cidr)s.") diff --git a/neutron/conf/common.py b/neutron/conf/common.py index 962e7de5243..41f81d448cd 100644 --- a/neutron/conf/common.py +++ b/neutron/conf/common.py @@ -164,3 +164,22 @@ nova_opts = [ def register_nova_opts(cfg=cfg.CONF): cfg.register_opts(nova_opts, group=NOVA_CONF_SECTION) + + +PLACEMENT_CONF_SECTION = 'placement' + +placement_opts = [ + cfg.StrOpt('region_name', + help=_('Name of placement region to use. Useful if keystone ' + 'manages more than one region.')), + cfg.StrOpt('endpoint_type', + default='public', + choices=['public', 'admin', 'internal'], + help=_('Type of the placement endpoint to use. This endpoint ' + 'will be looked up in the keystone catalog and should ' + 'be one of public, internal or admin.')), +] + + +def register_placement_opts(cfg=cfg.CONF): + cfg.register_opts(placement_opts, group=PLACEMENT_CONF_SECTION) diff --git a/neutron/services/segments/placement_client.py b/neutron/services/segments/placement_client.py new file mode 100644 index 00000000000..5c92680112b --- /dev/null +++ b/neutron/services/segments/placement_client.py @@ -0,0 +1,163 @@ +# Copyright (c) 2016 IBM +# All Rights Reserved. +# +# 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. + +from keystoneauth1 import exceptions as ks_exc +from keystoneauth1 import loading as ks_loading +from keystoneauth1 import session +from oslo_config import cfg +from oslo_log import log as logging + +from neutron._i18n import _ +from neutron.common import exceptions as n_exc + +LOG = logging.getLogger(__name__) + +PLACEMENT_API_WITH_AGGREGATES = 'placement 1.1' + + +class PlacementAPIClient(object): + """Client class for placement ReST API.""" + + ks_filter = {'service_type': 'placement', + 'region_name': cfg.CONF.placement.region_name} + + def __init__(self): + auth_plugin = ks_loading.load_auth_from_conf_options( + cfg.CONF, 'placement') + self._client = session.Session(auth=auth_plugin) + self._disabled = False + + def _get(self, url, **kwargs): + return self._client.get(url, endpoint_filter=self.ks_filter, + **kwargs) + + def _post(self, url, data, **kwargs): + return self._client.post(url, json=data, + endpoint_filter=self.ks_filter, **kwargs) + + def _put(self, url, data, **kwargs): + return self._client.put(url, json=data, endpoint_filter=self.ks_filter, + **kwargs) + + def _delete(self, url, **kwargs): + return self._client.delete(url, endpoint_filter=self.ks_filter, + **kwargs) + + def create_resource_provider(self, resource_provider): + """Create a resource provider. + + :param resource_provider: The resource provider + :type resource_provider: dict: name (required), uuid (required) + """ + url = '/resource_providers' + self._post(url, resource_provider) + + def delete_resource_provider(self, resource_provider_uuid): + """Delete a resource provider. + + :param resource_provider_uuid: UUID of the resource provider + :type resource_provider_uuid: str + """ + url = '/resource_providers/%s' % resource_provider_uuid + self._delete(url) + + def create_inventory(self, resource_provider_uuid, inventory): + """Create an inventory. + + :param resource_provider_uuid: UUID of the resource provider + :type resource_provider_uuid: str + :param inventory: The inventory + :type inventory: dict: resource_class (required), total (required), + reserved (required), min_unit (required), max_unit (required), + step_size (required), allocation_ratio (required) + """ + url = '/resource_providers/%s/inventories' % resource_provider_uuid + self._post(url, inventory) + + def get_inventory(self, resource_provider_uuid, resource_class): + """Get resource provider inventory. + + :param resource_provider_uuid: UUID of the resource provider + :type resource_provider_uuid: str + :param resource_class: Resource class name of the inventory to be + returned + :type resource_class: str + :raises n_exc.PlacementInventoryNotFound: For failure to find inventory + for a resource provider + """ + url = '/resource_providers/%s/inventories/%s' % ( + resource_provider_uuid, resource_class) + try: + return self._get(url).json() + except ks_exc.NotFound as e: + if "No resource provider with uuid" in e.details: + raise n_exc.PlacementResourceProviderNotFound( + resource_provider=resource_provider_uuid) + elif _("No inventory of class") in e.details: + raise n_exc.PlacementInventoryNotFound( + resource_provider=resource_provider_uuid, + resource_class=resource_class) + else: + raise + + def update_inventory(self, resource_provider_uuid, inventory, + resource_class): + """Update an inventory. + + :param resource_provider_uuid: UUID of the resource provider + :type resource_provider_uuid: str + :param inventory: The inventory + :type inventory: dict + :param resource_class: The resource class of the inventory to update + :type resource_class: str + :raises n_exc.PlacementInventoryUpdateConflict: For failure to updste + inventory due to outdated resource_provider_generation + """ + url = '/resource_providers/%s/inventories/%s' % ( + resource_provider_uuid, resource_class) + try: + self._put(url, inventory) + except ks_exc.Conflict: + raise n_exc.PlacementInventoryUpdateConflict( + resource_provider=resource_provider_uuid, + resource_class=resource_class) + + def associate_aggregates(self, resource_provider_uuid, aggregates): + """Associate a list of aggregates with a resource provider. + + :param resource_provider_uuid: UUID of the resource provider + :type resource_provider_uuid: str + :param aggregates: aggregates to be associated to the resource provider + :type aggregates: list of UUIDs + """ + url = '/resource_providers/%s/aggregates' % resource_provider_uuid + self._put(url, aggregates, + headers={'openstack-api-version': + PLACEMENT_API_WITH_AGGREGATES}) + + def list_aggregates(self, resource_provider_uuid): + """List resource provider aggregates. + + :param resource_provider_uuid: UUID of the resource provider + :type resource_provider_uuid: str + """ + url = '/resource_providers/%s/aggregates' % resource_provider_uuid + try: + return self._get( + url, headers={'openstack-api-version': + PLACEMENT_API_WITH_AGGREGATES}).json() + except ks_exc.NotFound: + raise n_exc.PlacementAggregateNotFound( + resource_provider=resource_provider_uuid) diff --git a/neutron/tests/unit/extensions/test_segment.py b/neutron/tests/unit/extensions/test_segment.py index f4d34fc5517..10855c65466 100644 --- a/neutron/tests/unit/extensions/test_segment.py +++ b/neutron/tests/unit/extensions/test_segment.py @@ -12,10 +12,12 @@ # License for the specific language governing permissions and limitations # under the License. +from keystoneauth1 import exceptions as ks_exc import mock import netaddr from neutron_lib import constants from neutron_lib import exceptions as n_exc +from oslo_config import cfg from oslo_utils import uuidutils import webob.exc @@ -24,6 +26,7 @@ from neutron.callbacks import events from neutron.callbacks import exceptions from neutron.callbacks import registry from neutron.callbacks import resources +from neutron.common import exceptions as neutron_exc from neutron.conf.plugins.ml2.drivers import driver_type from neutron import context from neutron.db import agents_db @@ -40,6 +43,8 @@ from neutron.plugins.common import constants as p_constants from neutron.plugins.ml2 import config from neutron.services.segments import db from neutron.services.segments import exceptions as segment_exc +from neutron.services.segments import placement_client +from neutron.tests import base from neutron.tests.common import helpers from neutron.tests.unit.db import test_db_base_plugin_v2 @@ -1476,3 +1481,135 @@ class TestDhcpAgentSegmentScheduling(HostSegmentMappingTestCase): agent_hosts = [agent['host'] for agent in dhcp_agents] self.assertIn(DHCP_HOSTA, agent_hosts) self.assertIn(DHCP_HOSTB, agent_hosts) + + +class PlacementAPIClientTestCase(base.DietTestCase): + """Test the Placement API client.""" + + def setUp(self): + super(PlacementAPIClientTestCase, self).setUp() + self.mock_load_auth_p = mock.patch( + 'keystoneauth1.loading.load_auth_from_conf_options') + self.mock_load_auth = self.mock_load_auth_p.start() + self.mock_request_p = mock.patch( + 'keystoneauth1.session.Session.request') + self.mock_request = self.mock_request_p.start() + self.client = placement_client.PlacementAPIClient() + + @mock.patch('keystoneauth1.session.Session') + @mock.patch('keystoneauth1.loading.load_auth_from_conf_options') + def test_constructor(self, load_auth_mock, ks_sess_mock): + placement_client.PlacementAPIClient() + + load_auth_mock.assert_called_once_with(cfg.CONF, 'placement') + ks_sess_mock.assert_called_once_with(auth=load_auth_mock.return_value) + + def test_create_resource_provider(self): + expected_payload = 'fake_resource_provider' + self.client.create_resource_provider(expected_payload) + expected_url = '/resource_providers' + self.mock_request.assert_called_once_with( + expected_url, 'POST', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}, + json=expected_payload) + + def test_delete_resource_provider(self): + rp_uuid = uuidutils.generate_uuid() + self.client.delete_resource_provider(rp_uuid) + expected_url = '/resource_providers/%s' % rp_uuid + self.mock_request.assert_called_once_with( + expected_url, 'DELETE', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}) + + def test_create_inventory(self): + expected_payload = 'fake_inventory' + rp_uuid = uuidutils.generate_uuid() + self.client.create_inventory(rp_uuid, expected_payload) + expected_url = '/resource_providers/%s/inventories' % rp_uuid + self.mock_request.assert_called_once_with( + expected_url, 'POST', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}, + json=expected_payload) + + def test_get_inventory(self): + rp_uuid = uuidutils.generate_uuid() + resource_class = 'fake_resource_class' + self.client.get_inventory(rp_uuid, resource_class) + expected_url = '/resource_providers/%s/inventories/%s' % ( + rp_uuid, resource_class) + self.mock_request.assert_called_once_with( + expected_url, 'GET', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}) + + def _test_get_inventory_not_found(self, details, expected_exception): + rp_uuid = uuidutils.generate_uuid() + resource_class = 'fake_resource_class' + self.mock_request.side_effect = ks_exc.NotFound(details=details) + self.assertRaises(expected_exception, self.client.get_inventory, + rp_uuid, resource_class) + + def test_get_inventory_not_found_no_resource_provider(self): + self._test_get_inventory_not_found( + "No resource provider with uuid", + neutron_exc.PlacementResourceProviderNotFound) + + def test_get_inventory_not_found_no_inventory(self): + self._test_get_inventory_not_found( + "No inventory of class", neutron_exc.PlacementInventoryNotFound) + + def test_get_inventory_not_found_unknown_cause(self): + self._test_get_inventory_not_found("Unknown cause", ks_exc.NotFound) + + def test_update_inventory(self): + expected_payload = 'fake_inventory' + rp_uuid = uuidutils.generate_uuid() + resource_class = 'fake_resource_class' + self.client.update_inventory(rp_uuid, expected_payload, resource_class) + expected_url = '/resource_providers/%s/inventories/%s' % ( + rp_uuid, resource_class) + self.mock_request.assert_called_once_with( + expected_url, 'PUT', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}, + json=expected_payload) + + def test_update_inventory_conflict(self): + rp_uuid = uuidutils.generate_uuid() + expected_payload = 'fake_inventory' + resource_class = 'fake_resource_class' + self.mock_request.side_effect = ks_exc.Conflict + self.assertRaises(neutron_exc.PlacementInventoryUpdateConflict, + self.client.update_inventory, rp_uuid, + expected_payload, resource_class) + + def test_associate_aggregates(self): + expected_payload = 'fake_aggregates' + rp_uuid = uuidutils.generate_uuid() + self.client.associate_aggregates(rp_uuid, expected_payload) + expected_url = '/resource_providers/%s/aggregates' % rp_uuid + self.mock_request.assert_called_once_with( + expected_url, 'PUT', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}, + json=expected_payload, + headers={'openstack-api-version': 'placement 1.1'}) + + def test_list_aggregates(self): + rp_uuid = uuidutils.generate_uuid() + self.client.list_aggregates(rp_uuid) + expected_url = '/resource_providers/%s/aggregates' % rp_uuid + self.mock_request.assert_called_once_with( + expected_url, 'GET', + endpoint_filter={'region_name': mock.ANY, + 'service_type': 'placement'}, + headers={'openstack-api-version': 'placement 1.1'}) + + def test_list_aggregates_not_found(self): + rp_uuid = uuidutils.generate_uuid() + self.mock_request.side_effect = ks_exc.NotFound + self.assertRaises(neutron_exc.PlacementAggregateNotFound, + self.client.list_aggregates, rp_uuid) diff --git a/releasenotes/notes/add-placement-api-configuration-options-f1611d0909bf6166.yaml b/releasenotes/notes/add-placement-api-configuration-options-f1611d0909bf6166.yaml new file mode 100644 index 00000000000..83d19432dff --- /dev/null +++ b/releasenotes/notes/add-placement-api-configuration-options-f1611d0909bf6166.yaml @@ -0,0 +1,14 @@ +--- +prelude: > + Add configuration options to enable the segments plugin to use the + placement ReST API. This API enables the segments plugin to influence + the placement of instances based on the availability of IPv4 addresses + in routed networks. +features: + - A new section is added to neutron.conf, `[placement]`. + - The `[placement]` section has two new options. + - First option, `region_name`, indicates the placement region to use. This + option is useful if keystone manages more than one region. + - Second option, `endpoint_type`, indicates the type of the placement + endpoint to use. This endpoint will be looked up in the keystone catalog + and should be one of 'public', 'internal' or 'admin'.