Browse Source

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 <jenny.moorehead@sap.com>
Co-Authored-By: Will Stevenson <will.stevenson@sap.com>
Co-Authored-By: Vasyl Saienko <vsaienko@mirantis.com>
Co-Authored-By: Vladyslav Drok <vdrok@mirantis.com>
Co-Authored-By: Zhenguo Niu <Niu.ZGlinux@gmail.com>
Co-Authored-By: Michael Turek<mjturek@linux.vnet.ibm.com>
Change-Id: I03ab55c15c1ee2fdd4b2786e366f9502c1ad8972
tags/7.0.0
Vladyslav Drok Vasyl Saienko 3 years ago
parent
commit
a7310377e2
12 changed files with 1587 additions and 5 deletions
  1. +4
    -0
      doc/source/dev/webapi-version-history.rst
  2. +8
    -0
      etc/ironic/policy.json.sample
  3. +12
    -0
      ironic/api/controllers/v1/__init__.py
  4. +481
    -0
      ironic/api/controllers/v1/portgroup.py
  5. +31
    -0
      ironic/api/controllers/v1/utils.py
  6. +3
    -1
      ironic/api/controllers/v1/versions.py
  7. +16
    -0
      ironic/common/policy.py
  8. +17
    -4
      ironic/tests/unit/api/test_root.py
  9. +17
    -0
      ironic/tests/unit/api/utils.py
  10. +954
    -0
      ironic/tests/unit/api/v1/test_portgroups.py
  11. +38
    -0
      ironic/tests/unit/api/v1/test_utils.py
  12. +6
    -0
      releasenotes/notes/add_portgroup_support-7d5c6663bb00684a.yaml

+ 4
- 0
doc/source/dev/webapi-version-history.rst View File

@@ -2,6 +2,10 @@
REST API Version History
========================

**1.23**

Added '/v1/portgroups/ endpoint.

**1.22**

Added endpoints for deployment ramdisks.


+ 8
- 0
etc/ironic/policy.json.sample View File

@@ -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


+ 12
- 0
ironic/api/controllers/v1/__init__.py View File

@@ -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()


+ 481
- 0
ironic/api/controllers/v1/portgroup.py View File

@@ -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)

+ 31
- 0
ironic/api/controllers/v1/utils.py View File

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



+ 3
- 1
ironic/api/controllers/v1/versions.py View File

@@ -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)


+ 16
- 0
ironic/common/policy.py View File

@@ -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)


+ 17
- 4
ironic/tests/unit/api/test_root.py View File

@@ -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'])

+ 17
- 0
ironic/tests/unit/api/utils.py View File

@@ -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

+ 954
- 0
ironic/tests/unit/api/v1/test_portgroups.py View File

@@ -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)

+ 38
- 0
ironic/tests/unit/api/v1/test_utils.py View File

@@ -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)

+ 6
- 0
releasenotes/notes/add_portgroup_support-7d5c6663bb00684a.yaml View File

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

Loading…
Cancel
Save