Cristian Mondo a6a6b84258 Subcloud Name Reconfiguration
This change adds the capability to rename the subcloud after
bootstrap or during subcloud rehome operation.

Added a field in the database to separate the region name
from the subcloud name.
The region name determines the subcloud reference in the
Openstack core, through which it is possible to access
the endpoints of a given subcloud. Since the region name
cannot be changed, this commit adds the ability to maintain
a unique region name based on the UUID format, and allows
subcloud renaming when necessary without any endpoint
impact.
The region is randomly generated to configure the subcloud
when it is created and only applies to future subclouds.
For those systems that have existing subclouds, the region
will be the same as on day 0, that is, region will keep the
same name as the subcloud, but subclouds can be renamed.

This topic involves changes to dcmanager, dcmanager-client
and GUI. To ensure the region name reference needed by the
cert-monitor, a mechanism to determine if the request is
coming from the cert-monitor has been created.

Usage for subcloud rename:
dcmanager subcloud update <subcloud-name> --name <new-name>

Usage for subcloud rehoming:
dcmanager subcloud add --name <subcloud-name> --migrate ...

Note: Upgrade test from StarlingX 8 -> 9 for this commit
is deferred until upgrade functionality in master is
restored. Any issue found during upgrade test will be
addressed in a separate commit

Test Plan:
PASS: Run dcmanager subcloud passing subcommands:
      - add/delete/migrate/list/show/show --detail
      - errors/manage/unmanage/reinstall/reconfig
      - update/deploy
PASS: Run dcmanager subcloud add supplying --name
      parameter and validate the operation is not allowed
PASS: Run dcmanager supplying subcommands:
      - kube/patch/prestage strategies
PASS: Run dcmanager to apply patch and remove it
PASS: Run dcmanager subcloud-backup:
      - create/delete/restore/show/upload
PASS: Run subcloud-group:
      - add/delete/list/list-subclouds/show/update
PASS: Run dcmanager subcloud strategy for:
      - patch/kubernetes/firmware
PASS: Run dcmanager subcloud update command passing --name
      parameter supplying the following values:
      - current subcloud name (not changed)
      - different existing subcloud name
PASS: Run dcmanager to migrate a subcloud passing --name
      parameter supplying a new subcloud name
PASS: Run dcmanager to migrate a subcloud without --name
      parameter
PASS: Run dcmanager to migrate a subcloud passing --name
      parameter supplying a new subcloud name and
      different subcloud name in bootstrap file
PASS: Test dcmanager API response using cURL command line
      to validate new region name field
PASS: Run full DC sanity and regression

Story: 2010788
Task: 48217

Signed-off-by: Cristian Mondo <cristian.mondo@windriver.com>
Change-Id: Id04f42504b8e325d9ec3880c240fe4a06e3a20b7
2023-09-07 10:30:06 -03:00

395 lines
16 KiB
Python

#
# 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'))