# # Copyright (c) 2022-2023 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # from collections import namedtuple import json import os from oslo_config import cfg from oslo_log import log as logging from oslo_messaging import RemoteError import pecan from pecan import expose from pecan import request as pecan_request from pecan import response import tsconfig.tsconfig as tsc import yaml from dcmanager.api.controllers import restcomm from dcmanager.api.policies import subcloud_backup as subcloud_backup_policy from dcmanager.api import policy from dcmanager.common import consts from dcmanager.common import exceptions from dcmanager.common.i18n import _ from dcmanager.common import utils from dcmanager.db import api as db_api from dcmanager.rpc import client as rpc_client CONF = cfg.CONF LOG = logging.getLogger(__name__) LOCK_NAME = 'SubcloudBackupController' # Subcloud/group information to be retrieved from request params RequestEntity = namedtuple('RequestEntity', ['type', 'id', 'name', 'subclouds']) class SubcloudBackupController(object): def __init__(self): super(SubcloudBackupController, self).__init__() self.dcmanager_rpc_client = rpc_client.ManagerClient( timeout=consts.RPC_SUBCLOUD_BACKUP_TIMEOUT) @expose(generic=True, template='json') def index(self): # Route the request to specific methods with parameters pass @staticmethod def _get_payload(request, verb): expected_params = dict() if verb == 'create': expected_params = { "subcloud": "text", "group": "text", "local_only": "text", "registry_images": "text", "backup_values": "yaml", "sysadmin_password": "text" } elif verb == 'delete': expected_params = { "release": "text", "subcloud": "text", "group": "text", "local_only": "text", "sysadmin_password": "text" } elif verb == 'restore': expected_params = { "with_install": "text", "release": "text", "local_only": "text", "registry_images": "text", "sysadmin_password": "text", "restore_values": "text", "subcloud": "text", "group": "text" } else: pecan.abort(400, _("Unexpected verb received")) content_type = request.headers.get('content-type') LOG.info('Request content-type: %s' % content_type) if 'multipart/form-data' in content_type.lower(): return SubcloudBackupController._get_multipart_payload(request, expected_params) else: return SubcloudBackupController._get_json_payload(request, expected_params) @staticmethod def _get_multipart_payload(request, expected_params): payload = dict() file_params = ['backup_values', 'restore_values'] for param in file_params: if param in request.POST: file_item = request.POST[param] file_item.file.seek(0, os.SEEK_SET) data = yaml.safe_load(file_item.file.read().decode('utf8')) payload.update({param: data}) del request.POST[param] payload.update(request.POST) if not set(payload.keys()).issubset(expected_params.keys()): LOG.info("Got an unexpected parameter in: %s" % payload) pecan.abort(400, _("Unexpected parameter received")) return payload @staticmethod def _get_json_payload(request, expected_params): try: payload = json.loads(request.body) except Exception: error_msg = 'Request body is malformed.' LOG.exception(error_msg) pecan.abort(400, _(error_msg)) return if not isinstance(payload, dict): pecan.abort(400, _('Invalid request body format')) if not set(payload.keys()).issubset(expected_params.keys()): LOG.info("Got an unexpected parameter in: %s" % payload) pecan.abort(400, _("Unexpected parameter received")) return payload @staticmethod def _validate_and_decode_sysadmin_password(payload, param_name): sysadmin_password = payload.get(param_name) if not sysadmin_password: pecan.abort(400, _('subcloud sysadmin_password required')) try: payload['sysadmin_password'] = \ utils.decode_and_normalize_passwd(sysadmin_password) except Exception: msg = _('Failed to decode subcloud sysadmin_password, ' 'verify the password is base64 encoded') LOG.exception(msg) pecan.abort(400, msg) @staticmethod def _convert_param_to_bool(payload, param_names, default=False): for param_name in param_names: param = payload.get(param_name) if param: if param.lower() == 'true': payload[param_name] = True elif param.lower() == 'false': payload[param_name] = False else: pecan.abort(400, _('Invalid %s value, should be boolean' % param_name)) else: payload[param_name] = default @staticmethod def _validate_subclouds(request_entity, operation): """Validate the subcloud according to the operation Create/Delete: The subcloud is managed, online and in complete state. Restore: The subcloud is unmanaged, and not in the process of installation, boostrap, deployment or rehoming. If none of the subclouds are valid, the operation will be aborted. Args: request_entity (namedtuple): Request entity operation (string): Subcloud backup operation """ subclouds = request_entity.subclouds error_msg = _('Subcloud(s) must be in a valid state for backup %s.' % operation) has_valid_subclouds = False valid_subclouds = list() for subcloud in subclouds: try: is_valid = utils.is_valid_for_backup_operation(operation, subcloud) if operation == 'create': backup_in_progress = subcloud.backup_status in \ consts.STATES_FOR_ONGOING_BACKUP if is_valid and not backup_in_progress: has_valid_subclouds = True else: error_msg = _('Subcloud(s) already have a backup ' 'operation in progress.') else: if is_valid: valid_subclouds.append(subcloud) has_valid_subclouds = True except exceptions.ValidateFail as e: error_msg = e.message if (operation == 'create' and has_valid_subclouds and request_entity.type == 'subcloud'): # Check the system health only if the command was issued # to a single subcloud to avoid huge delays. if not utils.is_subcloud_healthy(subcloud.region_name): msg = _('Subcloud %s must be in good health for ' 'subcloud-backup create.' % subcloud.name) pecan.abort(400, msg) if not has_valid_subclouds: if request_entity.type == 'group': msg = _('None of the subclouds in group %s are in a valid ' 'state for subcloud-backup %s') % (request_entity.name, operation) elif request_entity.type == 'subcloud': msg = error_msg pecan.abort(400, msg) return valid_subclouds @staticmethod def _get_subclouds_from_group(group, context): if not group: pecan.abort(404, _('Group not found')) return db_api.subcloud_get_for_group(context, group.id) def _read_entity_from_request_params(self, context, payload): subcloud_ref = payload.get('subcloud') group_ref = payload.get('group') if subcloud_ref: if group_ref: pecan.abort(400, _("'subcloud' and 'group' parameters " "should not be given at the same time")) subcloud = utils.subcloud_get_by_ref(context, subcloud_ref) if not subcloud: pecan.abort(400, _('Subcloud not found')) return RequestEntity('subcloud', subcloud.id, subcloud_ref, [subcloud]) elif group_ref: group = utils.subcloud_group_get_by_ref(context, group_ref) group_subclouds = self._get_subclouds_from_group(group, context) if not group_subclouds: pecan.abort(400, _('No subclouds present in group')) return RequestEntity('group', group.id, group_ref, group_subclouds) else: pecan.abort(400, _("'subcloud' or 'group' parameter is required")) @utils.synchronized(LOCK_NAME) @index.when(method='POST', template='json') def post(self): """Create a new subcloud backup.""" context = restcomm.extract_context_from_environ() payload = self._get_payload(pecan_request, 'create') policy.authorize(subcloud_backup_policy.POLICY_ROOT % "create", {}, restcomm.extract_credentials_for_policy()) self._validate_and_decode_sysadmin_password(payload, 'sysadmin_password') if not payload.get('local_only') and payload.get('registry_images'): pecan.abort(400, _('Option registry_images can not be used without ' 'local_only option.')) request_entity = self._read_entity_from_request_params(context, payload) self._validate_subclouds(request_entity, 'create') # Set subcloud/group ID as reference instead of name to ease processing payload[request_entity.type] = request_entity.id self._convert_param_to_bool(payload, ['local_only', 'registry_images']) try: self.dcmanager_rpc_client.backup_subclouds(context, payload) return utils.subcloud_db_list_to_dict(request_entity.subclouds) except RemoteError as e: pecan.abort(422, e.value) except Exception: LOG.exception("Unable to backup subclouds") pecan.abort(500, _('Unable to backup subcloud')) @utils.synchronized(LOCK_NAME) @index.when(method='PATCH', template='json') def patch(self, verb, release_version=None): """Delete or restore a subcloud backup. :param verb: Specifies the patch action to be taken to the subcloud backup operation :param release_version: Backup release version to be deleted """ context = restcomm.extract_context_from_environ() payload = self._get_payload(pecan_request, verb) if verb == 'delete': policy.authorize(subcloud_backup_policy.POLICY_ROOT % "delete", {}, restcomm.extract_credentials_for_policy()) if not release_version: pecan.abort(400, _('Release version required')) self._convert_param_to_bool(payload, ['local_only']) # Backup delete in systemcontroller doesn't need sysadmin_password if payload.get('local_only'): self._validate_and_decode_sysadmin_password( payload, 'sysadmin_password') request_entity = self._read_entity_from_request_params(context, payload) # Validate subcloud state when deleting locally # Not needed for centralized storage, since connection is not required local_only = payload.get('local_only') if local_only: self._validate_subclouds(request_entity, verb) # Set subcloud/group ID as reference instead of name to ease processing payload[request_entity.type] = request_entity.id try: message = self.dcmanager_rpc_client.delete_subcloud_backups( context, release_version, payload) if message: response.status_int = 207 return message else: response.status_int = 204 except RemoteError as e: pecan.abort(422, e.value) except Exception: LOG.exception("Unable to delete subcloud backups") pecan.abort(500, _('Unable to delete subcloud backups')) elif verb == 'restore': policy.authorize(subcloud_backup_policy.POLICY_ROOT % "restore", {}, restcomm.extract_credentials_for_policy()) if not payload: pecan.abort(400, _('Body required')) self._validate_and_decode_sysadmin_password(payload, 'sysadmin_password') self._convert_param_to_bool(payload, ['local_only', 'with_install', 'registry_images']) if not payload['local_only'] and payload['registry_images']: pecan.abort(400, _('Option registry_images cannot be used ' 'without local_only option.')) if not payload['with_install'] and payload.get('release'): pecan.abort(400, _('Option release cannot be used ' 'without with_install option.')) request_entity = self._read_entity_from_request_params(context, payload) if len(request_entity.subclouds) == 0: msg = "No subclouds exist under %s %s" % (request_entity.type, request_entity.id) pecan.abort(400, _(msg)) restore_subclouds = self._validate_subclouds(request_entity, verb) payload[request_entity.type] = request_entity.id valid_subclouds = [subcloud for subcloud in request_entity.subclouds if subcloud.data_install] if not valid_subclouds: pecan.abort(400, _('Cannot proceed with the restore operation ' 'since the subcloud(s) do not contain ' 'install data.')) if payload.get('with_install'): # Confirm the requested or active load is still in dc-vault payload['software_version'] = payload.get('release', tsc.SW_VERSION) matching_iso, err_msg = utils.get_matching_iso(payload['software_version']) if err_msg: LOG.exception(err_msg) pecan.abort(400, _(err_msg)) LOG.info("Restore operation will use image %s in subcloud " "installation" % matching_iso) try: # local update to deploy_status - this is just for CLI response for i in range(len(restore_subclouds)): restore_subclouds[i].deploy_status = consts.DEPLOY_STATE_PRE_RESTORE message = self.dcmanager_rpc_client.restore_subcloud_backups( context, payload) return utils.subcloud_db_list_to_dict(restore_subclouds) except RemoteError as e: pecan.abort(422, e.value) except Exception: LOG.exception("Unable to restore subcloud") pecan.abort(500, _('Unable to restore subcloud')) else: pecan.abort(400, _('Invalid request'))