config/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/load.py

615 lines
23 KiB
Python

# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 UnitedStack Inc.
# All Rights Reserved.
#
# 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.
#
# Copyright (c) 2015-2021 Wind River Systems, Inc.
#
import json
import jsonpatch
import os
import pecan
from pecan import rest
import psutil
import six
import shutil
import socket
import sys
import wsme
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
from eventlet.green import subprocess
from oslo_log import log
from pecan import expose
from pecan import request
from sysinv._i18n import _
from sysinv.api.controllers.v1 import base
from sysinv.api.controllers.v1 import collection
from sysinv.api.controllers.v1 import link
from sysinv.api.controllers.v1 import types
from sysinv.api.controllers.v1 import utils
from sysinv.cert_mon import utils as cert_utils
from sysinv.common import constants
from sysinv.common import exception
from sysinv.common import utils as cutils
from sysinv import objects
from sysinv.openstack.common import rpc
from sysinv.openstack.common.rpc import common
import tsconfig.tsconfig as tsc
LOG = log.getLogger(__name__)
class LoadPatchType(types.JsonPatchType):
@staticmethod
def mandatory_attrs():
return []
class LoadImportType(base.APIBase):
path_to_iso = wtypes.text
path_to_sig = wtypes.text
def __init__(self, **kwargs):
self.fields = ['path_to_iso', 'path_to_sig']
for k in self.fields:
setattr(self, k, kwargs.get(k))
class Load(base.APIBase):
"""API representation of a Load
This class enforces type checking and value constraints, and converts
between the internal object model and the API representation of an
Load.
"""
id = int
"The id of the Load"
uuid = types.uuid
"Unique UUID for this Load"
state = wtypes.text
"Represents the current state of the Load"
software_version = wtypes.text
"Represents the software version of the Load"
compatible_version = wtypes.text
"Represents the compatible version of the Load"
required_patches = wtypes.text
"A list of the patches required to upgrade to this load"
def __init__(self, **kwargs):
self.fields = list(objects.load.fields.keys())
for k in self.fields:
setattr(self, k, kwargs.get(k))
@classmethod
def convert_with_links(cls, rpc_load, expand=True):
load = Load(**rpc_load.as_dict())
load_fields = ['id', 'uuid', 'state', 'software_version',
'compatible_version', 'required_patches'
]
if not expand:
load.unset_fields_except(load_fields)
load.links = [link.Link.make_link('self', pecan.request.host_url,
'loads', load.uuid),
link.Link.make_link('bookmark',
pecan.request.host_url,
'loads', load.uuid, bookmark=True)
]
return load
class LoadCollection(collection.Collection):
"""API representation of a collection of Load objects."""
loads = [Load]
"A list containing Load objects"
def __init__(self, **kwargs):
self._type = 'loads'
@classmethod
def convert_with_links(cls, rpc_loads, limit, url=None,
expand=False, **kwargs):
collection = LoadCollection()
collection.loads = [Load.convert_with_links(p, expand)
for p in rpc_loads]
collection.next = collection.get_next(limit, url=url, **kwargs)
return collection
LOCK_NAME = 'LoadController'
class LoadController(rest.RestController):
"""REST controller for Loads."""
_custom_actions = {
'detail': ['GET'],
'import_load': ['POST'],
'import_load_metadata': ['POST']
}
def __init__(self):
self._api_token = None
def _get_loads_collection(self, marker, limit, sort_key, sort_dir,
expand=False, resource_url=None):
limit = utils.validate_limit(limit)
sort_dir = utils.validate_sort_dir(sort_dir)
marker_obj = None
if marker:
marker_obj = objects.load.get_by_uuid(
pecan.request.context,
marker)
loads = pecan.request.dbapi.load_get_list(
limit, marker_obj,
sort_key=sort_key,
sort_dir=sort_dir)
return LoadCollection.convert_with_links(loads, limit,
url=resource_url,
expand=expand,
sort_key=sort_key,
sort_dir=sort_dir)
@wsme_pecan.wsexpose(LoadCollection, types.uuid, int, wtypes.text,
wtypes.text)
def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of loads."""
return self._get_loads_collection(marker, limit, sort_key, sort_dir)
@wsme_pecan.wsexpose(LoadCollection, types.uuid, int, wtypes.text,
wtypes.text)
def detail(self, marker=None, limit=None, sort_key='id', sort_dir='asc'):
"""Retrieve a list of loads with detail."""
parent = pecan.request.path.split('/')[:-1][-1]
if parent != "loads":
raise exception.HTTPNotFound
expand = True
resource_url = '/'.join(['loads', 'detail'])
return self._get_loads_collection(marker, limit, sort_key, sort_dir,
expand, resource_url)
@wsme_pecan.wsexpose(Load, six.text_type)
def get_one(self, load_uuid):
"""Retrieve information about the given Load."""
rpc_load = objects.load.get_by_uuid(
pecan.request.context, load_uuid)
return Load.convert_with_links(rpc_load)
@staticmethod
def _new_load_semantic_checks(load):
if not load['software_version']:
raise wsme.exc.ClientSideError(
_("Load missing software_version key"))
if load['state']:
raise wsme.exc.ClientSideError(
_("Can not set state during create"))
@cutils.synchronized(LOCK_NAME)
@wsme_pecan.wsexpose(Load, body=Load)
def post(self, load):
"""Create a new Load."""
# This method is only used to populate the inital load for the system
# This is invoked during config_controller
# Loads after the first are added via import
loads = pecan.request.dbapi.load_get_list()
if loads:
raise wsme.exc.ClientSideError(_("Aborting. Active load exits."))
patch = load.as_dict()
self._new_load_semantic_checks(patch)
patch['state'] = constants.ACTIVE_LOAD_STATE
try:
new_load = pecan.request.dbapi.load_create(patch)
# Controller-0 is added to the database before we add this load
# so we must add a host_upgrade entry for (at least) controller-0
hosts = pecan.request.dbapi.ihost_get_list()
for host in hosts:
values = dict()
values['forihostid'] = host.id
values['software_load'] = new_load.id
values['target_load'] = new_load.id
pecan.request.dbapi.host_upgrade_create(host.id,
new_load.software_version,
values)
except exception.SysinvException as e:
LOG.exception(e)
raise wsme.exc.ClientSideError(_("Invalid data"))
return load.convert_with_links(new_load)
@staticmethod
def _upload_file(file_item):
try:
staging_dir = constants.LOAD_FILES_STAGING_DIR
if not os.path.isdir(staging_dir):
os.makedirs(staging_dir)
source_file = file_item.file
staging_file = os.path.join(staging_dir,
os.path.basename(file_item.filename))
if source_file is None:
raise wsme.exc.ClientSideError(_("Failed to upload load file %s,\
invalid file object" % staging_file))
# This try block is to get only the iso file size as
# the signature file object type is different in Debian than CentOS
# and it has fileno() attribute but is not a supported operation on Debian
#
# The check for st_size is required to determine the file size of iso image
# It is not applicable to its signature file
try:
file_size = os.fstat(source_file.fileno()).st_size
except Exception:
file_size = -1
if file_size >= 0:
# Only proceed if there is space available for copying
avail_space = psutil.disk_usage('/scratch').free
if (avail_space < file_size):
raise wsme.exc.ClientSideError(_("Failed to upload load file %s, not enough space on /scratch"
" partition: %d bytes available "
% (staging_file, avail_space)))
# Large iso file, allocate the required space
subprocess.check_call(["/usr/bin/fallocate", # pylint: disable=not-callable
"-l " + str(file_size), staging_file])
with open(staging_file, 'wb') as destination_file:
shutil.copyfileobj(source_file, destination_file)
except subprocess.CalledProcessError as e:
if os.path.isfile(staging_file):
os.remove(staging_file)
raise wsme.exc.ClientSideError(_("Failed to upload load file %s, /usr/bin/fallocate error: %s"
% (staging_file, e.output)))
except Exception:
if os.path.isfile(staging_file):
os.remove(staging_file)
raise wsme.exc.ClientSideError(_("Failed to upload load file %s" % file_item.filename))
return staging_file
@expose('json')
@cutils.synchronized(LOCK_NAME)
def import_load(self):
"""Import a load from iso/sig files"""
try:
return self._import_load()
except Exception as e:
# Duplicate the exception handling behavior of the wsmeext.pecan wsexpose decorator
# This can be moved to a decorator if we need to reuse this in other modules
exception_code = getattr(e, 'code', None)
pecan.response.status = exception_code if wsme.utils.is_valid_code(exception_code) else 500
return wsme.api.format_exception(sys.exc_info())
def _import_load(self):
"""Create a new load from iso/sig files"""
LOG.info("Load import request received.")
# Only import loads on controller-0. This is required because the load
# is only installed locally and we will be booting controller-1 from
# this load during the upgrade.
if socket.gethostname() != constants.CONTROLLER_0_HOSTNAME:
raise wsme.exc.ClientSideError(_("A load can only be imported when"
" %s is active.")
% constants.CONTROLLER_0_HOSTNAME)
req_content = dict()
load_files = dict()
is_multiform_req = True
import_type = None
# Request coming from dc-api-proxy is not multiform, file transfer is handled
# by dc-api-proxy, the request contains only the vault file location
if request.content_type == "application/json":
req_content = dict(json.loads(request.body))
is_multiform_req = False
else:
req_content = dict(request.POST.items())
if not req_content:
raise wsme.exc.ClientSideError(_("Empty request."))
active = req_content.get('active')
inactive = req_content.get('inactive')
if active == 'true' and inactive == 'true':
raise wsme.exc.ClientSideError(_("Invalid use of --active and"
" --inactive arguments at"
" the same time."))
if active == 'true' or inactive == 'true':
isystem = pecan.request.dbapi.isystem_get_one()
if isystem.distributed_cloud_role == \
constants.DISTRIBUTED_CLOUD_ROLE_SYSTEMCONTROLLER:
LOG.info("System Controller allow start import_load")
if active == 'true':
import_type = constants.ACTIVE_LOAD_IMPORT
elif inactive == 'true':
import_type = constants.INACTIVE_LOAD_IMPORT
self._check_existing_loads(import_type=import_type)
try:
for file in constants.IMPORT_LOAD_FILES:
if file not in req_content:
raise wsme.exc.ClientSideError(_("Missing required file for %s")
% file)
if not is_multiform_req:
load_files.update({file: req_content[file]})
else:
if file not in request.POST:
raise wsme.exc.ClientSideError(_("Missing required file for %s")
% file)
file_item = request.POST[file]
if not file_item.filename:
raise wsme.exc.ClientSideError(_("No %s file uploaded") % file)
file_location = self._upload_file(file_item)
if file_location:
load_files.update({file: file_location})
except subprocess.CalledProcessError as ex:
raise wsme.exc.ClientSideError(str(ex))
except Exception as ex:
raise wsme.exc.ClientSideError(_("Failed to save file %s to disk. Error: %s"
" Please check sysinv logs for"
" details." % (file_item.filename, str(ex))))
LOG.info("Load files: %s saved to disk." % load_files)
exception_occured = False
try:
new_load = pecan.request.rpcapi.start_import_load(
pecan.request.context,
load_files[constants.LOAD_ISO],
load_files[constants.LOAD_SIGNATURE],
import_type,
)
if new_load is None:
raise wsme.exc.ClientSideError(_("Error importing load. Load not found"))
if import_type != constants.ACTIVE_LOAD_IMPORT:
# Signature and upgrade path checks have passed, make rpc call
# to the conductor to run import script in the background.
pecan.request.rpcapi.import_load(
pecan.request.context,
load_files[constants.LOAD_ISO],
new_load,
import_type,
)
except (rpc.common.Timeout, common.RemoteError) as e:
exception_occured = True
error = e.value if hasattr(e, 'value') else str(e)
raise wsme.exc.ClientSideError(error)
except Exception:
exception_occured = True
raise
finally:
if exception_occured and os.path.isdir(constants.LOAD_FILES_STAGING_DIR):
shutil.rmtree(constants.LOAD_FILES_STAGING_DIR)
load_data = new_load.as_dict()
LOG.info("Load import request validated, returning new load data: %s"
% load_data)
return load_data
@cutils.synchronized(LOCK_NAME)
@wsme_pecan.wsexpose(Load, body=Load)
def import_load_metadata(self, load):
"""Import a new load using only the metadata. Only available to SX subcoulds."""
LOG.info("Load import metadata request received.")
err_msg = None
# Enforce system type restrictions
err_msg = _("Metadata load import is only available to simplex subclouds.")
if utils.get_system_mode() != constants.SYSTEM_MODE_SIMPLEX:
raise wsme.exc.ClientSideError(err_msg)
if utils.get_distributed_cloud_role() != constants.DISTRIBUTED_CLOUD_ROLE_SUBCLOUD:
raise wsme.exc.ClientSideError(err_msg)
self._check_existing_loads()
if load.software_version == load.compatible_version:
raise wsme.exc.ClientSideError(_("Invalid load software_version."))
if load.compatible_version != tsc.SW_VERSION:
raise wsme.exc.ClientSideError(_("Load compatible_version does not match SW_VERSION."))
patch = load.as_dict()
self._new_load_semantic_checks(patch)
patch['state'] = constants.IMPORTED_METADATA_LOAD_STATE
patch['uuid'] = None
LOG.info("Load import metadata validated, creating new load: %s" % patch)
try:
new_load = pecan.request.dbapi.load_create(patch)
except exception.SysinvException:
LOG.exception("Failure to create load")
raise wsme.exc.ClientSideError(_("Failure to create load"))
return load.convert_with_links(new_load)
def _check_existing_loads(self, import_type=None):
# Only are allowed at one time:
# - the active load
# - an imported load regardless of its current state
# - an inactive load.
loads = pecan.request.dbapi.load_get_list()
if len(loads) <= constants.IMPORTED_LOAD_MAX_COUNT:
return
for load in loads:
if load.state == constants.ACTIVE_LOAD_STATE:
continue
load_state = load.state
if load_state == constants.ERROR_LOAD_STATE:
err_msg = _("Please remove the load in error state "
"before importing a new one.")
elif load_state == constants.DELETING_LOAD_STATE:
err_msg = _("Please wait for the current load delete "
"to complete before importing a new one.")
elif load_state == constants.INACTIVE_LOAD_STATE:
if import_type != constants.INACTIVE_LOAD_IMPORT:
continue
err_msg = _("An inactived load already exists. "
"Please, remove the inactive load "
"before trying to import a new one.")
elif import_type == constants.ACTIVE_LOAD_IMPORT or \
import_type == constants.INACTIVE_LOAD_IMPORT:
continue
elif not err_msg:
# Already imported or being imported
err_msg = _("Max number of loads (2) reached. Please "
"remove the old or unused load before "
"importing a new one.")
raise wsme.exc.ClientSideError(err_msg)
@cutils.synchronized(LOCK_NAME)
@wsme.validate(six.text_type, [LoadPatchType])
@wsme_pecan.wsexpose(Load, six.text_type,
body=[LoadPatchType])
def patch(self, load_id, patch):
"""Update an existing load."""
# TODO (dsulliva)
# This is a stub. We will need to place reasonable limits on what can
# be patched as we add to the upgrade system. This portion of the API
# likely will not be publicly accessible.
rpc_load = objects.load.get_by_uuid(pecan.request.context, load_id)
utils.validate_patch(patch)
patch_obj = jsonpatch.JsonPatch(patch)
try:
load = Load(**jsonpatch.apply_patch(rpc_load.as_dict(), patch_obj))
except utils.JSONPATCH_EXCEPTIONS as e:
raise exception.PatchError(patch=patch, reason=e)
fields = objects.load.fields
for field in fields:
if rpc_load[field] != getattr(load, field):
rpc_load[field] = getattr(load, field)
rpc_load.save()
return Load.convert_with_links(rpc_load)
@cutils.synchronized(LOCK_NAME)
@wsme_pecan.wsexpose(Load, six.text_type, status_code=200)
def delete(self, load_id):
"""Delete a load."""
load = pecan.request.dbapi.load_get(load_id)
# make sure the load isn't in use by an upgrade
try:
# NOTE(bqian) load relates only to the legacy upgrade
upgrade = pecan.request.dbapi.software_upgrade_get_one()
except exception.NotFound:
pass
else:
if load.id == upgrade.to_load or load.id == upgrade.from_load:
raise wsme.exc.ClientSideError(
_("Unable to delete load, load in use by upgrade"))
# make sure the load isn't used by any hosts
hosts = pecan.request.dbapi.host_upgrade_get_list()
for host in hosts:
if host.target_load == load.id or host.software_load == load.id:
raise wsme.exc.ClientSideError(_(
"Unable to delete load, load in use by host (id: %s)")
% host.forihostid)
# make sure there are no subclouds with current load different from central
# cloud current load
system = pecan.request.dbapi.isystem_get_one()
if load.state == constants.IMPORTED_LOAD_STATE and \
system.distributed_cloud_role == constants.DISTRIBUTED_CLOUD_ROLE_SYSTEMCONTROLLER:
subclouds = []
try:
# TODO move the cert_utils to a more common place
cert_utils.init_keystone_auth_opts()
token_cache = cert_utils.TokenCache(constants.OS_INTERFACE_INTERNAL)
all_subclouds = cert_utils.get_subclouds_from_dcmanager(token_cache.get_token())
for sc in all_subclouds:
subcloud = cert_utils.get_subcloud(token_cache.get_token(), sc['name'])
if subcloud['management-state'] == cert_utils.MANAGEMENT_MANAGED \
and subcloud['software-version'] == load.software_version:
subclouds.append(sc['name'])
except Exception as err:
LOG.error("Unexpected error to get subclouds software version: %s", err)
raise wsme.exc.ClientSideError(_("Failed to detect if the load can be safely deleted."))
if subclouds:
raise wsme.exc.ClientSideError(_(
"Unable to delete load, %i subclouds are not upgraded yet. "
"Some of these include %s") % (len(subclouds), subclouds[:10]))
cutils.validate_load_for_delete(load)
pecan.request.rpcapi.delete_load(pecan.request.context, load_id)
return Load.convert_with_links(load)