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'.