3fe42b53fd
Primarily remove the workaround added in Ia6d512ff2ae417bab938cb095fbb0884d195010a which added continued use of autocommit, which is incompatible with SQLAlchemy 2.0. Also set the environment for unit tests to report compatability warnings, although it appears none are being reported at this time. Also cuts out the db upgrade cruft to only use the online database migration code through oslo_db's enginefacade, which has the smarts to handle online or offline migrations. And then, retools unit/functional test data storage to utlize sqlite, and in that re-tooled the queries to prevent locking conditions which could exist with queries, and some additional refactoring/cleanup. Also, don't mock and test time.sleep(). Additionally, it looks like we have discovered the root cause of the memory/connection leakage issue which has been observed, due to the way lists of nodes are processed/returned. This change was based upon the work in I506da42a9891a245831f325e34bec92e0a3f33f0 which is included in this commit as the entire database structure and interaction has been modified for ironic-inspector. Co-Authored-By: aarefiev <aarefiev@mirantis.com> Story: 2009727 Task: 44132 Change-Id: Ic88eb9dec5fddc924a72d9a23c17a304954ebf46
343 lines
10 KiB
Python
343 lines
10 KiB
Python
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
# implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import datetime
|
|
import logging as pylog
|
|
|
|
import futurist
|
|
from ironic_lib import auth_basic
|
|
from ironic_lib import exception
|
|
from keystonemiddleware import auth_token
|
|
from openstack.baremetal.v1 import node
|
|
from oslo_config import cfg
|
|
from oslo_log import log
|
|
from oslo_middleware import cors as cors_middleware
|
|
from oslo_middleware import healthcheck as healthcheck_middleware
|
|
import pytz
|
|
import webob
|
|
|
|
from ironic_inspector.common.i18n import _
|
|
from ironic_inspector import policy
|
|
|
|
CONF = cfg.CONF
|
|
|
|
_EXECUTOR = None
|
|
|
|
|
|
def get_ipmi_address_from_data(introspection_data):
|
|
try:
|
|
result = introspection_data['inventory']['bmc_address']
|
|
except KeyError:
|
|
result = introspection_data.get('ipmi_address')
|
|
|
|
if result in ('', '0.0.0.0'):
|
|
# ipmitool can return these values, if it does not know the address
|
|
return None
|
|
else:
|
|
return result
|
|
|
|
|
|
def get_ipmi_v6address_from_data(introspection_data):
|
|
try:
|
|
result = introspection_data['inventory']['bmc_v6address']
|
|
except KeyError:
|
|
result = introspection_data.get('ipmi_v6address')
|
|
|
|
if result in ('', '::/0'):
|
|
# ipmitool can return these values, if it does not know the address
|
|
return None
|
|
else:
|
|
return result
|
|
|
|
|
|
def get_pxe_mac(introspection_data):
|
|
pxe_mac = introspection_data.get('boot_interface')
|
|
if pxe_mac and '-' in pxe_mac:
|
|
# pxelinux format: 01-aa-bb-cc-dd-ee-ff
|
|
pxe_mac = pxe_mac.split('-', 1)[1]
|
|
pxe_mac = pxe_mac.replace('-', ':').lower()
|
|
return pxe_mac
|
|
|
|
|
|
def processing_logger_prefix(data=None, node_info=None):
|
|
"""Calculate prefix for logging.
|
|
|
|
Tries to use:
|
|
* node UUID, node._state
|
|
* node PXE MAC,
|
|
* node BMC address
|
|
|
|
:param data: introspection data
|
|
:param node_info: NodeInfo or ironic node object
|
|
:return: logging prefix as a string
|
|
"""
|
|
# TODO(dtantsur): try to get MAC and BMC address for node_info as well
|
|
parts = []
|
|
data = data or {}
|
|
|
|
if node_info is not None:
|
|
if isinstance(node_info, node.Node):
|
|
parts.append(str(node_info.id))
|
|
else:
|
|
parts.append(str(node_info))
|
|
|
|
pxe_mac = get_pxe_mac(data)
|
|
if pxe_mac:
|
|
parts.append('MAC %s' % pxe_mac)
|
|
|
|
bmc_address = get_ipmi_address_from_data(data) if data else None
|
|
if bmc_address:
|
|
parts.append('BMC %s' % bmc_address)
|
|
|
|
if parts:
|
|
return _('[node: %s]') % ' '.join(parts)
|
|
else:
|
|
return _('[unidentified node]')
|
|
|
|
|
|
class ProcessingLoggerAdapter(log.KeywordArgumentAdapter):
|
|
def process(self, msg, kwargs):
|
|
if 'data' not in kwargs and 'node_info' not in kwargs:
|
|
return super(ProcessingLoggerAdapter, self).process(msg, kwargs)
|
|
|
|
data = kwargs.get('data', {})
|
|
node_info = kwargs.get('node_info')
|
|
prefix = processing_logger_prefix(data, node_info)
|
|
|
|
msg, kwargs = super(ProcessingLoggerAdapter, self).process(msg, kwargs)
|
|
return ('%s %s' % (prefix, msg)), kwargs
|
|
|
|
|
|
def getProcessingLogger(name):
|
|
# We can't use getLogger from oslo_log, as it's an adapter itself
|
|
logger = pylog.getLogger(name)
|
|
return ProcessingLoggerAdapter(logger, {})
|
|
|
|
|
|
LOG = getProcessingLogger(__name__)
|
|
|
|
|
|
class Error(Exception):
|
|
"""Inspector exception."""
|
|
|
|
def __init__(self, msg, code=400, log_level='error', **kwargs):
|
|
super(Error, self).__init__(msg)
|
|
getattr(LOG, log_level)(msg, **kwargs)
|
|
self.http_code = code
|
|
|
|
|
|
class NotFoundInCacheError(Error):
|
|
"""Exception when node was not found in cache during processing."""
|
|
|
|
def __init__(self, msg, code=404, **kwargs):
|
|
super(NotFoundInCacheError, self).__init__(msg, code,
|
|
log_level='info', **kwargs)
|
|
|
|
|
|
class NodeStateRaceCondition(Error):
|
|
"""State mismatch between the DB and a node_info."""
|
|
def __init__(self, *args, **kwargs):
|
|
message = _('Node state mismatch detected between the DB and the '
|
|
'cached node_info object')
|
|
kwargs.setdefault('code', 500)
|
|
super(NodeStateRaceCondition, self).__init__(message, *args, **kwargs)
|
|
|
|
|
|
class NodeStateInvalidEvent(Error):
|
|
"""Invalid event attempted."""
|
|
|
|
|
|
class IntrospectionDataStoreDisabled(Error):
|
|
"""Introspection data store is disabled."""
|
|
|
|
|
|
class IntrospectionDataNotFound(NotFoundInCacheError):
|
|
"""Introspection data not found."""
|
|
|
|
|
|
class NoAvailableConductor(Error):
|
|
"""No available conductor in the service group."""
|
|
|
|
def __init__(self, msg, **kwargs):
|
|
super(NoAvailableConductor, self).__init__(msg, code=503, **kwargs)
|
|
|
|
|
|
class DeferredBasicAuthMiddleware(object):
|
|
"""Middleware which sets X-Identity-Status header based on authentication
|
|
|
|
"""
|
|
def __init__(self, app, auth_file):
|
|
self.app = app
|
|
self.auth_file = auth_file
|
|
auth_basic.validate_auth_file(auth_file)
|
|
|
|
@webob.dec.wsgify()
|
|
def __call__(self, req):
|
|
|
|
headers = req.headers
|
|
try:
|
|
if 'Authorization' not in headers:
|
|
auth_basic.unauthorized()
|
|
|
|
token = auth_basic.parse_header({
|
|
'HTTP_AUTHORIZATION': headers.get('Authorization')
|
|
})
|
|
username, password = auth_basic.parse_token(token)
|
|
headers.update(
|
|
auth_basic.authenticate(self.auth_file, username, password))
|
|
headers['X-Identity-Status'] = 'Confirmed'
|
|
|
|
except exception.Unauthorized:
|
|
headers['X-Identity-Status'] = 'Invalid'
|
|
except exception.IronicException as e:
|
|
status = '%s %s' % (int(e.code), str(e))
|
|
resp = webob.Response(status=status)
|
|
resp.headers.update(e.headers)
|
|
return resp
|
|
|
|
return req.get_response(self.app)
|
|
|
|
|
|
class NodeNotFoundInDBError(Error):
|
|
"""The node was not found in the database."""
|
|
# NOTE(TheJulia): This exception exists largely to help facilitate
|
|
# Internal error handling.
|
|
def __init__(self, **kwargs):
|
|
msg = 'The requested node was not found.'
|
|
super(NodeNotFoundInDBError, self).__init__(
|
|
msg, code=404, log_level='error', **kwargs)
|
|
|
|
|
|
class RuleUUIDExistError(Error):
|
|
"""Rule requested already exists in the database."""
|
|
def __init__(self, uuid, *args, **kwargs):
|
|
message = _('Rule with UUID %s already exists') % uuid
|
|
kwargs.setdefault('code', 409)
|
|
super(RuleUUIDExistError, self).__init__(message, *args, **kwargs)
|
|
|
|
|
|
class RuleNotFoundError(Error):
|
|
"""The requested rule was not found."""
|
|
def __init__(self, uuid, *args, **kwargs):
|
|
message = _('Rule %s was not found') % uuid
|
|
kwargs.setdefault('code', 404)
|
|
super(RuleNotFoundError, self).__init__(message, *args, **kwargs)
|
|
|
|
|
|
def executor():
|
|
"""Return the current futures executor."""
|
|
global _EXECUTOR
|
|
if _EXECUTOR is None:
|
|
_EXECUTOR = futurist.GreenThreadPoolExecutor(
|
|
max_workers=CONF.max_concurrency)
|
|
return _EXECUTOR
|
|
|
|
|
|
def add_auth_middleware(app):
|
|
"""Add authentication middleware to Flask application.
|
|
|
|
:param app: application.
|
|
"""
|
|
auth_conf = dict(CONF.keystone_authtoken)
|
|
auth_conf['delay_auth_decision'] = True
|
|
app.wsgi_app = auth_token.AuthProtocol(app.wsgi_app, auth_conf)
|
|
|
|
|
|
def add_basic_auth_middleware(app):
|
|
"""Add HTTP Basic authentication middleware to Flask application.
|
|
|
|
:param app: application.
|
|
"""
|
|
app.wsgi_app = DeferredBasicAuthMiddleware(
|
|
app.wsgi_app, CONF.http_basic_auth_user_file)
|
|
|
|
|
|
def add_cors_middleware(app):
|
|
"""Create a CORS wrapper
|
|
|
|
Attach ironic-inspector-specific defaults that must be included
|
|
in all CORS responses.
|
|
|
|
:param app: application
|
|
"""
|
|
app.wsgi_app = cors_middleware.CORS(app.wsgi_app, CONF)
|
|
|
|
|
|
def add_healthcheck_middleware(app):
|
|
"""Add healthcheck middleware
|
|
|
|
:param app: application
|
|
"""
|
|
app.wsgi_app = healthcheck_middleware.Healthcheck(app.wsgi_app, CONF)
|
|
|
|
|
|
def check_auth(request, rule=None, target=None):
|
|
"""Check authentication on request.
|
|
|
|
:param request: Flask request
|
|
:param rule: policy rule to check the request against
|
|
:param target: dict-like structure to check rule against
|
|
:raises: utils.Error if access is denied
|
|
"""
|
|
if CONF.auth_strategy not in ('keystone', 'http_basic'):
|
|
return
|
|
if not request.context.is_public_api:
|
|
if request.headers.get('X-Identity-Status', '').lower() == 'invalid':
|
|
raise Error(_('Authentication required'), code=401)
|
|
if CONF.auth_strategy != 'keystone':
|
|
return
|
|
target = {} if target is None else target
|
|
if not policy.authorize(rule, target, request.context.to_policy_values()):
|
|
raise Error(_("Access denied by policy"), code=403)
|
|
|
|
|
|
def get_valid_macs(data):
|
|
"""Get a list of valid MAC's from the introspection data."""
|
|
return [m['mac']
|
|
for m in data.get('all_interfaces', {}).values()
|
|
if m.get('mac')]
|
|
|
|
|
|
def get_inventory(data, node_info=None):
|
|
"""Get and validate the hardware inventory from introspection data."""
|
|
inventory = data.get('inventory')
|
|
# TODO(dtantsur): validate inventory using JSON schema
|
|
if not inventory:
|
|
raise Error(_('Hardware inventory is empty or missing'),
|
|
data=data, node_info=node_info)
|
|
|
|
if not inventory.get('interfaces'):
|
|
raise Error(_('No network interfaces provided in the inventory'),
|
|
data=data, node_info=node_info)
|
|
|
|
if not inventory.get('disks'):
|
|
LOG.info('No disks were detected in the inventory, assuming this '
|
|
'is a disk-less node', data=data, node_info=node_info)
|
|
# Make sure the code iterating over it does not fail with a TypeError
|
|
inventory['disks'] = []
|
|
|
|
return inventory
|
|
|
|
|
|
def iso_timestamp(timestamp=None, tz=pytz.timezone('utc')):
|
|
"""Return an ISO8601-formatted timestamp (tz: UTC) or None.
|
|
|
|
:param timestamp: such as time.time() or None
|
|
:param tz: timezone
|
|
:returns: an ISO8601-formatted timestamp, or None
|
|
"""
|
|
if timestamp is None:
|
|
return None
|
|
date = datetime.datetime.fromtimestamp(timestamp, tz=tz)
|
|
return date.isoformat()
|