Add logical name support to Ironic

Everything that is tracked in Ironic is done via UUID. This isn't very
friendly to humans.

This change adds a new concept to ironic, that being the <logical name>,
which can be used interchangeably with the <node uuid>.  Everywhere a
<node uuid> can be specified, we are able to instead specify a <logical
name>, if such an association exists for that node.

APIImpact:
    This raises the X-OpenStack-Ironic-API-Version to 1.5, and
    adds a new "name" property to nodes, which can be substituted
    for the UUID in requests.

    This also adds a 'node' parameter for 'GET /v1/nodes/validate'. This
    parameter can be a node's UUID or name. The existing 'node_uuid'
    parameter is still supported.

DocImpact

blueprint: logical-names

Co-Authored-By: Devananda van der Veen <devananda.vdv@gmail.com>
Change-Id: I505181a1df064194848e3d5b79d01746024ce037
This commit is contained in:
Michael Davies 2014-12-15 15:02:35 +10:30
parent cf9932fe63
commit ea25926c26
16 changed files with 937 additions and 146 deletions

View File

@ -59,7 +59,8 @@ MIN_VER = base.Version({base.Version.string: "1.1"})
# v1.2: Renamed NOSTATE ("None") to AVAILABLE ("available")
# v1.3: Add node.driver_internal_info
# v1.4: Add MANAGEABLE state
MAX_VER = base.Version({base.Version.string: "1.4"})
# v1.5: Add logical node names
MAX_VER = base.Version({base.Version.string: "1.5"})
class MediaType(base.APIBase):

View File

@ -76,6 +76,45 @@ def check_allow_management_verbs(verb):
raise exception.NotAcceptable()
def allow_logical_names():
try:
# v1.5 added logical name aliases
if pecan.request.version.minor < 5:
return False
# ignore check if we're not in a pecan context
except AttributeError:
pass
return True
def is_valid_name(name):
return utils.is_hostname_safe(name) and (not utils.is_uuid_like(name))
def _get_rpc_node(node_ident):
"""Get the RPC node from the node uuid or logical name.
:param node_ident: the UUID or logical name of a node.
:returns: The RPC Node.
:raises: InvalidUuidOrName if the name or uuid provided is not valid.
:raises: NodeNotFound if the node is not found.
"""
# Check to see if the node_ident is a valid UUID. If it is, treat it
# as a UUID.
if utils.is_uuid_like(node_ident):
return objects.Node.get_by_uuid(pecan.request.context, node_ident)
# If it was not UUID-like, but it is name-like, and we allow names,
# check for nodes by that name
if allow_logical_names() and utils.is_hostname_safe(node_ident):
return objects.Node.get_by_name(pecan.request.context, node_ident)
# It's not a valid uuid, or it's not a valid name, or we don't allow names
raise exception.InvalidUuidOrName(name=node_ident)
class NodePatchType(types.JsonPatchType):
@staticmethod
@ -100,10 +139,10 @@ class BootDeviceController(rest.RestController):
'supported': ['GET'],
}
def _get_boot_device(self, node_uuid, supported=False):
def _get_boot_device(self, node_ident, supported=False):
"""Get the current boot device or a list of supported devices.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical name of a node.
:param supported: Boolean value. If true return a list of
supported boot devices, if false return the
current boot device. Default: False.
@ -111,23 +150,23 @@ class BootDeviceController(rest.RestController):
boot devices.
"""
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
if supported:
return pecan.request.rpcapi.get_supported_boot_devices(
pecan.request.context, node_uuid, topic)
pecan.request.context, rpc_node.uuid, topic)
else:
return pecan.request.rpcapi.get_boot_device(pecan.request.context,
node_uuid, topic)
rpc_node.uuid, topic)
@wsme_pecan.wsexpose(None, types.uuid, wtypes.text, types.boolean,
@wsme_pecan.wsexpose(None, types.uuid_or_name, wtypes.text, types.boolean,
status_code=204)
def put(self, node_uuid, boot_device, persistent=False):
def put(self, node_ident, boot_device, persistent=False):
"""Set the boot device for a node.
Set the boot device to use on next reboot of the node.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical name of a node.
:param boot_device: the boot device, one of
:mod:`ironic.common.boot_devices`.
:param persistent: Boolean value. True if the boot device will
@ -135,18 +174,19 @@ class BootDeviceController(rest.RestController):
Default: False.
"""
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
pecan.request.rpcapi.set_boot_device(pecan.request.context, node_uuid,
pecan.request.rpcapi.set_boot_device(pecan.request.context,
rpc_node.uuid,
boot_device,
persistent=persistent,
topic=topic)
@wsme_pecan.wsexpose(wtypes.text, types.uuid)
def get(self, node_uuid):
@wsme_pecan.wsexpose(wtypes.text, types.uuid_or_name)
def get(self, node_ident):
"""Get the current boot device for a node.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical name of a node.
:returns: a json object containing:
:boot_device: the boot device, one of
@ -155,18 +195,18 @@ class BootDeviceController(rest.RestController):
future boots or not, None if it is unknown.
"""
return self._get_boot_device(node_uuid)
return self._get_boot_device(node_ident)
@wsme_pecan.wsexpose(wtypes.text, types.uuid)
def supported(self, node_uuid):
@wsme_pecan.wsexpose(wtypes.text, types.uuid_or_name)
def supported(self, node_ident):
"""Get a list of the supported boot devices.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical name of a node.
:returns: A json object with the list of supported boot
devices.
"""
boot_devices = self._get_boot_device(node_uuid, supported=True)
boot_devices = self._get_boot_device(node_ident, supported=True)
return {'supported_boot_devices': boot_devices}
@ -194,18 +234,17 @@ class ConsoleInfo(base.APIBase):
class NodeConsoleController(rest.RestController):
@wsme_pecan.wsexpose(ConsoleInfo, types.uuid)
def get(self, node_uuid):
@wsme_pecan.wsexpose(ConsoleInfo, types.uuid_or_name)
def get(self, node_ident):
"""Get connection information about the console.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
"""
rpc_node = objects.Node.get_by_uuid(pecan.request.context,
node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
try:
console = pecan.request.rpcapi.get_console_information(
pecan.request.context, node_uuid, topic)
pecan.request.context, rpc_node.uuid, topic)
console_state = True
except exception.NodeConsoleNotEnabled:
console = None
@ -213,20 +252,21 @@ class NodeConsoleController(rest.RestController):
return ConsoleInfo(console_enabled=console_state, console_info=console)
@wsme_pecan.wsexpose(None, types.uuid, types.boolean, status_code=202)
def put(self, node_uuid, enabled):
@wsme_pecan.wsexpose(None, types.uuid_or_name, types.boolean,
status_code=202)
def put(self, node_ident, enabled):
"""Start and stop the node console.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
:param enabled: Boolean value; whether to enable or disable the
console.
"""
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
pecan.request.rpcapi.set_console_mode(pecan.request.context, node_uuid,
enabled, topic)
pecan.request.rpcapi.set_console_mode(pecan.request.context,
rpc_node.uuid, enabled, topic)
# Set the HTTP Location Header
url_args = '/'.join([node_uuid, 'states', 'console'])
url_args = '/'.join([node_ident, 'states', 'console'])
pecan.response.location = link.build_url('nodes', url_args)
@ -289,23 +329,24 @@ class NodeStatesController(rest.RestController):
console = NodeConsoleController()
"""Expose console as a sub-element of states"""
@wsme_pecan.wsexpose(NodeStates, types.uuid)
def get(self, node_uuid):
@wsme_pecan.wsexpose(NodeStates, types.uuid_or_name)
def get(self, node_ident):
"""List the states of the node.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical_name of a node.
"""
# NOTE(lucasagomes): All these state values come from the
# DB. Ironic counts with a periodic task that verify the current
# power states of the nodes and update the DB accordingly.
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
return NodeStates.convert(rpc_node)
@wsme_pecan.wsexpose(None, types.uuid, wtypes.text, status_code=202)
def power(self, node_uuid, target):
@wsme_pecan.wsexpose(None, types.uuid_or_name, wtypes.text,
status_code=202)
def power(self, node_ident, target):
"""Set the power state of the node.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical name of a node.
:param target: The desired power state of the node.
:raises: ClientSideError (HTTP 409) if a power operation is
already in progress.
@ -315,25 +356,27 @@ class NodeStatesController(rest.RestController):
"""
# TODO(lucasagomes): Test if it's able to transition to the
# target state from the current one
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
if target not in [ir_states.POWER_ON,
ir_states.POWER_OFF,
ir_states.REBOOT]:
raise exception.InvalidStateRequested(
action=target, node=node_uuid, state=rpc_node.power_state)
action=target, node=node_ident,
state=rpc_node.power_state)
pecan.request.rpcapi.change_node_power_state(pecan.request.context,
node_uuid, target, topic)
rpc_node.uuid, target,
topic)
# Set the HTTP Location Header
url_args = '/'.join([node_uuid, 'states'])
url_args = '/'.join([node_ident, 'states'])
pecan.response.location = link.build_url('nodes', url_args)
@wsme_pecan.wsexpose(None, types.uuid, wtypes.text, wtypes.text,
status_code=202)
def provision(self, node_uuid, target, configdrive=None):
"""Asynchronously trigger the provisioning of the node.
@wsme_pecan.wsexpose(None, types.uuid_or_name, wtypes.text,
wtypes.text, status_code=202)
def provision(self, node_ident, target, configdrive=None):
"""Asynchronous trigger the provisioning of the node.
This will set the target provision state of the node, and a
background task will begin which actually applies the state
@ -342,7 +385,7 @@ class NodeStatesController(rest.RestController):
continue to GET the status of this node to observe the status
of the requested action.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
:param target: The desired provision state of the node.
:param configdrive: Optional. A gzipped and base64 encoded
configdrive. Only valid when setting provision state
@ -356,7 +399,7 @@ class NodeStatesController(rest.RestController):
not allow the requested state transition.
"""
check_allow_management_verbs(target)
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
# Normally, we let the task manager recognize and deal with
@ -373,7 +416,7 @@ class NodeStatesController(rest.RestController):
m.initialize(rpc_node.provision_state)
if not m.is_valid_event(ir_states.VERBS.get(target, target)):
raise exception.InvalidStateRequested(
action=target, node=node_uuid,
action=target, node=rpc_node.uuid,
state=rpc_node.provision_state)
if configdrive and target != ir_states.ACTIVE:
@ -386,26 +429,26 @@ class NodeStatesController(rest.RestController):
# lock.
if target == ir_states.ACTIVE:
pecan.request.rpcapi.do_node_deploy(pecan.request.context,
node_uuid, False,
rpc_node.uuid, False,
configdrive, topic)
elif target == ir_states.REBUILD:
pecan.request.rpcapi.do_node_deploy(pecan.request.context,
node_uuid, True,
rpc_node.uuid, True,
None, topic)
elif target == ir_states.DELETED:
pecan.request.rpcapi.do_node_tear_down(
pecan.request.context, node_uuid, topic)
pecan.request.context, rpc_node.uuid, topic)
elif target in (
ir_states.VERBS['manage'], ir_states.VERBS['provide']):
pecan.request.rpcapi.do_provisioning_action(
pecan.request.context, node_uuid, target, topic)
pecan.request.context, rpc_node.uuid, target, topic)
else:
msg = (_('The requested action "%(action)s" could not be '
'understood.') % {'action': target})
raise exception.InvalidStateRequested(message=msg)
# Set the HTTP Location Header
url_args = '/'.join([node_uuid, 'states'])
url_args = '/'.join([node_ident, 'states'])
pecan.response.location = link.build_url('nodes', url_args)
@ -444,6 +487,9 @@ class Node(base.APIBase):
instance_uuid = types.uuid
"""The UUID of the instance in nova-compute"""
name = wsme.wsattr(wtypes.text)
"""The logical name for this node"""
power_state = wsme.wsattr(wtypes.text, readonly=True)
"""Represent the current (not transition) power state of the node"""
@ -535,7 +581,7 @@ class Node(base.APIBase):
def _convert_with_links(node, url, expand=True, show_password=True):
if not expand:
except_list = ['instance_uuid', 'maintenance', 'power_state',
'provision_state', 'uuid']
'provision_state', 'uuid', 'name']
node.unset_fields_except(except_list)
else:
if not show_password:
@ -553,6 +599,9 @@ class Node(base.APIBase):
# the user, it's internal only.
node.chassis_id = wtypes.Unset
if not allow_logical_names():
node.name = wsme.Unset
node.links = [link.Link.make_link('self', url, 'nodes',
node.uuid),
link.Link.make_link('bookmark', url, 'nodes',
@ -574,8 +623,9 @@ class Node(base.APIBase):
time = datetime.datetime(2000, 1, 1, 12, 0, 0)
node_uuid = '1be26c0b-03f2-4d2e-ae87-c02d7f33c123'
instance_uuid = 'dcf1fbc5-93fc-4596-9395-b80572f6267b'
name = 'database16-dc02'
sample = cls(uuid=node_uuid, instance_uuid=instance_uuid,
power_state=ir_states.POWER_ON,
name=name, power_state=ir_states.POWER_ON,
target_power_state=ir_states.NOSTATE,
last_error=None, provision_state=ir_states.ACTIVE,
target_provision_state=ir_states.NOSTATE,
@ -628,38 +678,37 @@ class NodeVendorPassthruController(rest.RestController):
'methods': ['GET']
}
@wsme_pecan.wsexpose(wtypes.text, types.uuid)
def methods(self, node_uuid):
@wsme_pecan.wsexpose(wtypes.text, types.uuid_or_name)
def methods(self, node_ident):
"""Retrieve information about vendor methods of the given node.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
:returns: dictionary with <vendor method name>:<method metadata>
entries.
:raises: NodeNotFound if the node is not found.
"""
# Raise an exception if node is not found
rpc_node = objects.Node.get_by_uuid(pecan.request.context,
node_uuid)
rpc_node = _get_rpc_node(node_ident)
if rpc_node.driver not in _VENDOR_METHODS:
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
ret = pecan.request.rpcapi.get_node_vendor_passthru_methods(
pecan.request.context, node_uuid, topic=topic)
pecan.request.context, rpc_node.uuid, topic=topic)
_VENDOR_METHODS[rpc_node.driver] = ret
return _VENDOR_METHODS[rpc_node.driver]
@wsme_pecan.wsexpose(wtypes.text, types.uuid, wtypes.text,
@wsme_pecan.wsexpose(wtypes.text, types.uuid_or_name, wtypes.text,
body=wtypes.text)
def _default(self, node_uuid, method, data=None):
def _default(self, node_ident, method, data=None):
"""Call a vendor extension.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
:param method: name of the method in vendor driver.
:param data: body of data to supply to the specified method.
"""
# Raise an exception if node is not found
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
# Raise an exception if method is not specified
@ -671,7 +720,7 @@ class NodeVendorPassthruController(rest.RestController):
http_method = pecan.request.method.upper()
ret, is_async = pecan.request.rpcapi.vendor_passthru(
pecan.request.context, node_uuid, method,
pecan.request.context, rpc_node.uuid, method,
http_method, data, topic)
status_code = 202 if is_async else 200
return wsme.api.Response(ret, status_code=status_code)
@ -679,8 +728,8 @@ class NodeVendorPassthruController(rest.RestController):
class NodeMaintenanceController(rest.RestController):
def _set_maintenance(self, node_uuid, maintenance_mode, reason=None):
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
def _set_maintenance(self, node_ident, maintenance_mode, reason=None):
rpc_node = _get_rpc_node(node_ident)
rpc_node.maintenance = maintenance_mode
rpc_node.maintenance_reason = reason
@ -692,24 +741,25 @@ class NodeMaintenanceController(rest.RestController):
pecan.request.rpcapi.update_node(pecan.request.context,
rpc_node, topic=topic)
@wsme_pecan.wsexpose(None, types.uuid, wtypes.text, status_code=202)
def put(self, node_uuid, reason=None):
@wsme_pecan.wsexpose(None, types.uuid_or_name, wtypes.text,
status_code=202)
def put(self, node_ident, reason=None):
"""Put the node in maintenance mode.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical_name of a node.
:param reason: Optional, the reason why it's in maintenance.
"""
self._set_maintenance(node_uuid, True, reason=reason)
self._set_maintenance(node_ident, True, reason=reason)
@wsme_pecan.wsexpose(None, types.uuid, status_code=202)
def delete(self, node_uuid):
@wsme_pecan.wsexpose(None, types.uuid_or_name, status_code=202)
def delete(self, node_ident):
"""Remove the node from maintenance mode.
:param node_uuid: UUID of a node.
:param node_ident: the UUID or logical name of a node.
"""
self._set_maintenance(node_uuid, False)
self._set_maintenance(node_ident, False)
class NodesController(rest.RestController):
@ -857,28 +907,38 @@ class NodesController(rest.RestController):
limit, sort_key, sort_dir, expand,
resource_url)
@wsme_pecan.wsexpose(wtypes.text, types.uuid)
def validate(self, node_uuid):
"""Validate the driver interfaces.
@wsme_pecan.wsexpose(wtypes.text, types.uuid_or_name, types.uuid)
def validate(self, node=None, node_uuid=None):
"""Validate the driver interfaces, using the node's UUID or name.
Note that the 'node_uuid' interface is deprecated in favour
of the 'node' interface
:param node: UUID or name of a node.
:param node_uuid: UUID of a node.
"""
# check if node exists
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
if node:
# We're invoking this interface using positional notation, or
# explicitly using 'node'. Try and determine which one.
if not allow_logical_names() and not utils.is_uuid_like(node):
raise exception.NotAcceptable()
rpc_node = _get_rpc_node(node_uuid or node)
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
return pecan.request.rpcapi.validate_driver_interfaces(
pecan.request.context, rpc_node.uuid, topic)
@wsme_pecan.wsexpose(Node, types.uuid)
def get_one(self, node_uuid):
@wsme_pecan.wsexpose(Node, types.uuid_or_name)
def get_one(self, node_ident):
"""Retrieve information about the given node.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
"""
if self.from_chassis:
raise exception.OperationNotPermitted
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
return Node.convert_with_links(rpc_node)
@wsme_pecan.wsexpose(Node, body=Node, status_code=201)
@ -906,6 +966,16 @@ class NodesController(rest.RestController):
e.code = 400
raise e
# Verify that if we're creating a new node with a 'name' set
# that it is a valid name
if node.name:
if not allow_logical_names():
raise exception.NotAcceptable()
if not is_valid_name(node.name):
msg = _("Cannot create node with invalid name %(name)s")
raise wsme.exc.ClientSideError(msg % {'name': node.name},
status_code=400)
new_node = objects.Node(pecan.request.context,
**node.as_dict())
new_node.create()
@ -914,17 +984,17 @@ class NodesController(rest.RestController):
return Node.convert_with_links(new_node)
@wsme.validate(types.uuid, [NodePatchType])
@wsme_pecan.wsexpose(Node, types.uuid, body=[NodePatchType])
def patch(self, node_uuid, patch):
@wsme_pecan.wsexpose(Node, types.uuid_or_name, body=[NodePatchType])
def patch(self, node_ident, patch):
"""Update an existing node.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
:param patch: a json PATCH document to apply to this node.
"""
if self.from_chassis:
raise exception.OperationNotPermitted
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
# Check if node is transitioning state, although nodes in DEPLOYFAIL
# can be updated.
@ -932,7 +1002,19 @@ class NodesController(rest.RestController):
and rpc_node.provision_state != ir_states.DEPLOYFAIL):
msg = _("Node %s can not be updated while a state transition "
"is in progress.")
raise wsme.exc.ClientSideError(msg % node_uuid, status_code=409)
raise wsme.exc.ClientSideError(msg % node_ident, status_code=409)
# Verify that if we're patching 'name' that it is a valid
name = api_utils.get_patch_value(patch, '/name')
if name:
if not allow_logical_names():
raise exception.NotAcceptable()
if not is_valid_name(name):
msg = _("Node %(node)s: Cannot change name to invalid "
"name '%(name)s'")
raise wsme.exc.ClientSideError(msg % {'node': node_ident,
'name': name},
status_code=400)
try:
node_dict = rpc_node.as_dict()
@ -975,16 +1057,17 @@ class NodesController(rest.RestController):
return Node.convert_with_links(new_node)
@wsme_pecan.wsexpose(None, types.uuid, status_code=204)
def delete(self, node_uuid):
@wsme_pecan.wsexpose(None, types.uuid_or_name, status_code=204)
def delete(self, node_ident):
"""Delete a node.
:param node_uuid: UUID of a node.
:param node_ident: UUID or logical name of a node.
"""
if self.from_chassis:
raise exception.OperationNotPermitted
rpc_node = objects.Node.get_by_uuid(pecan.request.context, node_uuid)
rpc_node = _get_rpc_node(node_ident)
try:
topic = pecan.request.rpcapi.get_topic_for(rpc_node)
except exception.NoValidHost as e:
@ -992,4 +1075,4 @@ class NodesController(rest.RestController):
raise e
pecan.request.rpcapi.destroy_node(pecan.request.context,
node_uuid, topic)
rpc_node.uuid, topic)

View File

@ -49,6 +49,54 @@ class MacAddressType(wtypes.UserType):
return MacAddressType.validate(value)
class UuidOrNameType(wtypes.UserType):
"""A simple UUID or logical name type."""
basetype = wtypes.text
name = 'uuid_or_name'
# FIXME(lucasagomes): When used with wsexpose decorator WSME will try
# to get the name of the type by accessing it's __name__ attribute.
# Remove this __name__ attribute once it's fixed in WSME.
# https://bugs.launchpad.net/wsme/+bug/1265590
__name__ = name
@staticmethod
def validate(value):
if not (utils.is_uuid_like(value) or utils.is_hostname_safe(value)):
raise exception.InvalidUuidOrName(name=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return UuidOrNameType.validate(value)
class NameType(wtypes.UserType):
"""A simple logical name type."""
basetype = wtypes.text
name = 'name'
# FIXME(lucasagomes): When used with wsexpose decorator WSME will try
# to get the name of the type by accessing it's __name__ attribute.
# Remove this __name__ attribute once it's fixed in WSME.
# https://bugs.launchpad.net/wsme/+bug/1265590
__name__ = name
@staticmethod
def validate(value):
if not utils.is_hostname_safe(value):
raise exception.InvalidName(name=value)
return value
@staticmethod
def frombasetype(value):
if value is None:
return None
return NameType.validate(value)
class UuidType(wtypes.UserType):
"""A simple UUID type."""
@ -130,6 +178,8 @@ class JsonType(wtypes.UserType):
macaddress = MacAddressType()
uuid_or_name = UuidOrNameType()
name = NameType()
uuid = UuidType()
boolean = BooleanType()
# Can't call it 'json' because that's the name of the stdlib module

View File

@ -50,3 +50,9 @@ def apply_jsonpatch(doc, patch):
' the resource is not allowed')
raise wsme.exc.ClientSideError(msg % p['path'])
return jsonpatch.apply_patch(doc, jsonpatch.JsonPatch(patch))
def get_patch_value(patch, path):
for p in patch:
if p['path'] == path:
return p['value']

View File

@ -150,10 +150,22 @@ class InstanceAssociated(Conflict):
" it cannot be associated with this other node %(node)s")
class DuplicateName(Conflict):
message = _("A node with name %(name)s already exists.")
class InvalidUUID(Invalid):
message = _("Expected a uuid but received %(uuid)s.")
class InvalidUuidOrName(Invalid):
message = _("Expected a logical name or uuid but received %(name)s.")
class InvalidName(Invalid):
message = _("Expected a logical name but received %(name)s.")
class InvalidIdentity(Invalid):
message = _("Expected an uuid or int but received %(identity)s.")

View File

@ -182,9 +182,25 @@ def is_valid_mac(address):
"""
m = "[0-9a-f]{2}(:[0-9a-f]{2}){5}$"
if isinstance(address, six.string_types) and re.match(m, address.lower()):
return True
return False
return (isinstance(address, six.string_types) and
re.match(m, address.lower()))
def is_hostname_safe(hostname):
"""Determine if the supplied hostname is RFC compliant.
Check that the supplied hostname conforms to:
* http://en.wikipedia.org/wiki/Hostname
* http://tools.ietf.org/html/rfc952
* http://tools.ietf.org/html/rfc1123
:param hostname: The hostname to be validated.
:returns: True if valid. False if not.
"""
m = '^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$'
return (isinstance(hostname, six.string_types) and
(re.match(m, hostname) is not None))
def validate_and_normalize_mac(address):

View File

@ -161,6 +161,14 @@ class Connection(object):
:returns: A node.
"""
@abc.abstractmethod
def get_node_by_name(self, node_name):
"""Return a node.
:param node_name: The logical name of a node.
:returns: A node.
"""
@abc.abstractmethod
def get_node_by_instance(self, instance):
"""Return a node.

View File

@ -0,0 +1,37 @@
# 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.
"""add_logical_name
Revision ID: 3ae36a5f5131
Revises: bb59b63f55a
Create Date: 2014-12-10 14:27:26.323540
"""
# revision identifiers, used by Alembic.
revision = '3ae36a5f5131'
down_revision = 'bb59b63f55a'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('nodes', sa.Column('name', sa.String(length=63),
nullable=True))
op.create_unique_constraint('uniq_nodes0name', 'nodes', ['name'])
def downgrade():
op.drop_constraint('uniq_nodes0name', 'nodes', type_='unique')
op.drop_column('nodes', 'name')

View File

@ -266,7 +266,9 @@ class Connection(api.Connection):
try:
node.save()
except db_exc.DBDuplicateEntry as exc:
if 'instance_uuid' in exc.columns:
if 'name' in exc.columns:
raise exception.DuplicateName(name=values['name'])
elif 'instance_uuid' in exc.columns:
raise exception.InstanceAssociated(
instance_uuid=values['instance_uuid'],
node=values['uuid'])
@ -287,6 +289,13 @@ class Connection(api.Connection):
except NoResultFound:
raise exception.NodeNotFound(node=node_uuid)
def get_node_by_name(self, node_name):
query = model_query(models.Node).filter_by(name=node_name)
try:
return query.one()
except NoResultFound:
raise exception.NodeNotFound(node=node_name)
def get_node_by_instance(self, instance):
if not utils.is_uuid_like(instance):
raise exception.InvalidUUID(uuid=instance)
@ -331,10 +340,17 @@ class Connection(api.Connection):
try:
return self._do_update_node(node_id, values)
except db_exc.DBDuplicateEntry:
raise exception.InstanceAssociated(
instance_uuid=values['instance_uuid'],
node=node_id)
except db_exc.DBDuplicateEntry as e:
if 'name' in e.columns:
raise exception.DuplicateName(name=values['name'])
elif 'uuid' in e.columns:
raise exception.NodeAlreadyExists(uuid=values['uuid'])
elif 'instance_uuid' in e.columns:
raise exception.InstanceAssociated(
instance_uuid=values['instance_uuid'],
node=node_id)
else:
raise e
def _do_update_node(self, node_id, values):
session = get_session()

View File

@ -146,6 +146,7 @@ class Node(Base):
schema.UniqueConstraint('uuid', name='uniq_nodes0uuid'),
schema.UniqueConstraint('instance_uuid',
name='uniq_nodes0instance_uuid'),
schema.UniqueConstraint('name', name='uniq_nodes0name'),
table_args())
id = Column(Integer, primary_key=True)
uuid = Column(String(36))
@ -153,6 +154,7 @@ class Node(Base):
# filter on it more efficiently, even though it is
# user-settable, and would otherwise be in node.properties.
instance_uuid = Column(String(36), nullable=True)
name = Column(String(63), nullable=True)
chassis_id = Column(Integer, ForeignKey('chassis.id'), nullable=True)
power_state = Column(String(15), nullable=True)
target_power_state = Column(String(15), nullable=True)

View File

@ -32,7 +32,8 @@ class Node(base.IronicObject):
# Version 1.7: Add conductor_affinity
# Version 1.8: Add maintenance_reason
# Version 1.9: Add driver_internal_info
VERSION = '1.9'
# Version 1.10: Add name and get_by_name()
VERSION = '1.10'
dbapi = db_api.get_instance()
@ -40,6 +41,7 @@ class Node(base.IronicObject):
'id': int,
'uuid': obj_utils.str_or_none,
'name': obj_utils.str_or_none,
'chassis_id': obj_utils.int_or_none,
'instance_uuid': obj_utils.str_or_none,
@ -122,6 +124,17 @@ class Node(base.IronicObject):
node = Node._from_db_object(cls(context), db_node)
return node
@base.remotable_classmethod
def get_by_name(cls, context, name):
"""Find a node based on name and return a Node object.
:param name: the logical name of a node.
:returns: a :class:`Node` object.
"""
db_node = cls.dbapi.get_node_by_name(name)
node = Node._from_db_object(cls(context), db_node)
return node
@base.remotable_classmethod
def get_by_instance_uuid(cls, context, instance_uuid):
"""Find a node based on the instance uuid and return a Node object.

View File

@ -21,6 +21,7 @@ import json
import mock
from oslo.utils import timeutils
from oslo_config import cfg
import pecan
from six.moves.urllib import parse as urlparse
from testtools.matchers import HasLength
from wsme import types as wtypes
@ -51,6 +52,71 @@ def post_get_test_node(**kw):
return node
class TestTopLevelFunctions(base.TestCase):
def setUp(self):
super(TestTopLevelFunctions, self).setUp()
self.valid_name = 'my-host'
self.valid_uuid = utils.generate_uuid()
self.invalid_name = 'Mr Plow'
self.invalid_uuid = '636-555-3226-'
self.node = post_get_test_node()
def test_is_valid_name(self):
self.assertTrue(api_node.is_valid_name(self.valid_name))
self.assertFalse(api_node.is_valid_name(self.invalid_name))
self.assertFalse(api_node.is_valid_name(self.valid_uuid))
self.assertFalse(api_node.is_valid_name(self.invalid_uuid))
@mock.patch.object(pecan, 'request')
@mock.patch.object(api_node, 'allow_logical_names')
@mock.patch.object(objects.Node, 'get_by_uuid')
@mock.patch.object(objects.Node, 'get_by_name')
def test__get_rpc_node_expect_uuid(self, mock_gbn, mock_gbu, mock_aln,
mock_pr):
mock_aln.return_value = True
self.node['uuid'] = self.valid_uuid
mock_gbu.return_value = self.node
self.assertEqual(self.node, api_node._get_rpc_node(self.valid_uuid))
self.assertEqual(1, mock_gbu.call_count)
self.assertEqual(0, mock_gbn.call_count)
@mock.patch.object(pecan, 'request')
@mock.patch.object(api_v1.node, 'allow_logical_names')
@mock.patch.object(objects.Node, 'get_by_uuid')
@mock.patch.object(objects.Node, 'get_by_name')
def test__get_rpc_node_expect_name(self, mock_gbn, mock_gbu, mock_aln,
mock_pr):
mock_aln.return_value = True
self.node['name'] = self.valid_name
mock_gbn.return_value = self.node
self.assertEqual(self.node, api_node._get_rpc_node(self.valid_name))
self.assertEqual(0, mock_gbu.call_count)
self.assertEqual(1, mock_gbn.call_count)
@mock.patch.object(pecan, 'request')
@mock.patch.object(api_v1.node, 'allow_logical_names')
@mock.patch.object(objects.Node, 'get_by_uuid')
@mock.patch.object(objects.Node, 'get_by_name')
def test__get_rpc_node_invalid_name(self, mock_gbn, mock_gbu,
mock_aln, mock_pr):
mock_aln.return_value = True
self.assertRaises(exception.InvalidUuidOrName,
api_node._get_rpc_node,
self.invalid_name)
@mock.patch.object(pecan, 'request')
@mock.patch.object(api_v1.node, 'allow_logical_names')
@mock.patch.object(objects.Node, 'get_by_uuid')
@mock.patch.object(objects.Node, 'get_by_name')
def test__get_rpc_node_invalid_uuid(self, mock_gbn, mock_gbu,
mock_aln, mock_pr):
mock_aln.return_value = True
self.assertRaises(exception.InvalidUuidOrName,
api_node._get_rpc_node,
self.invalid_uuid)
class TestNodeObject(base.TestCase):
def test_node_init(self):
@ -84,7 +150,7 @@ class TestListNodes(test_api_base.FunctionalTest):
node = obj_utils.create_test_node(
self.context, uuid=utils.generate_uuid(),
instance_uuid=utils.generate_uuid())
associated_nodes.append(node['uuid'])
associated_nodes.append(node.uuid)
return {'associated': associated_nodes,
'unassociated': unassociated_nodes}
@ -101,7 +167,7 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertIn('power_state', data['nodes'][0])
self.assertIn('provision_state', data['nodes'][0])
self.assertIn('uuid', data['nodes'][0])
self.assertEqual(node['uuid'], data['nodes'][0]["uuid"])
self.assertEqual(node.uuid, data['nodes'][0]["uuid"])
self.assertNotIn('driver', data['nodes'][0])
self.assertNotIn('driver_info', data['nodes'][0])
self.assertNotIn('driver_internal_info', data['nodes'][0])
@ -119,7 +185,7 @@ class TestListNodes(test_api_base.FunctionalTest):
def test_get_one(self):
node = obj_utils.create_test_node(self.context)
data = self.get_json('/nodes/%s' % node['uuid'],
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(node.uuid, data['uuid'])
self.assertIn('driver', data)
@ -132,13 +198,16 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertIn('chassis_uuid', data)
self.assertIn('reservation', data)
self.assertIn('maintenance_reason', data)
self.assertIn('name', data)
# never expose the chassis_id
self.assertNotIn('chassis_id', data)
def test_detail(self):
node = obj_utils.create_test_node(self.context)
data = self.get_json('/nodes/detail')
self.assertEqual(node['uuid'], data['nodes'][0]["uuid"])
data = self.get_json('/nodes/detail',
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(node.uuid, data['nodes'][0]["uuid"])
self.assertIn('name', data['nodes'][0])
self.assertIn('driver', data['nodes'][0])
self.assertIn('driver_info', data['nodes'][0])
self.assertIn('extra', data['nodes'][0])
@ -155,7 +224,7 @@ class TestListNodes(test_api_base.FunctionalTest):
def test_detail_against_single(self):
node = obj_utils.create_test_node(self.context)
response = self.get_json('/nodes/%s/detail' % node['uuid'],
response = self.get_json('/nodes/%s/detail' % node.uuid,
expect_errors=True)
self.assertEqual(404, response.status_int)
@ -163,25 +232,36 @@ class TestListNodes(test_api_base.FunctionalTest):
node = obj_utils.create_test_node(self.context,
provision_state=states.AVAILABLE)
data = self.get_json('/nodes/%s' % node['uuid'],
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: str(api_v1.MIN_VER)})
self.assertEqual(states.NOSTATE, data['provision_state'])
data = self.get_json('/nodes/%s' % node['uuid'],
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: "1.2"})
self.assertEqual(states.AVAILABLE, data['provision_state'])
def test_hide_driver_internal_info(self):
node = obj_utils.create_test_node(self.context,
driver_internal_info={"foo": "bar"})
data = self.get_json('/nodes/%s' % node['uuid'],
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: str(api_v1.MIN_VER)})
self.assertNotIn('driver_internal_info', data)
data = self.get_json('/nodes/%s' % node['uuid'],
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: "1.3"})
self.assertEqual({"foo": "bar"}, data['driver_internal_info'])
def test_unset_logical_names(self):
node = obj_utils.create_test_node(self.context,
name="fish")
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: "1.4"})
self.assertNotIn('name', data)
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: "1.5"})
self.assertEqual('fish', data['name'])
def test_many(self):
nodes = []
for id in range(5):
@ -194,6 +274,22 @@ class TestListNodes(test_api_base.FunctionalTest):
uuids = [n['uuid'] for n in data['nodes']]
self.assertEqual(sorted(nodes), sorted(uuids))
def test_many_have_names(self):
nodes = []
node_names = []
for id in range(5):
name = 'node-%s' % id
node = obj_utils.create_test_node(self.context,
uuid=utils.generate_uuid(),
name=name)
nodes.append(node.uuid)
node_names.append(name)
data = self.get_json('/nodes',
headers={api_base.Version.string: "1.5"})
names = [n['name'] for n in data['nodes']]
self.assertEqual(len(nodes), len(data['nodes']))
self.assertEqual(sorted(node_names), sorted(names))
def test_links(self):
uuid = utils.generate_uuid()
obj_utils.create_test_node(self.context, uuid=uuid)
@ -289,13 +385,40 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertEqual(fake_error, data['last_error'])
self.assertFalse(data['console_enabled'])
@mock.patch.object(timeutils, 'utcnow')
def test_node_states_by_name(self, mock_utcnow):
fake_state = 'fake-state'
fake_error = 'fake-error'
test_time = datetime.datetime(1971, 3, 9, 0, 0)
mock_utcnow.return_value = test_time
node = obj_utils.create_test_node(self.context,
name='eggs',
power_state=fake_state,
target_power_state=fake_state,
provision_state=fake_state,
target_provision_state=fake_state,
provision_updated_at=test_time,
last_error=fake_error)
data = self.get_json('/nodes/%s/states' % node.name,
headers={api_base.Version.string: "1.5"})
self.assertEqual(fake_state, data['power_state'])
self.assertEqual(fake_state, data['target_power_state'])
self.assertEqual(fake_state, data['provision_state'])
self.assertEqual(fake_state, data['target_provision_state'])
prov_up_at = timeutils.parse_isotime(
data['provision_updated_at']).replace(tzinfo=None)
self.assertEqual(test_time, prov_up_at)
self.assertEqual(fake_error, data['last_error'])
self.assertFalse(data['console_enabled'])
def test_node_by_instance_uuid(self):
node = obj_utils.create_test_node(self.context,
uuid=utils.generate_uuid(),
instance_uuid=utils.generate_uuid())
instance_uuid = node.instance_uuid
data = self.get_json('/nodes?instance_uuid=%s' % instance_uuid)
data = self.get_json('/nodes?instance_uuid=%s' % instance_uuid,
headers={api_base.Version.string: "1.5"})
self.assertThat(data['nodes'], HasLength(1))
self.assertEqual(node['instance_uuid'],
@ -452,6 +575,18 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertEqual(expected_data, data)
mock_gci.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'get_console_information')
def test_get_console_information_by_name(self, mock_gci):
node = obj_utils.create_test_node(self.context, name='spam')
expected_console_info = {'test': 'test-data'}
expected_data = {'console_enabled': True,
'console_info': expected_console_info}
mock_gci.return_value = expected_console_info
data = self.get_json('/nodes/%s/states/console' % node.name,
headers={api_base.Version.string: "1.5"})
self.assertEqual(expected_data, data)
mock_gci.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
def test_get_console_information_console_disabled(self):
node = obj_utils.create_test_node(self.context)
expected_data = {'console_enabled': False,
@ -484,6 +619,16 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertEqual(expected_data, data)
mock_gbd.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'get_boot_device')
def test_get_boot_device_by_name(self, mock_gbd):
node = obj_utils.create_test_node(self.context, name='spam')
expected_data = {'boot_device': boot_devices.PXE, 'persistent': True}
mock_gbd.return_value = expected_data
data = self.get_json('/nodes/%s/management/boot_device' % node.name,
headers={api_base.Version.string: "1.5"})
self.assertEqual(expected_data, data)
mock_gbd.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'get_boot_device')
def test_get_boot_device_iface_not_supported(self, mock_gbd):
node = obj_utils.create_test_node(self.context)
@ -505,6 +650,17 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertEqual(expected_data, data)
mock_gsbd.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'get_supported_boot_devices')
def test_get_supported_boot_devices_by_name(self, mock_gsbd):
mock_gsbd.return_value = [boot_devices.PXE]
node = obj_utils.create_test_node(self.context, name='spam')
data = self.get_json(
'/nodes/%s/management/boot_device/supported' % node.name,
headers={api_base.Version.string: "1.5"})
expected_data = {'supported_boot_devices': [boot_devices.PXE]}
self.assertEqual(expected_data, data)
mock_gsbd.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'get_supported_boot_devices')
def test_get_supported_boot_devices_iface_not_supported(self, mock_gsbd):
node = obj_utils.create_test_node(self.context)
@ -516,13 +672,47 @@ class TestListNodes(test_api_base.FunctionalTest):
self.assertTrue(ret.json['error_message'])
mock_gsbd.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'validate_driver_interfaces')
def test_validate_by_uuid_using_deprecated_interface(self, mock_vdi):
# Note(mrda): The 'node_uuid' interface is deprecated in favour
# of the 'node' interface
node = obj_utils.create_test_node(self.context)
self.get_json('/nodes/validate?node_uuid=%s' % node.uuid)
mock_vdi.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'validate_driver_interfaces')
def test_validate_by_uuid(self, mock_vdi):
node = obj_utils.create_test_node(self.context)
self.get_json('/nodes/validate?node=%s' % node.uuid,
headers={api_base.Version.string: "1.5"})
mock_vdi.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'validate_driver_interfaces')
def test_validate_by_name_unsupported(self, mock_vdi):
node = obj_utils.create_test_node(self.context, name='spam')
ret = self.get_json('/nodes/validate?node=%s' % node.name,
expect_errors=True)
self.assertEqual(406, ret.status_code)
self.assertFalse(mock_vdi.called)
@mock.patch.object(rpcapi.ConductorAPI, 'validate_driver_interfaces')
def test_validate_by_name(self, mock_vdi):
node = obj_utils.create_test_node(self.context, name='spam')
self.get_json('/nodes/validate?node=%s' % node.name,
headers={api_base.Version.string: "1.5"})
# note that this should be node.uuid here as we get that from the
# rpc_node lookup and pass that downwards
mock_vdi.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
class TestPatch(test_api_base.FunctionalTest):
def setUp(self):
super(TestPatch, self).setUp()
self.chassis = obj_utils.create_test_chassis(self.context)
self.node = obj_utils.create_test_node(self.context)
self.node = obj_utils.create_test_node(self.context, name='node-57')
self.node_no_name = obj_utils.create_test_node(self.context,
uuid='deadbeef-0000-1111-2222-333333333333')
p = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
self.mock_gtf = p.start()
self.mock_gtf.return_value = 'test-topic'
@ -540,7 +730,7 @@ class TestPatch(test_api_base.FunctionalTest):
.mock_update_node
.return_value
.updated_at) = "2013-12-03T06:20:41.184720+00:00"
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/instance_uuid',
'value': 'aaaaaaaa-1111-bbbb-2222-cccccccccccc',
'op': 'replace'}])
@ -551,8 +741,42 @@ class TestPatch(test_api_base.FunctionalTest):
self.mock_update_node.assert_called_once_with(
mock.ANY, mock.ANY, 'test-topic')
def test_update_by_name_unsupported(self):
self.mock_update_node.return_value = self.node
(self
.mock_update_node
.return_value
.updated_at) = "2013-12-03T06:20:41.184720+00:00"
response = self.patch_json(
'/nodes/%s' % self.node.name,
[{'path': '/instance_uuid',
'value': 'aaaaaaaa-1111-bbbb-2222-cccccccccccc',
'op': 'replace'}],
expect_errors=True)
self.assertEqual(400, response.status_code)
self.assertFalse(self.mock_update_node.called)
def test_update_ok_by_name(self):
self.mock_update_node.return_value = self.node
(self
.mock_update_node
.return_value
.updated_at) = "2013-12-03T06:20:41.184720+00:00"
response = self.patch_json(
'/nodes/%s' % self.node.name,
[{'path': '/instance_uuid',
'value': 'aaaaaaaa-1111-bbbb-2222-cccccccccccc',
'op': 'replace'}],
headers={api_base.Version.string: "1.5"})
self.assertEqual('application/json', response.content_type)
self.assertEqual(200, response.status_code)
self.assertEqual(self.mock_update_node.return_value.updated_at,
timeutils.parse_isotime(response.json['updated_at']))
self.mock_update_node.assert_called_once_with(
mock.ANY, mock.ANY, 'test-topic')
def test_update_state(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'power_state': 'new state'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
@ -564,7 +788,7 @@ class TestPatch(test_api_base.FunctionalTest):
self.mock_update_node.side_effect = exception.InvalidParameterValue(
fake_err)
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/driver_info/this',
'value': 'foo',
'op': 'add'},
@ -581,7 +805,7 @@ class TestPatch(test_api_base.FunctionalTest):
def test_update_fails_bad_driver(self):
self.mock_gtf.side_effect = exception.NoValidHost('Fake Error')
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/driver',
'value': 'bad-driver',
'op': 'replace'}],
@ -593,9 +817,9 @@ class TestPatch(test_api_base.FunctionalTest):
def test_update_fails_bad_state(self):
fake_err = 'Fake Power State'
self.mock_update_node.side_effect = exception.NodeInWrongPowerState(
node=self.node['uuid'], pstate=fake_err)
node=self.node.uuid, pstate=fake_err)
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/instance_uuid',
'value': 'aaaaaaaa-1111-bbbb-2222-cccccccccccc',
'op': 'replace'}],
@ -609,7 +833,7 @@ class TestPatch(test_api_base.FunctionalTest):
def test_add_ok(self):
self.mock_update_node.return_value = self.node
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/extra/foo',
'value': 'bar',
'op': 'add'}])
@ -621,7 +845,7 @@ class TestPatch(test_api_base.FunctionalTest):
def test_add_root(self):
self.mock_update_node.return_value = self.node
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/instance_uuid',
'value': 'aaaaaaaa-1111-bbbb-2222-cccccccccccc',
'op': 'add'}])
@ -631,7 +855,7 @@ class TestPatch(test_api_base.FunctionalTest):
mock.ANY, mock.ANY, 'test-topic')
def test_add_root_non_existent(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/foo', 'value': 'bar', 'op': 'add'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
@ -641,7 +865,7 @@ class TestPatch(test_api_base.FunctionalTest):
def test_remove_ok(self):
self.mock_update_node.return_value = self.node
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/extra',
'op': 'remove'}])
self.assertEqual('application/json', response.content_type)
@ -651,7 +875,7 @@ class TestPatch(test_api_base.FunctionalTest):
mock.ANY, mock.ANY, 'test-topic')
def test_remove_non_existent_property_fail(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/extra/non-existent', 'op': 'remove'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
@ -684,7 +908,7 @@ class TestPatch(test_api_base.FunctionalTest):
mock.ANY, mock.ANY, 'test-topic')
def test_patch_ports_subresource(self):
response = self.patch_json('/nodes/%s/ports' % self.node['uuid'],
response = self.patch_json('/nodes/%s/ports' % self.node.uuid,
[{'path': '/extra/foo', 'value': 'bar',
'op': 'add'}], expect_errors=True)
self.assertEqual(403, response.status_int)
@ -698,7 +922,7 @@ class TestPatch(test_api_base.FunctionalTest):
self.assertTrue(response.json['error_message'])
def test_remove_mandatory_field(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/driver', 'op': 'remove'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
@ -753,7 +977,7 @@ class TestPatch(test_api_base.FunctionalTest):
self.assertTrue(response.json['error_message'])
def test_replace_non_existent_chassis_uuid(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/chassis_uuid',
'value': 'eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa',
'op': 'replace'}], expect_errors=True)
@ -762,7 +986,7 @@ class TestPatch(test_api_base.FunctionalTest):
self.assertTrue(response.json['error_message'])
def test_remove_internal_field(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/last_error', 'op': 'remove'}],
expect_errors=True)
self.assertEqual('application/json', response.content_type)
@ -770,7 +994,7 @@ class TestPatch(test_api_base.FunctionalTest):
self.assertTrue(response.json['error_message'])
def test_replace_internal_field(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/power_state', 'op': 'replace',
'value': 'fake-state'}],
expect_errors=True)
@ -790,8 +1014,22 @@ class TestPatch(test_api_base.FunctionalTest):
self.mock_update_node.assert_called_once_with(
mock.ANY, mock.ANY, 'test-topic')
def test_replace_maintenance_by_name(self):
self.mock_update_node.return_value = self.node
response = self.patch_json(
'/nodes/%s' % self.node.name,
[{'path': '/maintenance', 'op': 'replace',
'value': 'true'}],
headers={api_base.Version.string: "1.5"})
self.assertEqual('application/json', response.content_type)
self.assertEqual(200, response.status_code)
self.mock_update_node.assert_called_once_with(
mock.ANY, mock.ANY, 'test-topic')
def test_replace_consoled_enabled(self):
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/console_enabled',
'op': 'replace', 'value': True}],
expect_errors=True)
@ -801,7 +1039,7 @@ class TestPatch(test_api_base.FunctionalTest):
def test_replace_provision_updated_at(self):
test_time = '2000-01-01 00:00:00'
response = self.patch_json('/nodes/%s' % self.node['uuid'],
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/provision_updated_at',
'op': 'replace', 'value': test_time}],
expect_errors=True)
@ -809,6 +1047,69 @@ class TestPatch(test_api_base.FunctionalTest):
self.assertEqual(400, response.status_code)
self.assertTrue(response.json['error_message'])
def test_patch_add_name_ok(self):
self.mock_update_node.return_value = self.node_no_name
test_name = 'guido-van-rossum'
response = self.patch_json('/nodes/%s' % self.node_no_name.uuid,
[{'path': '/name',
'op': 'add',
'value': test_name}],
headers={api_base.Version.string: "1.5"})
self.assertEqual('application/json', response.content_type)
self.assertEqual(200, response.status_code)
def test_patch_add_name_invalid(self):
self.mock_update_node.return_value = self.node_no_name
test_name = 'I-AM-INVALID'
response = self.patch_json('/nodes/%s' % self.node_no_name.uuid,
[{'path': '/name',
'op': 'add',
'value': test_name}],
headers={api_base.Version.string: "1.5"},
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(400, response.status_code)
self.assertTrue(response.json['error_message'])
def test_patch_name_replace_ok(self):
self.mock_update_node.return_value = self.node
test_name = 'guido-van-rossum'
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/name',
'op': 'replace',
'value': test_name}],
headers={api_base.Version.string: "1.5"})
self.assertEqual('application/json', response.content_type)
self.assertEqual(200, response.status_code)
def test_patch_add_replace_invalid(self):
self.mock_update_node.return_value = self.node_no_name
test_name = 'Guido Van Error'
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/name',
'op': 'replace',
'value': test_name}],
headers={api_base.Version.string: "1.5"},
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(400, response.status_code)
self.assertTrue(response.json['error_message'])
def test_patch_duplicate_name(self):
node = obj_utils.create_test_node(self.context,
uuid=utils.generate_uuid())
test_name = "this-is-my-node"
self.mock_update_node.side_effect = exception.DuplicateName(test_name)
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/name',
'op': 'replace',
'value': test_name}],
headers={api_base.Version.string: "1.5"},
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(409, response.status_code)
self.assertTrue(response.json['error_message'])
class TestPost(test_api_base.FunctionalTest):
@ -891,6 +1192,22 @@ class TestPost(test_api_base.FunctionalTest):
self.assertEqual(expected_return_value, response.body)
self.assertEqual(expected_status, response.status_code)
def _test_vendor_passthru_ok_by_name(self, mock_vendor, return_value=None,
is_async=True):
expected_status = 202 if is_async else 200
expected_return_value = json.dumps(return_value)
node = obj_utils.create_test_node(self.context, name='node-109')
info = {'foo': 'bar'}
mock_vendor.return_value = (return_value, is_async)
response = self.post_json('/nodes/%s/vendor_passthru/test' % node.name,
info,
headers={api_base.Version.string: "1.5"})
mock_vendor.assert_called_once_with(
mock.ANY, node.uuid, 'test', 'POST', info, 'test-topic')
self.assertEqual(expected_return_value, response.body)
self.assertEqual(expected_status, response.status_code)
@mock.patch.object(rpcapi.ConductorAPI, 'vendor_passthru')
def test_vendor_passthru_async(self, mock_vendor):
self._test_vendor_passthru_ok(mock_vendor)
@ -912,6 +1229,10 @@ class TestPost(test_api_base.FunctionalTest):
self.assertEqual(202, response.status_int)
self.assertEqual(return_value[0], response.json)
@mock.patch.object(rpcapi.ConductorAPI, 'vendor_passthru')
def test_vendor_passthru_by_name(self, mock_vendor):
self._test_vendor_passthru_ok_by_name(mock_vendor)
@mock.patch.object(rpcapi.ConductorAPI, 'vendor_passthru')
def test_vendor_passthru_get(self, mocked_vendor_passthru):
node = obj_utils.create_test_node(self.context)
@ -1059,6 +1380,20 @@ class TestDelete(test_api_base.FunctionalTest):
self.delete('/nodes/%s' % node.uuid)
mock_dn.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'destroy_node')
def test_delete_node_by_name_unsupported(self, mock_dn):
node = obj_utils.create_test_node(self.context, name='foo')
self.delete('/nodes/%s' % node.name,
expect_errors=True)
self.assertFalse(mock_dn.called)
@mock.patch.object(rpcapi.ConductorAPI, 'destroy_node')
def test_delete_node_by_name(self, mock_dn):
node = obj_utils.create_test_node(self.context, name='foo')
self.delete('/nodes/%s' % node.name,
headers={api_base.Version.string: "1.5"})
mock_dn.assert_called_once_with(mock.ANY, node.uuid, 'test-topic')
@mock.patch.object(objects.Node, 'get_by_uuid')
def test_delete_node_not_found(self, mock_gbu):
node = obj_utils.get_test_node(self.context)
@ -1070,6 +1405,29 @@ class TestDelete(test_api_base.FunctionalTest):
self.assertTrue(response.json['error_message'])
mock_gbu.assert_called_once_with(mock.ANY, node.uuid)
@mock.patch.object(objects.Node, 'get_by_name')
def test_delete_node_not_found_by_name_unsupported(self, mock_gbn):
node = obj_utils.get_test_node(self.context, name='foo')
mock_gbn.side_effect = exception.NodeNotFound(node=node.name)
response = self.delete('/nodes/%s' % node.name,
expect_errors=True)
self.assertEqual(400, response.status_int)
self.assertFalse(mock_gbn.called)
@mock.patch.object(objects.Node, 'get_by_name')
def test_delete_node_not_found_by_name(self, mock_gbn):
node = obj_utils.get_test_node(self.context, name='foo')
mock_gbn.side_effect = exception.NodeNotFound(node=node.name)
response = self.delete('/nodes/%s' % node.name,
headers={api_base.Version.string: "1.5"},
expect_errors=True)
self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
mock_gbn.assert_called_once_with(mock.ANY, node.name)
def test_delete_ports_subresource(self):
node = obj_utils.create_test_node(self.context)
response = self.delete('/nodes/%s/ports' % node.uuid,
@ -1103,6 +1461,24 @@ class TestDelete(test_api_base.FunctionalTest):
mock_update.assert_called_once_with(mock.ANY, mock.ANY,
topic='test-topic')
@mock.patch.object(objects.Node, 'get_by_name')
@mock.patch.object(rpcapi.ConductorAPI, 'update_node')
def test_delete_node_maintenance_mode_by_name(self, mock_update,
mock_get):
node = obj_utils.create_test_node(self.context, maintenance=True,
maintenance_reason='blah',
name='foo')
mock_get.return_value = node
response = self.delete('/nodes/%s/maintenance' % node.name,
headers={api_base.Version.string: "1.5"})
self.assertEqual(202, response.status_int)
self.assertEqual('', response.body)
self.assertEqual(False, node.maintenance)
self.assertEqual(None, node.maintenance_reason)
mock_get.assert_called_once_with(mock.ANY, node.name)
mock_update.assert_called_once_with(mock.ANY, mock.ANY,
topic='test-topic')
class TestPut(test_api_base.FunctionalTest):
@ -1110,7 +1486,7 @@ class TestPut(test_api_base.FunctionalTest):
super(TestPut, self).setUp()
self.chassis = obj_utils.create_test_chassis(self.context)
self.node = obj_utils.create_test_node(self.context,
provision_state=states.AVAILABLE)
provision_state=states.AVAILABLE, name='node-39')
p = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
self.mock_gtf = p.start()
self.mock_gtf.return_value = 'test-topic'
@ -1140,6 +1516,28 @@ class TestPut(test_api_base.FunctionalTest):
self.assertEqual(urlparse.urlparse(response.location).path,
expected_location)
def test_power_state_by_name_unsupported(self):
response = self.put_json('/nodes/%s/states/power' % self.node.name,
{'target': states.POWER_ON},
expect_errors=True)
self.assertEqual(400, response.status_code)
def test_power_state_by_name(self):
response = self.put_json('/nodes/%s/states/power' % self.node.name,
{'target': states.POWER_ON},
headers={api_base.Version.string: "1.5"})
self.assertEqual(202, response.status_code)
self.assertEqual('', response.body)
self.mock_cnps.assert_called_once_with(mock.ANY,
self.node.uuid,
states.POWER_ON,
'test-topic')
# Check location header
self.assertIsNotNone(response.location)
expected_location = '/v1/nodes/%s/states' % self.node.name
self.assertEqual(urlparse.urlparse(response.location).path,
expected_location)
def test_power_invalid_state_request(self):
ret = self.put_json('/nodes/%s/states/power' % self.node.uuid,
{'target': 'not-supported'}, expect_errors=True)
@ -1163,6 +1561,21 @@ class TestPut(test_api_base.FunctionalTest):
self.assertEqual(urlparse.urlparse(ret.location).path,
expected_location)
def test_provision_by_name_unsupported(self):
ret = self.put_json('/nodes/%s/states/provision' % self.node.name,
{'target': states.ACTIVE},
expect_errors=True)
self.assertEqual(400, ret.status_code)
def test_provision_by_name(self):
ret = self.put_json('/nodes/%s/states/provision' % self.node.name,
{'target': states.ACTIVE},
headers={api_base.Version.string: "1.5"})
self.assertEqual(202, ret.status_code)
self.assertEqual('', ret.body)
self.mock_dnd.assert_called_once_with(
mock.ANY, self.node.uuid, False, None, 'test-topic')
def test_provision_with_deploy_configdrive(self):
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
{'target': states.ACTIVE, 'configdrive': 'foo'})
@ -1319,6 +1732,23 @@ class TestPut(test_api_base.FunctionalTest):
self.assertEqual(urlparse.urlparse(ret.location).path,
expected_location)
@mock.patch.object(rpcapi.ConductorAPI, 'set_console_mode')
def test_set_console_by_name_unsupported(self, mock_scm):
ret = self.put_json('/nodes/%s/states/console' % self.node.name,
{'enabled': "true"},
expect_errors=True)
self.assertEqual(400, ret.status_code)
@mock.patch.object(rpcapi.ConductorAPI, 'set_console_mode')
def test_set_console_by_name(self, mock_scm):
ret = self.put_json('/nodes/%s/states/console' % self.node.name,
{'enabled': "true"},
headers={api_base.Version.string: "1.5"})
self.assertEqual(202, ret.status_code)
self.assertEqual('', ret.body)
mock_scm.assert_called_once_with(mock.ANY, self.node.uuid,
True, 'test-topic')
def test_set_console_mode_disabled(self):
with mock.patch.object(rpcapi.ConductorAPI,
'set_console_mode') as mock_scm:
@ -1388,6 +1818,18 @@ class TestPut(test_api_base.FunctionalTest):
device, persistent=False,
topic='test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'set_boot_device')
def test_set_boot_device_by_name(self, mock_sbd):
device = boot_devices.PXE
ret = self.put_json('/nodes/%s/management/boot_device'
% self.node.name, {'boot_device': device},
headers={api_base.Version.string: "1.5"})
self.assertEqual(204, ret.status_code)
self.assertEqual('', ret.body)
mock_sbd.assert_called_once_with(mock.ANY, self.node.uuid,
device, persistent=False,
topic='test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'set_boot_device')
def test_set_boot_device_not_supported(self, mock_sbd):
mock_sbd.side_effect = exception.UnsupportedDriverExtension(
@ -1422,20 +1864,25 @@ class TestPut(test_api_base.FunctionalTest):
self.assertEqual('application/json', ret.content_type)
self.assertEqual(400, ret.status_code)
def _test_set_node_maintenance_mode(self, mock_update, mock_get, reason):
def _test_set_node_maintenance_mode(self, mock_update, mock_get, reason,
node_ident, is_by_name=False):
request_body = {}
if reason:
request_body['reason'] = reason
self.node.maintenance = False
mock_get.return_value = self.node
ret = self.put_json('/nodes/%s/maintenance' % self.node.uuid,
request_body)
if is_by_name:
headers = {api_base.Version.string: "1.5"}
else:
headers = {}
ret = self.put_json('/nodes/%s/maintenance' % node_ident,
request_body, headers=headers)
self.assertEqual(202, ret.status_code)
self.assertEqual('', ret.body)
self.assertEqual(True, self.node.maintenance)
self.assertEqual(reason, self.node.maintenance_reason)
mock_get.assert_called_once_with(mock.ANY, self.node.uuid)
mock_get.assert_called_once_with(mock.ANY, node_ident)
mock_update.assert_called_once_with(mock.ANY, mock.ANY,
topic='test-topic')
@ -1443,9 +1890,24 @@ class TestPut(test_api_base.FunctionalTest):
@mock.patch.object(rpcapi.ConductorAPI, 'update_node')
def test_set_node_maintenance_mode(self, mock_update, mock_get):
self._test_set_node_maintenance_mode(mock_update, mock_get,
'fake_reason')
'fake_reason', self.node.uuid)
@mock.patch.object(objects.Node, 'get_by_uuid')
@mock.patch.object(rpcapi.ConductorAPI, 'update_node')
def test_set_node_maintenance_mode_no_reason(self, mock_update, mock_get):
self._test_set_node_maintenance_mode(mock_update, mock_get, None)
self._test_set_node_maintenance_mode(mock_update, mock_get, None,
self.node.uuid)
@mock.patch.object(objects.Node, 'get_by_name')
@mock.patch.object(rpcapi.ConductorAPI, 'update_node')
def test_set_node_maintenance_mode_by_name(self, mock_update, mock_get):
self._test_set_node_maintenance_mode(mock_update, mock_get,
'fake_reason', self.node.name,
is_by_name=True)
@mock.patch.object(objects.Node, 'get_by_name')
@mock.patch.object(rpcapi.ConductorAPI, 'update_node')
def test_set_node_maintenance_mode_no_reason_by_name(self, mock_update,
mock_get):
self._test_set_node_maintenance_mode(mock_update, mock_get, None,
self.node.name, is_by_name=True)

View File

@ -43,15 +43,49 @@ class TestUuidType(base.TestCase):
def test_valid_uuid(self):
test_uuid = '1a1a1a1a-2b2b-3c3c-4d4d-5e5e5e5e5e5e'
with mock.patch.object(utils, 'is_uuid_like') as uuid_mock:
types.UuidType.validate(test_uuid)
uuid_mock.assert_called_once_with(test_uuid)
self.assertEqual(test_uuid, types.UuidType.validate(test_uuid))
def test_invalid_uuid(self):
self.assertRaises(exception.InvalidUUID,
types.UuidType.validate, 'invalid-uuid')
class TestNameType(base.TestCase):
def test_valid_name(self):
test_name = 'hal-9000'
self.assertEqual(test_name, types.NameType.validate(test_name))
def test_invalid_name(self):
self.assertRaises(exception.InvalidName,
types.NameType.validate, '-this is not valid-')
class TestUuidOrNameType(base.TestCase):
@mock.patch.object(utils, 'is_uuid_like')
@mock.patch.object(utils, 'is_hostname_safe')
def test_valid_uuid(self, host_mock, uuid_mock):
test_uuid = '1a1a1a1a-2b2b-3c3c-4d4d-5e5e5e5e5e5e'
host_mock.return_value = False
uuid_mock.return_value = True
self.assertTrue(types.UuidOrNameType.validate(test_uuid))
uuid_mock.assert_called_once_with(test_uuid)
@mock.patch.object(utils, 'is_uuid_like')
@mock.patch.object(utils, 'is_hostname_safe')
def test_valid_name(self, host_mock, uuid_mock):
test_name = 'dc16-database5'
uuid_mock.return_value = False
host_mock.return_value = True
self.assertTrue(types.UuidOrNameType.validate(test_name))
host_mock.assert_called_once_with(test_name)
def test_invalid_uuid_or_name(self):
self.assertRaises(exception.InvalidUuidOrName,
types.UuidOrNameType.validate, 'inval#uuid%or*name')
class MyPatchType(types.JsonPatchType):
"""Helper class for TestJsonPatchType tests."""

View File

@ -50,6 +50,12 @@ class DbNodeTestCase(base.DbTestCase):
uuid=ironic_utils.generate_uuid(),
instance_uuid=instance)
def test_create_node_name_duplicate(self):
node = utils.create_test_node(name='spam')
self.assertRaises(exception.DuplicateName,
utils.create_test_node,
name=node.name)
def test_get_node_by_id(self):
node = utils.create_test_node()
res = self.dbapi.get_node_by_id(node.id)
@ -62,12 +68,22 @@ class DbNodeTestCase(base.DbTestCase):
self.assertEqual(node.id, res.id)
self.assertEqual(node.uuid, res.uuid)
def test_get_node_by_name(self):
node = utils.create_test_node()
res = self.dbapi.get_node_by_name(node.name)
self.assertEqual(node.id, res.id)
self.assertEqual(node.uuid, res.uuid)
self.assertEqual(node.name, res.name)
def test_get_node_that_does_not_exist(self):
self.assertRaises(exception.NodeNotFound,
self.dbapi.get_node_by_id, 99)
self.assertRaises(exception.NodeNotFound,
self.dbapi.get_node_by_uuid,
'12345678-9999-0000-aaaa-123456789012')
self.assertRaises(exception.NodeNotFound,
self.dbapi.get_node_by_name,
'spam-eggs-bacon-spam')
def test_get_nodeinfo_list_defaults(self):
node_id_list = []
@ -326,6 +342,15 @@ class DbNodeTestCase(base.DbTestCase):
self.assertEqual(mocked_time,
timeutils.normalize_time(res['provision_updated_at']))
def test_update_node_name_duplicate(self):
node1 = utils.create_test_node(uuid=ironic_utils.generate_uuid(),
name='spam')
node2 = utils.create_test_node(uuid=ironic_utils.generate_uuid())
self.assertRaises(exception.DuplicateName,
self.dbapi.update_node,
node2.id,
{'name': node1.name})
def test_update_node_no_provision(self):
node = utils.create_test_node()
res = self.dbapi.update_node(node.id, {'extra': {'foo': 'bar'}})

View File

@ -170,6 +170,7 @@ def get_test_node(**kw):
fake_info = {"foo": "bar", "fake_password": "fakepass"}
return {
'id': kw.get('id', 123),
'name': kw.get('name', None),
'uuid': kw.get('uuid', '1be26c0b-03f2-4d2e-ae87-c02d7f33c123'),
'chassis_id': kw.get('chassis_id', 42),
'conductor_affinity': kw.get('conductor_affinity', None),

View File

@ -336,6 +336,31 @@ class GenericUtilsTestCase(base.TestCase):
self.assertFalse(utils.is_valid_mac("AA BB CC DD EE FF"))
self.assertFalse(utils.is_valid_mac("AA-BB-CC-DD-EE-FF"))
def test_is_hostname_safe(self):
self.assertTrue(utils.is_hostname_safe('spam'))
self.assertFalse(utils.is_hostname_safe('spAm'))
self.assertFalse(utils.is_hostname_safe('SPAM'))
self.assertFalse(utils.is_hostname_safe('-spam'))
self.assertFalse(utils.is_hostname_safe('spam-'))
self.assertTrue(utils.is_hostname_safe('spam-eggs'))
self.assertFalse(utils.is_hostname_safe('spam eggs'))
self.assertTrue(utils.is_hostname_safe('9spam'))
self.assertTrue(utils.is_hostname_safe('spam7'))
self.assertTrue(utils.is_hostname_safe('br34kf4st'))
self.assertFalse(utils.is_hostname_safe('$pam'))
self.assertFalse(utils.is_hostname_safe('egg$'))
self.assertFalse(utils.is_hostname_safe('spam#eggs'))
self.assertFalse(utils.is_hostname_safe(' eggs'))
self.assertFalse(utils.is_hostname_safe('spam '))
self.assertTrue(utils.is_hostname_safe('s'))
self.assertTrue(utils.is_hostname_safe('s' * 63))
self.assertFalse(utils.is_hostname_safe('s' * 64))
self.assertFalse(utils.is_hostname_safe(''))
self.assertFalse(utils.is_hostname_safe(None))
# Need to ensure a binary response for success or fail
self.assertIsNotNone(utils.is_hostname_safe('spam'))
self.assertIsNotNone(utils.is_hostname_safe('-spam'))
def test_validate_and_normalize_mac(self):
mac = 'AA:BB:CC:DD:EE:FF'
with mock.patch.object(utils, 'is_valid_mac') as m_mock: