Source code for ironic.api.controllers.v1.ramdisk

# Copyright 2016 Red Hat, Inc.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

from http import client as http_client
from urllib import parse as urlparse

from oslo_config import cfg
from oslo_log import log
from oslo_utils import uuidutils
from pecan import rest

from ironic import api
from ironic.api.controllers.v1 import node as node_ctl
from ironic.api.controllers.v1 import notification_utils as notify
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api.controllers.v1 import versions
from ironic.api import method
from ironic.common import args
from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import states
from ironic.common import utils
from ironic.drivers.modules import inspect_utils
from ironic import objects


CONF = cfg.CONF
LOG = log.getLogger(__name__)

_LOOKUP_RETURN_FIELDS = ['uuid', 'properties', 'instance_info',
                         'driver_internal_info']
AGENT_VALID_STATES = ['start', 'end', 'error']


[docs] def config(token): return { 'metrics': { 'backend': CONF.metrics.agent_backend, 'prepend_host': CONF.metrics.agent_prepend_host, 'prepend_uuid': CONF.metrics.agent_prepend_uuid, 'prepend_host_reverse': CONF.metrics.agent_prepend_host_reverse, 'global_prefix': CONF.metrics.agent_global_prefix }, 'metrics_statsd': { 'statsd_host': CONF.metrics_statsd.agent_statsd_host, 'statsd_port': CONF.metrics_statsd.agent_statsd_port }, 'heartbeat_timeout': CONF.api.ramdisk_heartbeat_timeout, 'agent_token': token, # Since this is for the Victoria release, we send this as an # explicit True statement for newer agents to lock the setting # and behavior into place. 'agent_token_required': True, 'agent_md5_checksum_enable': CONF.agent.allow_md5_checksum, 'disable_deep_image_inspection': CONF.conductor.disable_deep_image_inspection, # noqa 'permitted_image_formats': CONF.conductor.permitted_image_formats, }
[docs] def get_valid_mac_addresses(addresses, node_uuid=None): if addresses is None: addresses = [] valid_addresses = [] invalid_addresses = [] for addr in addresses: try: mac = utils.validate_and_normalize_mac(addr) valid_addresses.append(mac) except exception.InvalidMAC: invalid_addresses.append(addr) if invalid_addresses: node_log = ('' if not node_uuid else '(Node UUID: %s)' % node_uuid) LOG.warning('The following MAC addresses "%(addrs)s" are ' 'invalid and will be ignored by the lookup ' 'request %(node)s', {'addrs': ', '.join(invalid_addresses), 'node': node_log}) return valid_addresses
[docs] class LookupController(rest.RestController): """Controller handling node lookup for a deploy ramdisk."""
[docs] def lookup_allowed(self, node): if utils.fast_track_enabled(node): return ( node.provision_state in states.FASTTRACK_LOOKUP_ALLOWED_STATES ) else: return node.provision_state in states.LOOKUP_ALLOWED_STATES
[docs] @method.expose() @args.validate(addresses=args.string_list, node_uuid=args.uuid) def get_all(self, addresses=None, node_uuid=None): """Look up a node by its MAC addresses and optionally UUID. If the "restrict_lookup" option is set to True (the default), limit the search to nodes in certain transient states (e.g. deploy wait). :param addresses: list of MAC addresses for a node. :param node_uuid: UUID of a node. :raises: NotFound if requested API version does not allow this endpoint. :raises: NotFound if suitable node was not found or node's provision state is not allowed for the lookup. :raises: IncompleteLookup if neither node UUID nor any valid MAC address was provided. """ if not api_utils.allow_ramdisk_endpoints(): raise exception.NotFound() api_utils.check_policy('baremetal:driver:ipa_lookup') # Validate the list of MAC addresses valid_addresses = get_valid_mac_addresses(addresses) if not valid_addresses and not node_uuid: raise exception.IncompleteLookup() try: if node_uuid: node = objects.Node.get_by_uuid( api.request.context, node_uuid) else: node = objects.Node.get_by_port_addresses( api.request.context, valid_addresses) except exception.NotFound as e: # NOTE(dtantsur): we are reraising the same exception to make sure # we don't disclose the difference between nodes that are not found # at all and nodes in a wrong state by different error messages. LOG.error('No node has been found during lookup: %s', e) raise exception.NotFound() if CONF.api.restrict_lookup and not self.lookup_allowed(node): LOG.error('Lookup is not allowed for node %(node)s in the ' 'provision state %(state)s', {'node': node.uuid, 'state': node.provision_state}) raise exception.NotFound() if api_utils.allow_agent_token(): try: topic = api.request.rpcapi.get_topic_for(node) except exception.NoValidHost as e: e.code = http_client.BAD_REQUEST raise found_node = api.request.rpcapi.get_node_with_token( api.request.context, node.uuid, topic=topic) else: found_node = node return convert_with_links(found_node)
[docs] class HeartbeatController(rest.RestController): """Controller handling heartbeats from deploy ramdisk."""
[docs] @method.expose(status_code=http_client.ACCEPTED) @args.validate(node_ident=args.uuid_or_name, callback_url=args.string, agent_version=args.string, agent_token=args.string, agent_verify_ca=args.string, agent_status=args.string, agent_status_message=args.string) def post(self, node_ident, callback_url, agent_version=None, agent_token=None, agent_verify_ca=None, agent_status=None, agent_status_message=None): """Process a heartbeat from the deploy ramdisk. :param node_ident: the UUID or logical name of a node. :param callback_url: the URL to reach back to the ramdisk. :param agent_version: The version of the agent that is heartbeating. ``None`` indicates that the agent that is heartbeating is a version before sending agent_version was introduced so agent v3.0.0 (the last release before sending agent_version was introduced) will be assumed. :param agent_token: randomly generated validation token. :param agent_verify_ca: TLS certificate to use to connect to the agent. :param agent_status: Current status of the heartbeating agent. Used by anaconda ramdisk to send status back to Ironic. The valid states are 'start', 'end', 'error' :param agent_status_message: Optional status message describing current agent_status :raises: NodeNotFound if node with provided UUID or name was not found. :raises: InvalidUuidOrName if node_ident is not valid name or UUID. :raises: NoValidHost if RPC topic for node could not be retrieved. :raises: NotFound if requested API version does not allow this endpoint. """ if not api_utils.allow_ramdisk_endpoints(): raise exception.NotFound() if agent_version and not api_utils.allow_agent_version_in_heartbeat(): raise exception.InvalidParameterValue( _('Field "agent_version" not recognised')) if ((agent_status or agent_status_message) and not api_utils.allow_status_in_heartbeat()): raise exception.InvalidParameterValue( _('Fields "agent_status" and "agent_status_message" ' 'not recognised.') ) api_utils.check_policy('baremetal:node:ipa_heartbeat') if (agent_verify_ca is not None and not api_utils.allow_verify_ca_in_heartbeat()): raise exception.InvalidParameterValue( _('Field "agent_verify_ca" not recognised in this version')) rpc_node = api_utils.get_rpc_node_with_suffix(node_ident) dii = rpc_node['driver_internal_info'] agent_url = dii.get('agent_url') try: # NOTE(TheJulia): Use of urllib.urlparse is not a security # guard, but detecting oddities and incorrect formatting # https://docs.python.org/3/library/urllib.parse.html#url-parsing-security # noqa parsed_url = urlparse.urlparse(callback_url) # Check if http (compatibility), or https (much newer agents) if 'http' not in parsed_url.scheme: raise ValueError callback_url = parsed_url.geturl() except ValueError: if callback_url != "": # Anaconda deploy interface sends a empty callback url, since # it is a one way heartbeat. raise exception.InvalidParameterValue( _('An issue with the supplied "callback_url" has been ' 'detected.')) # If we have an agent_url on file, and we get something different # we should fail because this is unexpected behavior of the agent. if agent_url is not None and agent_url != callback_url: LOG.error('Received heartbeat for node %(node)s with ' 'callback URL %(url)s. This is not expected, ' 'and the heartbeat will not be processed.', {'node': rpc_node.uuid, 'url': callback_url}) raise exception.Invalid( _('Detected change in ramdisk provided ' '"callback_url"')) # NOTE(TheJulia): If tokens are required, lets go ahead and fail the # heartbeat very early on. if agent_token is None: LOG.error('Agent heartbeat received for node %(node)s ' 'without an agent token.', {'node': node_ident}) raise exception.InvalidParameterValue( _('Agent token is required for heartbeat processing.')) if agent_status is not None and agent_status not in AGENT_VALID_STATES: valid_states = ','.join(AGENT_VALID_STATES) LOG.error('Agent heartbeat received for node %(node)s ' 'has an invalid agent status: %(agent_status)s. ' 'Valid states are %(valid_states)s ', {'node': node_ident, 'agent_status': agent_status, 'valid_states': valid_states}) msg = (_('Agent status is invalid. Valid states are %s.') % valid_states) raise exception.InvalidParameterValue(msg) try: topic = api.request.rpcapi.get_topic_for(rpc_node) except exception.NoValidHost as e: e.code = http_client.BAD_REQUEST raise api.request.rpcapi.heartbeat( api.request.context, rpc_node.uuid, callback_url, agent_version, agent_token, agent_verify_ca, agent_status, agent_status_message, topic=topic)
DATA_VALIDATOR = args.schema({ 'type': 'object', 'properties': { # This validator defines a minimal acceptable inventory. 'inventory': { 'type': 'object', 'properties': { 'bmc_address': {'type': ['string', 'null']}, 'bmc_v6address': {'type': ['string', 'null']}, 'interfaces': { 'type': 'array', 'items': { 'type': 'object', 'properties': { 'mac_address': {'type': 'string'}, }, 'required': ['mac_address'], 'additionalProperties': True, }, 'minItems': 1, }, }, 'required': ['interfaces'], 'additionalProperties': True, }, }, 'required': ['inventory'], 'additionalProperties': True, })
[docs] class ContinueInspectionController(rest.RestController): """Controller handling inspection data from deploy ramdisk.""" def _auto_enroll(self, macs, bmc_addresses): context = api.request.context new_node = objects.Node( context, conductor_group='', # TODO(dtantsur): default_conductor_group driver=CONF.auto_discovery.driver, provision_state=states.ENROLL, resource_class=CONF.default_resource_class, uuid=uuidutils.generate_uuid()) try: topic = api.request.rpcapi.get_topic_for(new_node) except exception.NoValidHost as e: LOG.error("Failed to find a conductor to handle the newly " "enrolled node with driver %s: %s", new_node.driver, e) # NOTE(dtantsur): do not disclose any information to the caller raise exception.IronicException() LOG.info("Enrolling the newly discovered node %(uuid)s with driver " "%(driver)s, MAC addresses [%(macs)s] and BMC address(es) " "[%(bmc)s]", {'driver': new_node.driver, 'uuid': new_node.uuid, 'macs': ', '.join(macs or ()), 'bmc': ', '.join(bmc_addresses or ())}) notify.emit_start_notification(context, new_node, 'create') with notify.handle_error_notification(context, new_node, 'create'): try: node = api.request.rpcapi.create_node( context, new_node, topic=topic) except exception.IronicException: LOG.exception("Failed to enroll node with driver %s", new_node.driver) # NOTE(dtantsur): do not disclose any information to the caller raise exception.IronicException() return node, topic
[docs] @method.expose(status_code=http_client.ACCEPTED) @method.body('data') @args.validate(data=DATA_VALIDATOR, node_uuid=args.uuid) def post(self, data, node_uuid=None): """Process a introspection data from the deploy ramdisk. :param data: Introspection data. :param node_uuid: UUID of a node. :raises: InvalidParameterValue if node_uuid is a valid UUID. :raises: NoValidHost if RPC topic for node could not be retrieved. :raises: NotFound if requested API version does not allow this endpoint or if lookup fails. """ if (not api_utils.allow_continue_inspection_endpoint() # Node UUID support is a new addition or (node_uuid and not api_utils.new_continue_inspection_endpoint())): raise exception.NotFound( # This is a small lie: 1.1 is accepted as well, but no need # to really advertise this fact, it's only for compatibility. _('API version 1.%d or newer is required') % versions.MINOR_84_CONTINUE_INSPECTION) api_utils.check_policy('baremetal:driver:ipa_continue_inspection') inventory = data.pop('inventory') macs = get_valid_mac_addresses( iface['mac_address'] for iface in inventory['interfaces']) bmc_addresses = list( filter(None, (inventory.get('bmc_address'), inventory.get('bmc_v6address'))) ) if not macs and not bmc_addresses and not node_uuid: raise exception.BadRequest(_('No lookup information provided')) try: rpc_node = inspect_utils.lookup_node( api.request.context, macs, bmc_addresses, node_uuid=node_uuid) except inspect_utils.AutoEnrollPossible: if not CONF.auto_discovery.enabled: raise exception.NotFound() rpc_node, topic = self._auto_enroll(macs, bmc_addresses) # TODO(dtantsur): consider adding a Node-level property to make # newly discovered nodes searchable via API. The flag in # plugin_data is for compatibility with ironic-inspector. data[inspect_utils.AUTO_DISCOVERED_FLAG] = True else: try: topic = api.request.rpcapi.get_topic_for(rpc_node) except exception.NoValidHost as e: e.code = http_client.BAD_REQUEST raise if api_utils.new_continue_inspection_endpoint(): # This has to happen before continue_inspection since processing # the data may take significant time, and creating a token required # a lock on the node. rpc_node = api.request.rpcapi.get_node_with_token( api.request.context, rpc_node.uuid, topic=topic) api.request.rpcapi.continue_inspection( api.request.context, rpc_node.uuid, inventory=inventory, plugin_data=data, topic=topic) if api_utils.new_continue_inspection_endpoint(): return convert_with_links(rpc_node) else: # Compatibility with ironic-inspector return {'uuid': rpc_node.uuid}