From a7310377e27bbce454b8a7a6b943cc5b7a45bbad Mon Sep 17 00:00:00 2001 From: Vladyslav Drok Date: Mon, 25 Jul 2016 20:08:14 +0000 Subject: [PATCH] Add PortGroups API This patch adds the portgroups API object and REST controller to Ironic. Additionally this patch provides a PortgroupsCollection class and PortgroupsPatch class. API version has been bumped to 1.23. This commit includes changes to: - the API (addition of portgroup API) - the API tests Partial-bug: #1618754 Co-Authored-By: Jenny Moorehead Co-Authored-By: Will Stevenson Co-Authored-By: Vasyl Saienko Co-Authored-By: Vladyslav Drok Co-Authored-By: Zhenguo Niu Co-Authored-By: Michael Turek Change-Id: I03ab55c15c1ee2fdd4b2786e366f9502c1ad8972 --- doc/source/dev/webapi-version-history.rst | 4 + etc/ironic/policy.json.sample | 8 + ironic/api/controllers/v1/__init__.py | 12 + ironic/api/controllers/v1/portgroup.py | 481 +++++++++ ironic/api/controllers/v1/utils.py | 31 + ironic/api/controllers/v1/versions.py | 4 +- ironic/common/policy.py | 16 + ironic/tests/unit/api/test_root.py | 21 +- ironic/tests/unit/api/utils.py | 17 + ironic/tests/unit/api/v1/test_portgroups.py | 954 ++++++++++++++++++ ironic/tests/unit/api/v1/test_utils.py | 38 + ...dd_portgroup_support-7d5c6663bb00684a.yaml | 6 + 12 files changed, 1587 insertions(+), 5 deletions(-) create mode 100644 ironic/api/controllers/v1/portgroup.py create mode 100644 ironic/tests/unit/api/v1/test_portgroups.py create mode 100644 releasenotes/notes/add_portgroup_support-7d5c6663bb00684a.yaml diff --git a/doc/source/dev/webapi-version-history.rst b/doc/source/dev/webapi-version-history.rst index 0fea287081..2005e6433e 100644 --- a/doc/source/dev/webapi-version-history.rst +++ b/doc/source/dev/webapi-version-history.rst @@ -2,6 +2,10 @@ REST API Version History ======================== +**1.23** + + Added '/v1/portgroups/ endpoint. + **1.22** Added endpoints for deployment ramdisks. diff --git a/etc/ironic/policy.json.sample b/etc/ironic/policy.json.sample index 31b923ce81..177ded5b11 100644 --- a/etc/ironic/policy.json.sample +++ b/etc/ironic/policy.json.sample @@ -50,6 +50,14 @@ "baremetal:port:delete": "rule:is_admin" # Update Port records "baremetal:port:update": "rule:is_admin" +# Retrieve Portgroup records +"baremetal:portgroup:get": "rule:is_admin or rule:is_observer" +# Create Portgroup records +"baremetal:portgroup:create": "rule:is_admin" +# Delete Portgroup records +"baremetal:portgroup:delete": "rule:is_admin" +# Update Portgroup records +"baremetal:portgroup:update": "rule:is_admin" # Retrieve Chassis records "baremetal:chassis:get": "rule:is_admin or rule:is_observer" # Create Chassis records diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py index 5d285fdd28..e6c23a1c7a 100644 --- a/ironic/api/controllers/v1/__init__.py +++ b/ironic/api/controllers/v1/__init__.py @@ -29,6 +29,7 @@ from ironic.api.controllers.v1 import chassis from ironic.api.controllers.v1 import driver from ironic.api.controllers.v1 import node from ironic.api.controllers.v1 import port +from ironic.api.controllers.v1 import portgroup from ironic.api.controllers.v1 import ramdisk from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import versions @@ -77,6 +78,9 @@ class V1(base.APIBase): ports = [link.Link] """Links to the ports resource""" + portgroups = [link.Link] + """Links to the portgroups resource""" + drivers = [link.Link] """Links to the drivers resource""" @@ -121,6 +125,13 @@ class V1(base.APIBase): 'ports', '', bookmark=True) ] + if utils.allow_portgroups(): + v1.portgroups = [ + link.Link.make_link('self', pecan.request.public_url, + 'portgroups', ''), + link.Link.make_link('bookmark', pecan.request.public_url, + 'portgroups', '', bookmark=True) + ] v1.drivers = [link.Link.make_link('self', pecan.request.public_url, 'drivers', ''), link.Link.make_link('bookmark', @@ -152,6 +163,7 @@ class Controller(rest.RestController): nodes = node.NodesController() ports = port.PortsController() + portgroups = portgroup.PortgroupsController() chassis = chassis.ChassisController() drivers = driver.DriversController() lookup = ramdisk.LookupController() diff --git a/ironic/api/controllers/v1/portgroup.py b/ironic/api/controllers/v1/portgroup.py new file mode 100644 index 0000000000..c7e8d0639f --- /dev/null +++ b/ironic/api/controllers/v1/portgroup.py @@ -0,0 +1,481 @@ +# 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 datetime + +from ironic_lib import metrics_utils +import pecan +from six.moves import http_client +import wsme +from wsme import types as wtypes + +from ironic.api.controllers import base +from ironic.api.controllers import link +from ironic.api.controllers.v1 import collection +from ironic.api.controllers.v1 import types +from ironic.api.controllers.v1 import utils as api_utils +from ironic.api import expose +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import policy +from ironic import objects + +METRICS = metrics_utils.get_metrics_logger(__name__) + +_DEFAULT_RETURN_FIELDS = ('uuid', 'address', 'name') + + +class Portgroup(base.APIBase): + """API representation of a portgroup. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a + portgroup. + """ + + _node_uuid = None + + def _get_node_uuid(self): + return self._node_uuid + + def _set_node_uuid(self, value): + if value and self._node_uuid != value: + if not api_utils.allow_portgroups(): + self._node_uuid = wtypes.Unset + return + try: + node = objects.Node.get(pecan.request.context, value) + self._node_uuid = node.uuid + # NOTE: Create the node_id attribute on-the-fly + # to satisfy the api -> rpc object + # conversion. + self.node_id = node.id + except exception.NodeNotFound as e: + # Change error code because 404 (NotFound) is inappropriate + # response for a POST request to create a Portgroup + e.code = http_client.BAD_REQUEST + raise e + elif value == wtypes.Unset: + self._node_uuid = wtypes.Unset + + uuid = types.uuid + """Unique UUID for this portgroup""" + + address = wsme.wsattr(types.macaddress, mandatory=True) + """MAC Address for this portgroup""" + + extra = {wtypes.text: types.jsontype} + """This portgroup's meta data""" + + internal_info = wsme.wsattr({wtypes.text: types.jsontype}, readonly=True) + """This portgroup's internal info""" + + node_uuid = wsme.wsproperty(types.uuid, _get_node_uuid, _set_node_uuid, + mandatory=True) + """The UUID of the node this portgroup belongs to""" + + name = wsme.wsattr(wtypes.text) + """The logical name for this portgroup""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated portgroup links""" + + standalone_ports_supported = types.boolean + """Indicates whether ports of this portgroup may be used as + single NIC ports""" + + def __init__(self, **kwargs): + self.fields = [] + fields = list(objects.Portgroup.fields) + # NOTE: node_uuid is not part of objects.Portgroup.fields + # because it's an API-only attribute + fields.append('node_uuid') + for field in fields: + # Skip fields we do not expose. + if not hasattr(self, field): + continue + self.fields.append(field) + setattr(self, field, kwargs.get(field, wtypes.Unset)) + + # NOTE: node_id is an attribute created on-the-fly + # by _set_node_uuid(), it needs to be present in the fields so + # that as_dict() will contain node_id field when converting it + # before saving it in the database. + self.fields.append('node_id') + setattr(self, 'node_uuid', kwargs.get('node_id', wtypes.Unset)) + + @staticmethod + def _convert_with_links(portgroup, url, fields=None): + """Add links to the portgroup.""" + # NOTE(lucasagomes): Since we are able to return a specified set of + # fields the "uuid" can be unset, so we need to save it in another + # variable to use when building the links + portgroup_uuid = portgroup.uuid + if fields is not None: + portgroup.unset_fields_except(fields) + else: + portgroup.ports = [ + link.Link.make_link('self', url, 'portgroups', + portgroup_uuid + "/ports"), + link.Link.make_link('bookmark', url, 'portgroups', + portgroup_uuid + "/ports", bookmark=True) + ] + + # never expose the node_id attribute + portgroup.node_id = wtypes.Unset + + portgroup.links = [link.Link.make_link('self', url, + 'portgroups', portgroup_uuid), + link.Link.make_link('bookmark', url, + 'portgroups', portgroup_uuid, + bookmark=True) + ] + return portgroup + + @classmethod + def convert_with_links(cls, rpc_portgroup, fields=None): + """Add links to the portgroup.""" + portgroup = Portgroup(**rpc_portgroup.as_dict()) + + if fields is not None: + api_utils.check_for_invalid_fields(fields, portgroup.as_dict()) + + return cls._convert_with_links(portgroup, pecan.request.host_url, + fields=fields) + + @classmethod + def sample(cls, expand=True): + """Return a sample of the portgroup.""" + sample = cls(uuid='a594544a-2daf-420c-8775-17a8c3e0852f', + address='fe:54:00:77:07:d9', + name='node1-portgroup-01', + extra={'foo': 'bar'}, + internal_info={'baz': 'boo'}, + standalone_ports_supported=True, + created_at=datetime.datetime(2000, 1, 1, 12, 0, 0), + updated_at=datetime.datetime(2000, 1, 1, 12, 0, 0)) + # NOTE(lucasagomes): node_uuid getter() method look at the + # _node_uuid variable + sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae' + fields = None if expand else _DEFAULT_RETURN_FIELDS + return cls._convert_with_links(sample, 'http://localhost:6385', + fields=fields) + + +class PortgroupPatchType(types.JsonPatchType): + + _api_base = Portgroup + + @staticmethod + def internal_attrs(): + defaults = types.JsonPatchType.internal_attrs() + return defaults + ['/internal_info'] + + +class PortgroupCollection(collection.Collection): + """API representation of a collection of portgroups.""" + + portgroups = [Portgroup] + """A list containing portgroup objects""" + + def __init__(self, **kwargs): + self._type = 'portgroups' + + @staticmethod + def convert_with_links(rpc_portgroups, limit, url=None, fields=None, + **kwargs): + collection = PortgroupCollection() + collection.portgroups = [Portgroup.convert_with_links(p, fields=fields) + for p in rpc_portgroups] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + """Return a sample of the portgroup.""" + sample = cls() + sample.portgroups = [Portgroup.sample(expand=False)] + return sample + + +class PortgroupsController(pecan.rest.RestController): + """REST controller for portgroups.""" + + _custom_actions = { + 'detail': ['GET'], + } + + invalid_sort_key_list = ['extra', 'internal_info'] + + def _get_portgroups_collection(self, node_ident, address, + marker, limit, sort_key, sort_dir, + resource_url=None, fields=None): + """Return portgroups collection. + + :param node_ident: UUID or name of a node. + :param address: MAC address of a portgroup. + :param marker: Pagination marker for large data sets. + :param limit: Maximum number of resources to return in a single result. + :param sort_key: Column to sort results by. Default: id. + :param sort_dir: Direction to sort. "asc" or "desc". Default: asc. + :param resource_url: Optional, URL to the portgroup resource. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. + """ + limit = api_utils.validate_limit(limit) + sort_dir = api_utils.validate_sort_dir(sort_dir) + + marker_obj = None + if marker: + marker_obj = objects.Portgroup.get_by_uuid(pecan.request.context, + marker) + + if sort_key in self.invalid_sort_key_list: + raise exception.InvalidParameterValue( + _("The sort_key value %(key)s is an invalid field for " + "sorting") % {'key': sort_key}) + + if node_ident: + # FIXME: Since all we need is the node ID, we can + # make this more efficient by only querying + # for that column. This will get cleaned up + # as we move to the object interface. + node = api_utils.get_rpc_node(node_ident) + portgroups = objects.Portgroup.list_by_node_id( + pecan.request.context, node.id, limit, + marker_obj, sort_key=sort_key, sort_dir=sort_dir) + elif address: + portgroups = self._get_portgroups_by_address(address) + else: + portgroups = objects.Portgroup.list(pecan.request.context, limit, + marker_obj, sort_key=sort_key, + sort_dir=sort_dir) + + return PortgroupCollection.convert_with_links(portgroups, limit, + url=resource_url, + fields=fields, + sort_key=sort_key, + sort_dir=sort_dir) + + def _get_portgroups_by_address(self, address): + """Retrieve a portgroup by its address. + + :param address: MAC address of a portgroup, to get the portgroup + which has this MAC address. + :returns: a list with the portgroup, or an empty list if no portgroup + is found. + + """ + try: + portgroup = objects.Portgroup.get_by_address(pecan.request.context, + address) + return [portgroup] + except exception.PortgroupNotFound: + return [] + + @METRICS.timer('PortgroupsController.get_all') + @expose.expose(PortgroupCollection, types.uuid_or_name, types.macaddress, + types.uuid, int, wtypes.text, wtypes.text, types.listtype) + def get_all(self, node=None, address=None, marker=None, + limit=None, sort_key='id', sort_dir='asc', fields=None): + """Retrieve a list of portgroups. + + :param node: UUID or name of a node, to get only portgroups for that + node. + :param address: MAC address of a portgroup, to get the portgroup which + has this MAC address. + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + This value cannot be larger than the value of max_limit + in the [api] section of the ironic configuration, or only + max_limit resources will be returned. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. + """ + if not api_utils.allow_portgroups(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:portgroup:get', cdict, cdict) + + if fields is None: + fields = _DEFAULT_RETURN_FIELDS + + return self._get_portgroups_collection(node, address, + marker, limit, + sort_key, sort_dir, + fields=fields) + + @METRICS.timer('PortgroupsController.detail') + @expose.expose(PortgroupCollection, types.uuid_or_name, types.macaddress, + types.uuid, int, wtypes.text, wtypes.text) + def detail(self, node=None, address=None, marker=None, + limit=None, sort_key='id', sort_dir='asc'): + """Retrieve a list of portgroups with detail. + + :param node: UUID or name of a node, to get only portgroups for that + node. + :param address: MAC address of a portgroup, to get the portgroup which + has this MAC address. + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + This value cannot be larger than the value of max_limit + in the [api] section of the ironic configuration, or only + max_limit resources will be returned. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + if not api_utils.allow_portgroups(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:portgroup:get', cdict, cdict) + + # NOTE: /detail should only work against collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "portgroups": + raise exception.HTTPNotFound() + + resource_url = '/'.join(['portgroups', 'detail']) + return self._get_portgroups_collection( + node, address, marker, limit, sort_key, sort_dir, + resource_url=resource_url) + + @METRICS.timer('PortgroupsController.get_one') + @expose.expose(Portgroup, types.uuid_or_name, types.listtype) + def get_one(self, portgroup_ident, fields=None): + """Retrieve information about the given portgroup. + + :param portgroup_ident: UUID or logical name of a portgroup. + :param fields: Optional, a list with a specified set of fields + of the resource to be returned. + """ + if not api_utils.allow_portgroups(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:portgroup:get', cdict, cdict) + + rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident) + return Portgroup.convert_with_links(rpc_portgroup, fields=fields) + + @METRICS.timer('PortgroupsController.post') + @expose.expose(Portgroup, body=Portgroup, status_code=http_client.CREATED) + def post(self, portgroup): + """Create a new portgroup. + + :param portgroup: a portgroup within the request body. + """ + if not api_utils.allow_portgroups(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:portgroup:create', cdict, cdict) + + if (portgroup.name and + not api_utils.is_valid_logical_name(portgroup.name)): + error_msg = _("Cannot create portgroup with invalid name " + "'%(name)s'") % {'name': portgroup.name} + raise wsme.exc.ClientSideError( + error_msg, status_code=http_client.BAD_REQUEST) + + new_portgroup = objects.Portgroup(pecan.request.context, + **portgroup.as_dict()) + new_portgroup.create() + # Set the HTTP Location Header + pecan.response.location = link.build_url('portgroups', + new_portgroup.uuid) + return Portgroup.convert_with_links(new_portgroup) + + @METRICS.timer('PortgroupsController.patch') + @wsme.validate(types.uuid_or_name, [PortgroupPatchType]) + @expose.expose(Portgroup, types.uuid_or_name, body=[PortgroupPatchType]) + def patch(self, portgroup_ident, patch): + """Update an existing portgroup. + + :param portgroup_ident: UUID or logical name of a portgroup. + :param patch: a json PATCH document to apply to this portgroup. + """ + if not api_utils.allow_portgroups(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:portgroup:update', cdict, cdict) + + rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident) + + names = api_utils.get_patch_values(patch, '/name') + for name in names: + if (name and + not api_utils.is_valid_logical_name(name)): + error_msg = _("Portgroup %(portgroup)s: Cannot change name to" + " invalid name '%(name)s'") % {'portgroup': + portgroup_ident, + 'name': name} + raise wsme.exc.ClientSideError( + error_msg, status_code=http_client.BAD_REQUEST) + + try: + portgroup_dict = rpc_portgroup.as_dict() + # NOTE: + # 1) Remove node_id because it's an internal value and + # not present in the API object + # 2) Add node_uuid + portgroup_dict['node_uuid'] = portgroup_dict.pop('node_id', None) + portgroup = Portgroup(**api_utils.apply_jsonpatch(portgroup_dict, + patch)) + except api_utils.JSONPATCH_EXCEPTIONS as e: + raise exception.PatchError(patch=patch, reason=e) + + # Update only the fields that have changed + for field in objects.Portgroup.fields: + try: + patch_val = getattr(portgroup, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if rpc_portgroup[field] != patch_val: + rpc_portgroup[field] = patch_val + + rpc_node = objects.Node.get_by_id(pecan.request.context, + rpc_portgroup.node_id) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + + new_portgroup = pecan.request.rpcapi.update_portgroup( + pecan.request.context, rpc_portgroup, topic) + + return Portgroup.convert_with_links(new_portgroup) + + @METRICS.timer('PortgroupsController.delete') + @expose.expose(None, types.uuid_or_name, + status_code=http_client.NO_CONTENT) + def delete(self, portgroup_ident): + """Delete a portgroup. + + :param portgroup_ident: UUID or logical name of a portgroup. + """ + if not api_utils.allow_portgroups(): + raise exception.NotFound() + + cdict = pecan.request.context.to_dict() + policy.authorize('baremetal:portgroup:delete', cdict, cdict) + + rpc_portgroup = api_utils.get_rpc_portgroup(portgroup_ident) + rpc_node = objects.Node.get_by_id(pecan.request.context, + rpc_portgroup.node_id) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + pecan.request.rpcapi.destroy_portgroup(pecan.request.context, + rpc_portgroup, topic) diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 8a7d05dab2..7897f0f1f1 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -142,6 +142,28 @@ def get_rpc_node(node_ident): raise exception.NodeNotFound(node=node_ident) +def get_rpc_portgroup(portgroup_ident): + """Get the RPC portgroup from the portgroup UUID or logical name. + + :param portgroup_ident: the UUID or logical name of a portgroup. + + :returns: The RPC portgroup. + :raises: InvalidUuidOrName if the name or uuid provided is not valid. + :raises: PortgroupNotFound if the portgroup is not found. + """ + # Check to see if the portgroup_ident is a valid UUID. If it is, treat it + # as a UUID. + if uuidutils.is_uuid_like(portgroup_ident): + return objects.Portgroup.get_by_uuid(pecan.request.context, + portgroup_ident) + + # We can refer to portgroups by their name + if utils.is_valid_logical_name(portgroup_ident): + return objects.Portgroup.get_by_name(pecan.request.context, + portgroup_ident) + raise exception.InvalidUuidOrName(name=portgroup_ident) + + def is_valid_node_name(name): """Determine if the provided name is a valid node name. @@ -391,6 +413,15 @@ def allow_ramdisk_endpoints(): return pecan.request.version.minor >= versions.MINOR_22_LOOKUP_HEARTBEAT +def allow_portgroups(): + """Check if we should support portgroup operations. + + Version 1.23 of the API added support for PortGroups. + """ + return (pecan.request.version.minor >= + versions.MINOR_23_PORTGROUPS) + + def get_controller_reserved_names(cls): """Get reserved names for a given controller. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index b71bc1e0e5..b8e840dc5d 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -52,6 +52,7 @@ BASE_VERSION = 1 # v1.20: Add node.network_interface # v1.21: Add node.resource_class # v1.22: Ramdisk lookup and heartbeat endpoints. +# v1.23: Add portgroup support. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -76,11 +77,12 @@ MINOR_19_PORT_ADVANCED_NET_FIELDS = 19 MINOR_20_NETWORK_INTERFACE = 20 MINOR_21_RESOURCE_CLASS = 21 MINOR_22_LOOKUP_HEARTBEAT = 22 +MINOR_23_PORTGROUPS = 23 # When adding another version, update MINOR_MAX_VERSION and also update # doc/source/dev/webapi-version-history.rst with a detailed explanation of # what the version has changed. -MINOR_MAX_VERSION = MINOR_22_LOOKUP_HEARTBEAT +MINOR_MAX_VERSION = MINOR_23_PORTGROUPS # String representations of the minor and maximum versions MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/policy.py b/ironic/common/policy.py index 3aaf283d58..87199af450 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -135,6 +135,21 @@ port_policies = [ description='Update Port records'), ] +portgroup_policies = [ + policy.RuleDefault('baremetal:portgroup:get', + 'rule:is_admin or rule:is_observer', + description='Retrieve Portgroup records'), + policy.RuleDefault('baremetal:portgroup:create', + 'rule:is_admin', + description='Create Portgroup records'), + policy.RuleDefault('baremetal:portgroup:delete', + 'rule:is_admin', + description='Delete Portgroup records'), + policy.RuleDefault('baremetal:portgroup:update', + 'rule:is_admin', + description='Update Portgroup records'), +] + chassis_policies = [ policy.RuleDefault('baremetal:chassis:get', 'rule:is_admin or rule:is_observer', @@ -183,6 +198,7 @@ def list_policies(): policies = (default_policies + node_policies + port_policies + + portgroup_policies + chassis_policies + driver_policies + extra_policies) diff --git a/ironic/tests/unit/api/test_root.py b/ironic/tests/unit/api/test_root.py index ed5e9676bb..2c0a3421bd 100644 --- a/ironic/tests/unit/api/test_root.py +++ b/ironic/tests/unit/api/test_root.py @@ -38,8 +38,12 @@ class TestRoot(base.BaseApiTest): class TestV1Root(base.BaseApiTest): - def test_get_v1_root(self): - data = self.get_json('/') + def _test_get_root(self, headers=None, additional_expected_resources=None): + if headers is None: + headers = {} + if additional_expected_resources is None: + additional_expected_resources = [] + data = self.get_json('/', headers=headers) self.assertEqual('v1', data['id']) # Check fields are not empty for f in data: @@ -47,9 +51,9 @@ class TestV1Root(base.BaseApiTest): # Check if all known resources are present and there are no extra ones. not_resources = ('id', 'links', 'media_types') actual_resources = tuple(set(data.keys()) - set(not_resources)) - expected_resources = ('chassis', 'drivers', 'nodes', 'ports') + expected_resources = (['chassis', 'drivers', 'nodes', 'ports'] + + additional_expected_resources) self.assertEqual(sorted(expected_resources), sorted(actual_resources)) - self.assertIn({'type': 'application/vnd.openstack.ironic.v1+json', 'base': 'application/json'}, data['media_types']) @@ -69,3 +73,12 @@ class TestV1Root(base.BaseApiTest): self.assertIn({'type': 'application/vnd.openstack.ironic.v1+json', 'base': 'application/json'}, data['media_types']) + + def test_get_v1_root(self): + self._test_get_root() + + def test_get_v1_23_root(self): + self._test_get_root(headers={'X-OpenStack-Ironic-API-Version': '1.23'}, + additional_expected_resources=['heartbeat', + 'lookup', + 'portgroups']) diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index 433d1b54af..3f70d7410b 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -22,6 +22,7 @@ import json from ironic.api.controllers.v1 import chassis as chassis_controller from ironic.api.controllers.v1 import node as node_controller from ironic.api.controllers.v1 import port as port_controller +from ironic.api.controllers.v1 import portgroup as portgroup_controller from ironic.tests.unit.db import utils ADMIN_TOKEN = '4562138218392831' @@ -130,3 +131,19 @@ def post_get_test_node(**kw): chassis = utils.get_test_chassis() node['chassis_uuid'] = kw.get('chassis_uuid', chassis['uuid']) return node + + +def portgroup_post_data(**kw): + """Return a Portgroup object without internal attributes.""" + portgroup = utils.get_test_portgroup(**kw) + portgroup.pop('node_id') + internal = portgroup_controller.PortgroupPatchType.internal_attrs() + return remove_internal(portgroup, internal) + + +def post_get_test_portgroup(**kw): + """Return a Portgroup object with appropriate attributes.""" + portgroup = portgroup_post_data(**kw) + node = utils.get_test_node() + portgroup['node_uuid'] = kw.get('node_uuid', node['uuid']) + return portgroup diff --git a/ironic/tests/unit/api/v1/test_portgroups.py b/ironic/tests/unit/api/v1/test_portgroups.py new file mode 100644 index 0000000000..04a96ff8ef --- /dev/null +++ b/ironic/tests/unit/api/v1/test_portgroups.py @@ -0,0 +1,954 @@ +# 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. +""" +Tests for the API /portgroups/ methods. +""" + +import datetime + +import mock +from oslo_config import cfg +from oslo_utils import timeutils +from oslo_utils import uuidutils +import six +from six.moves import http_client +from six.moves.urllib import parse as urlparse +from testtools.matchers import HasLength +from wsme import types as wtypes + +from ironic.api.controllers import base as api_base +from ironic.api.controllers import v1 as api_v1 +from ironic.api.controllers.v1 import portgroup as api_portgroup +from ironic.api.controllers.v1 import utils as api_utils +from ironic.common import exception +from ironic.conductor import rpcapi +from ironic.tests import base +from ironic.tests.unit.api import base as test_api_base +from ironic.tests.unit.api import utils as apiutils +from ironic.tests.unit.objects import utils as obj_utils + + +class TestPortgroupObject(base.TestCase): + + def test_portgroup_init(self): + portgroup_dict = apiutils.portgroup_post_data(node_id=None) + del portgroup_dict['extra'] + portgroup = api_portgroup.Portgroup(**portgroup_dict) + self.assertEqual(wtypes.Unset, portgroup.extra) + + +class TestListPortgroups(test_api_base.BaseApiTest): + headers = {api_base.Version.string: str(api_v1.MAX_VER)} + + def setUp(self): + super(TestListPortgroups, self).setUp() + self.node = obj_utils.create_test_node(self.context) + + def test_empty(self): + data = self.get_json('/portgroups', headers=self.headers) + self.assertEqual([], data['portgroups']) + + def test_one(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + data = self.get_json('/portgroups', headers=self.headers) + self.assertEqual(portgroup.uuid, data['portgroups'][0]["uuid"]) + self.assertEqual(portgroup.address, data['portgroups'][0]["address"]) + self.assertEqual(portgroup.name, data['portgroups'][0]['name']) + self.assertNotIn('extra', data['portgroups'][0]) + self.assertNotIn('node_uuid', data['portgroups'][0]) + # never expose the node_id + self.assertNotIn('node_id', data['portgroups'][0]) + self.assertNotIn('standalone_ports_supported', data['portgroups'][0]) + + def test_get_one(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + data = self.get_json('/portgroups/%s' % portgroup.uuid, + headers=self.headers) + self.assertEqual(portgroup.uuid, data['uuid']) + self.assertIn('extra', data) + self.assertIn('node_uuid', data) + self.assertIn('standalone_ports_supported', data) + # never expose the node_id + self.assertNotIn('node_id', data) + + def test_get_one_custom_fields(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + fields = 'address,extra' + data = self.get_json( + '/portgroups/%s?fields=%s' % (portgroup.uuid, fields), + headers=self.headers) + # We always append "links" + self.assertItemsEqual(['address', 'extra', 'links'], data) + + def test_get_collection_custom_fields(self): + fields = 'uuid,extra' + for i in range(3): + obj_utils.create_test_portgroup( + self.context, + node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % i, + address='52:54:00:cf:2d:3%s' % i) + + data = self.get_json( + '/portgroups?fields=%s' % fields, + headers=self.headers) + + self.assertEqual(3, len(data['portgroups'])) + for portgroup in data['portgroups']: + # We always append "links" + self.assertItemsEqual(['uuid', 'extra', 'links'], portgroup) + + def test_get_custom_fields_invalid_fields(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + fields = 'uuid,spongebob' + response = self.get_json( + '/portgroups/%s?fields=%s' % (portgroup.uuid, fields), + headers=self.headers, expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn('spongebob', response.json['error_message']) + + def test_get_one_invalid_api_version(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + response = self.get_json( + '/portgroups/%s' % (portgroup.uuid), + headers={api_base.Version.string: str(api_v1.MIN_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_detail(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + data = self.get_json('/portgroups/detail', headers=self.headers) + self.assertEqual(portgroup.uuid, data['portgroups'][0]["uuid"]) + self.assertIn('extra', data['portgroups'][0]) + self.assertIn('node_uuid', data['portgroups'][0]) + self.assertIn('standalone_ports_supported', data['portgroups'][0]) + # never expose the node_id + self.assertNotIn('node_id', data['portgroups'][0]) + + def test_detail_invalid_api_version(self): + response = self.get_json( + '/portgroups/detail', + headers={api_base.Version.string: str(api_v1.MIN_VER)}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_detail_against_single(self): + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + response = self.get_json('/portgroups/%s/detail' % portgroup.uuid, + expect_errors=True, headers=self.headers) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_many(self): + portgroups = [] + for id_ in range(5): + portgroup = obj_utils.create_test_portgroup( + self.context, node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % id_, + address='52:54:00:cf:2d:3%s' % id_) + portgroups.append(portgroup.uuid) + data = self.get_json('/portgroups', headers=self.headers) + self.assertEqual(len(portgroups), len(data['portgroups'])) + + uuids = [n['uuid'] for n in data['portgroups']] + six.assertCountEqual(self, portgroups, uuids) + + def test_collection_links(self): + portgroups = [] + for id_ in range(5): + portgroup = obj_utils.create_test_portgroup( + self.context, + node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % id_, + address='52:54:00:cf:2d:3%s' % id_) + portgroups.append(portgroup.uuid) + data = self.get_json('/portgroups/?limit=3', headers=self.headers) + self.assertEqual(3, len(data['portgroups'])) + + next_marker = data['portgroups'][-1]['uuid'] + self.assertIn(next_marker, data['next']) + + def test_collection_links_default_limit(self): + cfg.CONF.set_override('max_limit', 3, 'api') + portgroups = [] + for id_ in range(5): + portgroup = obj_utils.create_test_portgroup( + self.context, + node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % id_, + address='52:54:00:cf:2d:3%s' % id_) + portgroups.append(portgroup.uuid) + data = self.get_json('/portgroups', headers=self.headers) + self.assertEqual(3, len(data['portgroups'])) + + next_marker = data['portgroups'][-1]['uuid'] + self.assertIn(next_marker, data['next']) + + def test_ports_subresource_no_portgroups_allowed(self): + pg = obj_utils.create_test_portgroup(self.context, + uuid=uuidutils.generate_uuid(), + node_id=self.node.id) + + for id_ in range(2): + obj_utils.create_test_port(self.context, node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + portgroup_id=pg.id, + address='52:54:00:cf:2d:3%s' % id_) + + response = self.get_json('/portgroups/%s/ports' % pg.uuid, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + self.assertEqual('application/json', response.content_type) + + def test_ports_subresource_portgroup_not_found(self): + non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc' + response = self.get_json('/portgroups/%s/ports' % non_existent_uuid, + expect_errors=True, headers=self.headers) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_portgroup_by_address(self): + address_template = "aa:bb:cc:dd:ee:f%d" + for id_ in range(3): + obj_utils.create_test_portgroup( + self.context, + node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % id_, + address=address_template % id_) + + target_address = address_template % 1 + data = self.get_json('/portgroups?address=%s' % target_address, + headers=self.headers) + self.assertThat(data['portgroups'], HasLength(1)) + self.assertEqual(target_address, data['portgroups'][0]['address']) + + def test_portgroup_get_all_invalid_api_version(self): + obj_utils.create_test_portgroup( + self.context, node_id=self.node.id, uuid=uuidutils.generate_uuid(), + name='portgroup_1') + response = self.get_json('/portgroups', + headers={api_base.Version.string: '1.14'}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_portgroup_by_address_non_existent_address(self): + # non-existent address + data = self.get_json('/portgroups?address=%s' % 'aa:bb:cc:dd:ee:ff', + headers=self.headers) + self.assertThat(data['portgroups'], HasLength(0)) + + def test_portgroup_by_address_invalid_address_format(self): + obj_utils.create_test_portgroup(self.context, node_id=self.node.id) + invalid_address = 'invalid-mac-format' + response = self.get_json('/portgroups?address=%s' % invalid_address, + expect_errors=True, headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn(invalid_address, response.json['error_message']) + + def test_sort_key(self): + portgroups = [] + for id_ in range(3): + portgroup = obj_utils.create_test_portgroup( + self.context, + node_id=self.node.id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % id_, + address='52:54:00:cf:2d:3%s' % id_) + portgroups.append(portgroup.uuid) + data = self.get_json('/portgroups?sort_key=uuid', headers=self.headers) + uuids = [n['uuid'] for n in data['portgroups']] + self.assertEqual(sorted(portgroups), uuids) + + def test_sort_key_invalid(self): + invalid_keys_list = ['foo', 'extra'] + for invalid_key in invalid_keys_list: + response = self.get_json('/portgroups?sort_key=%s' % invalid_key, + expect_errors=True, headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn(invalid_key, response.json['error_message']) + + @mock.patch.object(api_utils, 'get_rpc_node') + def test_get_all_by_node_name_ok(self, mock_get_rpc_node): + # GET /v1/portgroups specifying node_name - success + mock_get_rpc_node.return_value = self.node + for i in range(5): + if i < 3: + node_id = self.node.id + else: + node_id = 100000 + i + obj_utils.create_test_portgroup( + self.context, + node_id=node_id, + uuid=uuidutils.generate_uuid(), + name='portgroup%s' % i, + address='52:54:00:cf:2d:3%s' % i) + data = self.get_json("/portgroups?node=%s" % 'test-node', + headers=self.headers) + self.assertEqual(3, len(data['portgroups'])) + + @mock.patch.object(api_utils, 'get_rpc_node') + def test_get_all_by_node_uuid_ok(self, mock_get_rpc_node): + mock_get_rpc_node.return_value = self.node + obj_utils.create_test_portgroup(self.context, node_id=self.node.id) + data = self.get_json('/portgroups/detail?node=%s' % (self.node.uuid), + headers=self.headers) + mock_get_rpc_node.assert_called_once_with(self.node.uuid) + self.assertEqual(1, len(data['portgroups'])) + + @mock.patch.object(api_utils, 'get_rpc_node') + def test_detail_by_node_name_ok(self, mock_get_rpc_node): + # GET /v1/portgroups/detail specifying node_name - success + mock_get_rpc_node.return_value = self.node + portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + data = self.get_json('/portgroups/detail?node=%s' % 'test-node', + headers=self.headers) + self.assertEqual(portgroup.uuid, data['portgroups'][0]['uuid']) + self.assertEqual(self.node.uuid, data['portgroups'][0]['node_uuid']) + + +@mock.patch.object(rpcapi.ConductorAPI, 'update_portgroup') +class TestPatch(test_api_base.BaseApiTest): + headers = {api_base.Version.string: str(api_v1.MAX_VER)} + + def setUp(self): + super(TestPatch, self).setUp() + self.node = obj_utils.create_test_node(self.context) + self.portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + + p = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for') + self.mock_gtf = p.start() + self.mock_gtf.return_value = 'test-topic' + self.addCleanup(p.stop) + + def test_update_byid(self, mock_upd): + extra = {'foo': 'bar'} + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(extra, response.json['extra']) + + kargs = mock_upd.call_args[0][1] + self.assertEqual(extra, kargs.extra) + + def test_update_byname(self, mock_upd): + extra = {'foo': 'bar'} + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.name, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(extra, response.json['extra']) + + def test_update_invalid_name(self, mock_upd): + mock_upd.return_value = self.portgroup + response = self.patch_json('/portgroups/%s' % self.portgroup.name, + [{'path': '/name', + 'value': 'aa:bb_cc', + 'op': 'replace'}], + headers=self.headers, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + + def test_update_byid_invalid_api_version(self, mock_upd): + extra = {'foo': 'bar'} + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + headers = {api_base.Version.string: '1.14'} + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_update_byaddress_not_allowed(self, mock_upd): + extra = {'foo': 'bar'} + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.address, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertIn(self.portgroup.address, response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_update_not_found(self, mock_upd): + uuid = uuidutils.generate_uuid() + response = self.patch_json('/portgroups/%s' % uuid, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_replace_singular(self, mock_upd): + address = 'aa:bb:cc:dd:ee:ff' + mock_upd.return_value = self.portgroup + mock_upd.return_value.address = address + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/address', + 'value': address, + 'op': 'replace'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(address, response.json['address']) + self.assertTrue(mock_upd.called) + + kargs = mock_upd.call_args[0][1] + self.assertEqual(address, kargs.address) + + def test_replace_address_already_exist(self, mock_upd): + address = 'aa:aa:aa:aa:aa:aa' + mock_upd.side_effect = exception.MACAlreadyExists(mac=address) + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/address', + 'value': address, + 'op': 'replace'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.CONFLICT, response.status_code) + self.assertTrue(response.json['error_message']) + self.assertTrue(mock_upd.called) + + kargs = mock_upd.call_args[0][1] + self.assertEqual(address, kargs.address) + + def test_replace_node_uuid(self, mock_upd): + mock_upd.return_value = self.portgroup + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/node_uuid', + 'value': self.node.uuid, + 'op': 'replace'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_add_node_uuid(self, mock_upd): + mock_upd.return_value = self.portgroup + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/node_uuid', + 'value': self.node.uuid, + 'op': 'add'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_add_node_id(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/node_id', + 'value': '1', + 'op': 'add'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + self.assertFalse(mock_upd.called) + + def test_replace_node_id(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/node_id', + 'value': '1', + 'op': 'replace'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + self.assertFalse(mock_upd.called) + + def test_remove_node_id(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/node_id', + 'op': 'remove'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + self.assertFalse(mock_upd.called) + + def test_replace_non_existent_node_uuid(self, mock_upd): + node_uuid = '12506333-a81c-4d59-9987-889ed5f8687b' + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/node_uuid', + 'value': node_uuid, + 'op': 'replace'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + self.assertIn(node_uuid, response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_replace_multi(self, mock_upd): + extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"} + self.portgroup.extra = extra + self.portgroup.save() + + # mutate extra so we replace all of them + extra = dict((k, extra[k] + 'x') for k in extra.keys()) + + patch = [] + for k in extra.keys(): + patch.append({'path': '/extra/%s' % k, + 'value': extra[k], + 'op': 'replace'}) + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + patch, headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(extra, response.json['extra']) + kargs = mock_upd.call_args[0][1] + self.assertEqual(extra, kargs.extra) + + def test_remove_multi(self, mock_upd): + extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"} + self.portgroup.extra = extra + self.portgroup.save() + + # Removing one item from the collection + extra.pop('foo1') + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/extra/foo1', + 'op': 'remove'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(extra, response.json['extra']) + kargs = mock_upd.call_args[0][1] + self.assertEqual(extra, kargs.extra) + + # Removing the collection + extra = {} + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/extra', 'op': 'remove'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual({}, response.json['extra']) + kargs = mock_upd.call_args[0][1] + self.assertEqual(extra, kargs.extra) + + # Assert nothing else was changed + self.assertEqual(self.portgroup.uuid, response.json['uuid']) + self.assertEqual(self.portgroup.address, response.json['address']) + + def test_remove_non_existent_property_fail(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/extra/non-existent', + 'op': 'remove'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_remove_mandatory_field(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/address', + 'op': 'remove'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_add_root(self, mock_upd): + address = 'aa:bb:cc:dd:ee:ff' + mock_upd.return_value = self.portgroup + mock_upd.return_value.address = address + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/address', + 'value': address, + 'op': 'add'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(address, response.json['address']) + self.assertTrue(mock_upd.called) + kargs = mock_upd.call_args[0][1] + self.assertEqual(address, kargs.address) + + def test_add_root_non_existent(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/foo', + 'value': 'bar', + 'op': 'add'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_add_multi(self, mock_upd): + extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"} + patch = [] + for k in extra.keys(): + patch.append({'path': '/extra/%s' % k, + 'value': extra[k], + 'op': 'add'}) + mock_upd.return_value = self.portgroup + mock_upd.return_value.extra = extra + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + patch, headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(extra, response.json['extra']) + kargs = mock_upd.call_args[0][1] + self.assertEqual(extra, kargs.extra) + + def test_remove_uuid(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/uuid', + 'op': 'remove'}], + expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_update_address_invalid_format(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/address', + 'value': 'invalid-format', + 'op': 'replace'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_update_portgroup_address_normalized(self, mock_upd): + address = 'AA:BB:CC:DD:EE:FF' + mock_upd.return_value = self.portgroup + mock_upd.return_value.address = address.lower() + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/address', + 'value': address, + 'op': 'replace'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(address.lower(), response.json['address']) + kargs = mock_upd.call_args[0][1] + self.assertEqual(address.lower(), kargs.address) + + def test_update_portgroup_standalone_ports_supported(self, mock_upd): + mock_upd.return_value = self.portgroup + mock_upd.return_value.standalone_ports_supported = False + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/standalone_ports_supported', + 'value': False, + 'op': 'replace'}], + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + self.assertEqual(False, response.json['standalone_ports_supported']) + + def test_update_portgroup_standalone_ports_supported_bad_api_version( + self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/standalone_ports_supported', + 'value': False, + 'op': 'replace'}], + expect_errors=True, + headers={api_base.Version.string: + str(api_v1.MIN_VER)}) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + def test_update_portgroup_internal_info_not_allowed(self, mock_upd): + response = self.patch_json('/portgroups/%s' % self.portgroup.uuid, + [{'path': '/internal_info', + 'value': False, + 'op': 'replace'}], + expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + + +class TestPost(test_api_base.BaseApiTest): + headers = {api_base.Version.string: str(api_v1.MAX_VER)} + + def setUp(self): + super(TestPost, self).setUp() + self.node = obj_utils.create_test_node(self.context) + + @mock.patch.object(timeutils, 'utcnow', autospec=True) + def test_create_portgroup(self, mock_utcnow): + pdict = apiutils.post_get_test_portgroup() + test_time = datetime.datetime(2000, 1, 1, 0, 0) + mock_utcnow.return_value = test_time + response = self.post_json('/portgroups', pdict, + headers=self.headers) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/portgroups/%s' % pdict['uuid'], + headers=self.headers) + self.assertEqual(pdict['uuid'], result['uuid']) + self.assertFalse(result['updated_at']) + return_created_at = timeutils.parse_isotime( + result['created_at']).replace(tzinfo=None) + self.assertEqual(test_time, return_created_at) + # Check location header + self.assertIsNotNone(response.location) + expected_location = '/v1/portgroups/%s' % pdict['uuid'] + self.assertEqual(urlparse.urlparse(response.location).path, + expected_location) + + def test_create_portgroup_invalid_api_version(self): + pdict = apiutils.post_get_test_portgroup() + response = self.post_json( + '/portgroups', pdict, headers={api_base.Version.string: '1.14'}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_create_portgroup_doesnt_contain_id(self): + with mock.patch.object(self.dbapi, 'create_portgroup', + wraps=self.dbapi.create_portgroup) as cp_mock: + pdict = apiutils.post_get_test_portgroup(extra={'foo': 123}) + self.post_json('/portgroups', pdict, headers=self.headers) + result = self.get_json('/portgroups/%s' % pdict['uuid'], + headers=self.headers) + self.assertEqual(pdict['extra'], result['extra']) + cp_mock.assert_called_once_with(mock.ANY) + # Check that 'id' is not in first arg of positional args + self.assertNotIn('id', cp_mock.call_args[0][0]) + + def test_create_portgroup_generate_uuid(self): + pdict = apiutils.post_get_test_portgroup() + del pdict['uuid'] + response = self.post_json('/portgroups', pdict, headers=self.headers) + result = self.get_json('/portgroups/%s' % response.json['uuid'], + headers=self.headers) + self.assertEqual(pdict['address'], result['address']) + self.assertTrue(uuidutils.is_uuid_like(result['uuid'])) + + def test_create_portgroup_valid_extra(self): + pdict = apiutils.post_get_test_portgroup( + extra={'str': 'foo', 'int': 123, 'float': 0.1, 'bool': True, + 'list': [1, 2], 'none': None, 'dict': {'cat': 'meow'}}) + self.post_json('/portgroups', pdict, headers=self.headers) + result = self.get_json('/portgroups/%s' % pdict['uuid'], + headers=self.headers) + self.assertEqual(pdict['extra'], result['extra']) + + def test_create_portgroup_no_mandatory_field_address(self): + pdict = apiutils.post_get_test_portgroup() + del pdict['address'] + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_create_portgroup_no_mandatory_field_node_uuid(self): + pdict = apiutils.post_get_test_portgroup() + del pdict['node_uuid'] + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_create_portgroup_invalid_addr_format(self): + pdict = apiutils.post_get_test_portgroup(address='invalid-format') + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_create_portgroup_address_normalized(self): + address = 'AA:BB:CC:DD:EE:FF' + pdict = apiutils.post_get_test_portgroup(address=address) + self.post_json('/portgroups', pdict, headers=self.headers) + result = self.get_json('/portgroups/%s' % pdict['uuid'], + headers=self.headers) + self.assertEqual(address.lower(), result['address']) + + def test_create_portgroup_with_hyphens_delimiter(self): + pdict = apiutils.post_get_test_portgroup() + colonsMAC = pdict['address'] + hyphensMAC = colonsMAC.replace(':', '-') + pdict['address'] = hyphensMAC + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_create_portgroup_invalid_node_uuid_format(self): + pdict = apiutils.post_get_test_portgroup(node_uuid='invalid-format') + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + + def test_node_uuid_to_node_id_mapping(self): + pdict = apiutils.post_get_test_portgroup(node_uuid=self.node['uuid']) + self.post_json('/portgroups', pdict, headers=self.headers) + # GET doesn't return the node_id it's an internal value + portgroup = self.dbapi.get_portgroup_by_uuid(pdict['uuid']) + self.assertEqual(self.node['id'], portgroup.node_id) + + def test_create_portgroup_node_uuid_not_found(self): + pdict = apiutils.post_get_test_portgroup( + node_uuid='1a1a1a1a-2b2b-3c3c-4d4d-5e5e5e5e5e5e') + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + + def test_create_portgroup_address_already_exist(self): + address = 'AA:AA:AA:11:22:33' + pdict = apiutils.post_get_test_portgroup(address=address) + self.post_json('/portgroups', pdict, headers=self.headers) + pdict['uuid'] = uuidutils.generate_uuid() + pdict['name'] = uuidutils.generate_uuid() + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.CONFLICT, response.status_int) + self.assertEqual('application/json', response.content_type) + error_msg = response.json['error_message'] + self.assertTrue(error_msg) + self.assertIn(address, error_msg.upper()) + + def test_create_portgroup_name_ok(self): + address = 'AA:AA:AA:11:22:33' + name = 'foo' + pdict = apiutils.post_get_test_portgroup(address=address, name=name) + self.post_json('/portgroups', pdict, headers=self.headers) + result = self.get_json('/portgroups/%s' % pdict['uuid'], + headers=self.headers) + self.assertEqual(name, result['name']) + + def test_create_portgroup_name_invalid(self): + address = 'AA:AA:AA:11:22:33' + name = 'aa:bb_cc' + pdict = apiutils.post_get_test_portgroup(address=address, name=name) + response = self.post_json('/portgroups', pdict, headers=self.headers, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + + def test_create_portgroup_internal_info_not_allowed(self): + pdict = apiutils.post_get_test_portgroup() + pdict['internal_info'] = 'info' + response = self.post_json('/portgroups', pdict, expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + +@mock.patch.object(rpcapi.ConductorAPI, 'destroy_portgroup') +class TestDelete(test_api_base.BaseApiTest): + headers = {api_base.Version.string: str(api_v1.MAX_VER)} + + def setUp(self): + super(TestDelete, self).setUp() + self.node = obj_utils.create_test_node(self.context) + self.portgroup = obj_utils.create_test_portgroup(self.context, + node_id=self.node.id) + + gtf = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for') + self.mock_gtf = gtf.start() + self.mock_gtf.return_value = 'test-topic' + self.addCleanup(gtf.stop) + + def test_delete_portgroup_byaddress(self, mock_dpt): + response = self.delete('/portgroups/%s' % self.portgroup.address, + expect_errors=True, headers=self.headers) + self.assertEqual(http_client.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn(self.portgroup.address, response.json['error_message']) + + def test_delete_portgroup_byid(self, mock_dpt): + self.delete('/portgroups/%s' % self.portgroup.uuid, + headers=self.headers) + self.assertTrue(mock_dpt.called) + + def test_delete_portgroup_node_locked(self, mock_dpt): + self.node.reserve(self.context, 'fake', self.node.uuid) + mock_dpt.side_effect = exception.NodeLocked(node='fake-node', + host='fake-host') + ret = self.delete('/portgroups/%s' % self.portgroup.uuid, + expect_errors=True, headers=self.headers) + self.assertEqual(http_client.CONFLICT, ret.status_code) + self.assertTrue(ret.json['error_message']) + self.assertTrue(mock_dpt.called) + + def test_delete_portgroup_invalid_api_version(self, mock_dpt): + response = self.delete('/portgroups/%s' % self.portgroup.uuid, + expect_errors=True, + headers={api_base.Version.string: '1.14'}) + self.assertEqual(http_client.NOT_FOUND, response.status_int) + + def test_delete_portgroup_byname(self, mock_dpt): + self.delete('/portgroups/%s' % self.portgroup.name, + headers=self.headers) + self.assertTrue(mock_dpt.called) + + def test_delete_portgroup_byname_not_existed(self, mock_dpt): + res = self.delete('/portgroups/%s' % 'blah', expect_errors=True, + headers=self.headers) + self.assertEqual(http_client.NOT_FOUND, res.status_code) diff --git a/ironic/tests/unit/api/v1/test_utils.py b/ironic/tests/unit/api/v1/test_utils.py index b6bed34fe2..1887572add 100644 --- a/ironic/tests/unit/api/v1/test_utils.py +++ b/ironic/tests/unit/api/v1/test_utils.py @@ -285,6 +285,13 @@ class TestApiUtils(base.TestCase): mock_request.version.minor = 20 self.assertFalse(utils.allow_resource_class()) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_allow_portgroups(self, mock_request): + mock_request.version.minor = 23 + self.assertTrue(utils.allow_portgroups()) + mock_request.version.minor = 22 + self.assertFalse(utils.allow_portgroups()) + class TestNodeIdent(base.TestCase): @@ -467,3 +474,34 @@ class TestVendorPassthru(base.TestCase): self.assertEqual(sorted(expected), sorted(utils.get_controller_reserved_names( api_node.NodesController))) + + +class TestPortgroupIdent(base.TestCase): + def setUp(self): + super(TestPortgroupIdent, self).setUp() + self.valid_name = 'my-portgroup' + self.valid_uuid = uuidutils.generate_uuid() + self.invalid_name = 'My Portgroup' + self.portgroup = test_api_utils.post_get_test_portgroup() + + @mock.patch.object(pecan, 'request', spec_set=["context"]) + @mock.patch.object(objects.Portgroup, 'get_by_name') + def test_get_rpc_portgroup_name(self, mock_gbn, mock_pr): + mock_gbn.return_value = self.portgroup + self.assertEqual(self.portgroup, utils.get_rpc_portgroup( + self.valid_name)) + mock_gbn.assert_called_once_with(mock_pr.context, self.valid_name) + + @mock.patch.object(pecan, 'request', spec_set=["context"]) + @mock.patch.object(objects.Portgroup, 'get_by_uuid') + def test_get_rpc_portgroup_uuid(self, mock_gbu, mock_pr): + self.portgroup['uuid'] = self.valid_uuid + mock_gbu.return_value = self.portgroup + self.assertEqual(self.portgroup, utils.get_rpc_portgroup( + self.valid_uuid)) + mock_gbu.assert_called_once_with(mock_pr.context, self.valid_uuid) + + def test_get_rpc_portgroup_invalid_name(self): + self.assertRaises(exception.InvalidUuidOrName, + utils.get_rpc_portgroup, + self.invalid_name) diff --git a/releasenotes/notes/add_portgroup_support-7d5c6663bb00684a.yaml b/releasenotes/notes/add_portgroup_support-7d5c6663bb00684a.yaml new file mode 100644 index 0000000000..b1fcc4324c --- /dev/null +++ b/releasenotes/notes/add_portgroup_support-7d5c6663bb00684a.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds support for portgroups with a new endpoint `/v1/portgroups/` + in the REST API version 1.23. Ports can be combined into + portgroups to support static LAG and MLAG configurations.