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:
parent
cf9932fe63
commit
ea25926c26
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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.")
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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')
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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'}})
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue