Files
distcloud/distributedcloud/dcmanager/api/controllers/v1/phased_subcloud_deploy.py
Gustavo Herzmann 2050cf7ceb Add 'subcloud deploy complete' command to dcmanager
This commit adds the subcloud deploy complete command to dcmanager.
It's used to mark the subcloud deployment as 'complete'. This is
useful when the user manually configures the subcloud and wants to finalize the deployment without running 'dcmanager subcloud deploy config'.

To run the 'deploy complete' operation deploy status of the subcloud
must be 'bootstrap-complete'.

This commit also fixes an issue with the value returned from the
subcloud_deploy_create function. It was returning the database model
object to the RCP call when it should be returning a dictionary.

Test Plan:
1. PASS - Bootstrap a subcloud, manually configuring it and then run
          the deploy complete command (by CLI and directly through the
          API). Verify that the deploy status updates from
          'bootstrap-complete' to 'complete';
2. PASS - Verify that the command is rejected when the deploy status
          is not 'bootstrap-complete'.

Story: 2010756
Task: 48453

Change-Id: Ie2eca930e4b13a50cc12e8b9eef79bcb5e7c671f
Signed-off-by: Gustavo Herzmann <gustavo.herzmann@windriver.com>
2023-07-31 16:59:16 +00:00

484 lines
19 KiB
Python

#
# Copyright (c) 2023 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
import http.client as httpclient
import os
from oslo_log import log as logging
from oslo_messaging import RemoteError
import pecan
import yaml
from dcmanager.api.controllers import restcomm
from dcmanager.api.policies import phased_subcloud_deploy as \
phased_subcloud_deploy_policy
from dcmanager.api import policy
from dcmanager.common import consts
from dcmanager.common.context import RequestContext
from dcmanager.common import exceptions
from dcmanager.common.i18n import _
from dcmanager.common import phased_subcloud_deploy as psd_common
from dcmanager.common import prestage
from dcmanager.common import utils
from dcmanager.db import api as db_api
from dcmanager.db.sqlalchemy import models
from dcmanager.rpc import client as rpc_client
LOG = logging.getLogger(__name__)
LOCK_NAME = 'PhasedSubcloudDeployController'
INSTALL = consts.DEPLOY_PHASE_INSTALL
BOOTSTRAP = consts.DEPLOY_PHASE_BOOTSTRAP
CONFIG = consts.DEPLOY_PHASE_CONFIG
COMPLETE = consts.DEPLOY_PHASE_COMPLETE
ABORT = consts.DEPLOY_PHASE_ABORT
RESUME = consts.DEPLOY_PHASE_RESUME
SUBCLOUD_CREATE_REQUIRED_PARAMETERS = (
consts.BOOTSTRAP_VALUES,
consts.BOOTSTRAP_ADDRESS
)
# The consts.DEPLOY_CONFIG is missing here because it's handled differently
# by the upload_deploy_config_file() function
SUBCLOUD_CREATE_GET_FILE_CONTENTS = (
consts.BOOTSTRAP_VALUES,
consts.INSTALL_VALUES,
)
SUBCLOUD_INSTALL_GET_FILE_CONTENTS = (
consts.INSTALL_VALUES,
)
SUBCLOUD_BOOTSTRAP_GET_FILE_CONTENTS = (
consts.BOOTSTRAP_VALUES,
)
SUBCLOUD_CONFIG_GET_FILE_CONTENTS = (
consts.DEPLOY_CONFIG,
)
VALID_STATES_FOR_DEPLOY_INSTALL = (
consts.DEPLOY_STATE_CREATED,
consts.DEPLOY_STATE_PRE_INSTALL_FAILED,
consts.DEPLOY_STATE_INSTALL_FAILED,
consts.DEPLOY_STATE_INSTALLED,
consts.DEPLOY_STATE_INSTALL_ABORTED
)
VALID_STATES_FOR_DEPLOY_BOOTSTRAP = [
consts.DEPLOY_STATE_INSTALLED,
consts.DEPLOY_STATE_PRE_BOOTSTRAP_FAILED,
consts.DEPLOY_STATE_BOOTSTRAP_FAILED,
consts.DEPLOY_STATE_BOOTSTRAP_ABORTED,
consts.DEPLOY_STATE_BOOTSTRAPPED,
# The subcloud can be installed manually (without remote install) so we need
# to allow the bootstrap operation when the state == DEPLOY_STATE_CREATED
consts.DEPLOY_STATE_CREATED
]
# TODO(vgluzrom): remove deploy_failed once 'subcloud reconfig'
# has been deprecated
VALID_STATES_FOR_DEPLOY_CONFIG = (
consts.DEPLOY_STATE_DONE,
consts.DEPLOY_STATE_PRE_CONFIG_FAILED,
consts.DEPLOY_STATE_CONFIG_FAILED,
consts.DEPLOY_STATE_DEPLOY_FAILED,
consts.DEPLOY_STATE_BOOTSTRAPPED,
consts.DEPLOY_STATE_CONFIG_ABORTED
)
VALID_STATES_FOR_DEPLOY_ABORT = (
consts.DEPLOY_STATE_INSTALLING,
consts.DEPLOY_STATE_BOOTSTRAPPING,
consts.DEPLOY_STATE_CONFIGURING
)
FILES_FOR_RESUME_INSTALL = \
SUBCLOUD_INSTALL_GET_FILE_CONTENTS + \
SUBCLOUD_BOOTSTRAP_GET_FILE_CONTENTS + \
SUBCLOUD_CONFIG_GET_FILE_CONTENTS
FILES_FOR_RESUME_BOOTSTRAP = \
SUBCLOUD_BOOTSTRAP_GET_FILE_CONTENTS + \
SUBCLOUD_CONFIG_GET_FILE_CONTENTS
FILES_FOR_RESUME_CONFIG = SUBCLOUD_CONFIG_GET_FILE_CONTENTS
RESUMABLE_STATES = {
consts.DEPLOY_STATE_CREATED: [INSTALL, BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_INSTALLED: [BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_PRE_INSTALL_FAILED: [INSTALL, BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_INSTALL_FAILED: [INSTALL, BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_INSTALL_ABORTED: [INSTALL, BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_BOOTSTRAPPED: [CONFIG],
consts.DEPLOY_STATE_PRE_BOOTSTRAP_FAILED: [BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_BOOTSTRAP_FAILED: [BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_BOOTSTRAP_ABORTED: [BOOTSTRAP, CONFIG],
consts.DEPLOY_STATE_PRE_CONFIG_FAILED: [CONFIG],
consts.DEPLOY_STATE_CONFIG_FAILED: [CONFIG],
consts.DEPLOY_STATE_CONFIG_ABORTED: [CONFIG]
}
FILES_MAPPING = {
INSTALL: SUBCLOUD_INSTALL_GET_FILE_CONTENTS,
BOOTSTRAP: SUBCLOUD_BOOTSTRAP_GET_FILE_CONTENTS,
CONFIG: SUBCLOUD_CONFIG_GET_FILE_CONTENTS
}
RESUME_PREP_UPDATE_STATUS = {
INSTALL: consts.DEPLOY_STATE_PRE_INSTALL,
BOOTSTRAP: consts.DEPLOY_STATE_PRE_BOOTSTRAP,
CONFIG: consts.DEPLOY_STATE_PRE_CONFIG
}
def get_create_payload(request: pecan.Request) -> dict:
payload = dict()
for f in SUBCLOUD_CREATE_GET_FILE_CONTENTS:
if f in request.POST:
file_item = request.POST[f]
file_item.file.seek(0, os.SEEK_SET)
data = yaml.safe_load(file_item.file.read().decode('utf8'))
if f == consts.BOOTSTRAP_VALUES:
payload.update(data)
else:
payload.update({f: data})
del request.POST[f]
payload.update(request.POST)
return payload
class PhasedSubcloudDeployController(object):
def __init__(self):
super().__init__()
self.dcmanager_rpc_client = rpc_client.ManagerClient()
def _deploy_create(self, context: RequestContext, request: pecan.Request):
policy.authorize(phased_subcloud_deploy_policy.POLICY_ROOT % "create",
{}, restcomm.extract_credentials_for_policy())
psd_common.check_required_parameters(
request, SUBCLOUD_CREATE_REQUIRED_PARAMETERS)
payload = get_create_payload(request)
psd_common.pre_deploy_create(payload, context, request)
try:
# Add the subcloud details to the database
subcloud = psd_common.add_subcloud_to_database(context, payload)
# Ask dcmanager-manager to create the subcloud.
# It will do all the real work...
subcloud_dict = self.dcmanager_rpc_client.subcloud_deploy_create(
context, subcloud.id, payload)
return subcloud_dict
except RemoteError as e:
pecan.abort(httpclient.UNPROCESSABLE_ENTITY, e.value)
except Exception:
LOG.exception("Unable to create subcloud %s" % payload.get('name'))
pecan.abort(httpclient.INTERNAL_SERVER_ERROR,
_('Unable to create subcloud'))
def _deploy_install(self, context: RequestContext,
request: pecan.Request, subcloud):
payload = psd_common.get_request_data(
request, subcloud, SUBCLOUD_INSTALL_GET_FILE_CONTENTS)
if not payload:
pecan.abort(400, _('Body required'))
if subcloud.deploy_status not in VALID_STATES_FOR_DEPLOY_INSTALL:
allowed_states_str = ', '.join(VALID_STATES_FOR_DEPLOY_INSTALL)
pecan.abort(400, _('Subcloud deploy status must be either: %s')
% allowed_states_str)
payload['software_version'] = payload.get('release', subcloud.software_version)
psd_common.populate_payload_with_pre_existing_data(
payload, subcloud, SUBCLOUD_INSTALL_GET_FILE_CONTENTS)
psd_common.pre_deploy_install(payload, subcloud)
try:
# Align the software version of the subcloud with install
# version. Update the deploy status as pre-install.
self.dcmanager_rpc_client.subcloud_deploy_install(
context, subcloud.id, payload)
subcloud_dict = db_api.subcloud_db_model_to_dict(subcloud)
subcloud_dict['deploy-status'] = consts.DEPLOY_STATE_PRE_INSTALL
subcloud_dict['software-version'] = payload['software_version']
return subcloud_dict
except RemoteError as e:
pecan.abort(422, e.value)
except Exception:
LOG.exception("Unable to install subcloud %s" % subcloud.name)
pecan.abort(500, _('Unable to install subcloud'))
def _deploy_bootstrap(self, context: RequestContext,
request: pecan.Request,
subcloud: models.Subcloud):
if subcloud.deploy_status not in VALID_STATES_FOR_DEPLOY_BOOTSTRAP:
valid_states_str = ', '.join(VALID_STATES_FOR_DEPLOY_BOOTSTRAP)
pecan.abort(400, _('Subcloud deploy status must be either: %s')
% valid_states_str)
has_bootstrap_values = consts.BOOTSTRAP_VALUES in request.POST
payload = {}
# Try to load the existing override values
override_file = psd_common.get_config_file_path(subcloud.name)
if os.path.exists(override_file):
psd_common.populate_payload_with_pre_existing_data(
payload, subcloud, SUBCLOUD_BOOTSTRAP_GET_FILE_CONTENTS)
elif not has_bootstrap_values:
msg = _("Required bootstrap-values file was not provided and it was"
" not previously available at %s") % (override_file)
pecan.abort(400, msg)
request_data = psd_common.get_request_data(
request, subcloud, SUBCLOUD_BOOTSTRAP_GET_FILE_CONTENTS)
# Update the existing values with new ones from the request
payload.update(request_data)
psd_common.pre_deploy_bootstrap(context, payload, subcloud,
has_bootstrap_values)
try:
# Ask dcmanager-manager to bootstrap the subcloud.
self.dcmanager_rpc_client.subcloud_deploy_bootstrap(
context, subcloud.id, payload)
return db_api.subcloud_db_model_to_dict(subcloud)
except RemoteError as e:
pecan.abort(httpclient.UNPROCESSABLE_ENTITY, e.value)
except Exception:
LOG.exception("Unable to bootstrap subcloud %s" %
payload.get('name'))
pecan.abort(httpclient.INTERNAL_SERVER_ERROR,
_('Unable to bootstrap subcloud'))
def _deploy_config(self, context: RequestContext,
request: pecan.Request, subcloud):
payload = psd_common.get_request_data(
request, subcloud, SUBCLOUD_CONFIG_GET_FILE_CONTENTS)
if not payload:
pecan.abort(400, _('Body required'))
if not (subcloud.deploy_status in VALID_STATES_FOR_DEPLOY_CONFIG or
prestage.is_deploy_status_prestage(subcloud.deploy_status)):
allowed_states_str = ', '.join(VALID_STATES_FOR_DEPLOY_CONFIG)
pecan.abort(400, _('Subcloud deploy status must be either '
'%s or prestage-...') % allowed_states_str)
psd_common.populate_payload_with_pre_existing_data(
payload, subcloud, SUBCLOUD_CONFIG_GET_FILE_CONTENTS)
psd_common.validate_sysadmin_password(payload)
try:
self.dcmanager_rpc_client.subcloud_deploy_config(
context, subcloud.id, payload)
subcloud_dict = db_api.subcloud_db_model_to_dict(subcloud)
subcloud_dict['deploy-status'] = consts.DEPLOY_STATE_PRE_CONFIG
return subcloud_dict
except RemoteError as e:
pecan.abort(422, e.value)
except Exception:
LOG.exception("Unable to configure subcloud %s" % subcloud.name)
pecan.abort(500, _('Unable to configure subcloud'))
def _deploy_complete(self, context: RequestContext, subcloud):
# The deployment should be able to be completed when the deploy state
# is consts.DEPLOY_STATE_BOOTSTRAPPED because the user could have
# configured the subcloud manually
if subcloud.deploy_status != consts.DEPLOY_STATE_BOOTSTRAPPED:
pecan.abort(400, _('Subcloud deploy can only be completed when'
' its deploy status is: %s')
% consts.DEPLOY_STATE_BOOTSTRAPPED)
try:
# Ask dcmanager-manager to complete the subcloud deployment
subcloud = self.dcmanager_rpc_client.subcloud_deploy_complete(
context, subcloud.id)
return subcloud
except RemoteError as e:
pecan.abort(httpclient.UNPROCESSABLE_ENTITY, e.value)
except Exception:
LOG.exception("Unable to complete subcloud %s deployment" %
subcloud.name)
pecan.abort(httpclient.INTERNAL_SERVER_ERROR,
_('Unable to complete subcloud deployment'))
def _deploy_abort(self, context, subcloud):
if subcloud.deploy_status not in VALID_STATES_FOR_DEPLOY_ABORT:
allowed_states_str = ', '.join(VALID_STATES_FOR_DEPLOY_ABORT)
pecan.abort(400, _('Subcloud deploy status must be in one '
'of the following states: %s')
% allowed_states_str)
try:
self.dcmanager_rpc_client.subcloud_deploy_abort(
context, subcloud.id, subcloud.deploy_status)
subcloud_dict = db_api.subcloud_db_model_to_dict(subcloud)
subcloud_dict['deploy-status'] = \
utils.ABORT_UPDATE_STATUS[subcloud.deploy_status]
return subcloud_dict
except RemoteError as e:
pecan.abort(422, e.value)
except Exception:
LOG.exception("Unable to abort subcloud %s deployment" % subcloud.name)
pecan.abort(500, _('Unable to abort subcloud deploy'))
def _deploy_resume(self, context: RequestContext,
request: pecan.Request, subcloud):
if subcloud.deploy_status not in RESUMABLE_STATES:
allowed_states_str = ', '.join(RESUMABLE_STATES)
pecan.abort(400, _('Subcloud deploy status must be either: %s')
% allowed_states_str)
# Since both install and config are optional phases,
# it's necessary to check if they should be executed
config_file = psd_common.get_config_file_path(subcloud.name,
consts.DEPLOY_CONFIG)
has_original_install_values = subcloud.data_install
has_original_config_values = os.path.exists(config_file)
has_new_install_values = consts.INSTALL_VALUES in request.POST
has_new_config_values = consts.DEPLOY_CONFIG in request.POST
has_bootstrap_values = consts.BOOTSTRAP_VALUES in request.POST
has_config_values = has_original_config_values or has_new_config_values
has_install_values = has_original_install_values or has_new_install_values
deploy_states_to_run = RESUMABLE_STATES[subcloud.deploy_status]
if deploy_states_to_run == [CONFIG] and not has_config_values:
msg = _("Only deploy phase left is deploy config. "
"Required %s file was not provided and it was not "
"previously available.") % consts.DEPLOY_CONFIG
pecan.abort(400, msg)
# Since the subcloud can be installed manually and the config is optional,
# skip those phases if the user doesn't provide the install or config values
# and they are not available from previous executions.
files_for_resume = []
for state in deploy_states_to_run:
if state == INSTALL and not has_install_values:
deploy_states_to_run.remove(state)
elif state == CONFIG and not has_config_values:
deploy_states_to_run.remove(state)
else:
files_for_resume.extend(FILES_MAPPING[state])
payload = psd_common.get_request_data(request, subcloud, files_for_resume)
# Consider the incoming release parameter only if install is one
# of the pending deploy states
if INSTALL in deploy_states_to_run:
payload['software_version'] = payload.get('release', subcloud.software_version)
else:
payload['software_version'] = subcloud.software_version
# Need to remove bootstrap_values from the list of files to populate
# pre existing data so it does not overwrite newly loaded values
if has_bootstrap_values:
files_for_resume = [f for f in files_for_resume if f
not in FILES_MAPPING[BOOTSTRAP]]
psd_common.populate_payload_with_pre_existing_data(
payload, subcloud, files_for_resume)
psd_common.validate_sysadmin_password(payload)
for state in deploy_states_to_run:
if state == INSTALL:
psd_common.pre_deploy_install(payload, validate_password=False)
elif state == BOOTSTRAP:
psd_common.pre_deploy_bootstrap(context, payload, subcloud,
has_bootstrap_values,
validate_password=False)
elif state == CONFIG:
# Currently the only pre_deploy_config step is validate_sysadmin_password
# which can't be executed more than once
pass
try:
self.dcmanager_rpc_client.subcloud_deploy_resume(
context, subcloud.id, subcloud.name, payload, deploy_states_to_run)
subcloud_dict = db_api.subcloud_db_model_to_dict(subcloud)
next_deploy_phase = RESUMABLE_STATES[subcloud.deploy_status][0]
next_deploy_state = RESUME_PREP_UPDATE_STATUS[next_deploy_phase]
subcloud_dict['deploy-status'] = next_deploy_state
subcloud_dict['software-version'] = payload['software_version']
return subcloud_dict
except RemoteError as e:
pecan.abort(422, e.value)
except Exception:
LOG.exception("Unable to resume subcloud %s deployment" % subcloud.name)
pecan.abort(500, _('Unable to resume subcloud deployment'))
@pecan.expose(generic=True, template='json')
def index(self):
# Route the request to specific methods with parameters
pass
@utils.synchronized(LOCK_NAME)
@index.when(method='POST', template='json')
def post(self):
context = restcomm.extract_context_from_environ()
return self._deploy_create(context, pecan.request)
@utils.synchronized(LOCK_NAME)
@index.when(method='PATCH', template='json')
def patch(self, subcloud_ref=None, verb=None):
"""Modify the subcloud deployment.
:param subcloud_ref: ID or name of subcloud to update
:param verb: Specifies the patch action to be taken
or subcloud operation
"""
policy.authorize(phased_subcloud_deploy_policy.POLICY_ROOT % "modify", {},
restcomm.extract_credentials_for_policy())
context = restcomm.extract_context_from_environ()
if not subcloud_ref:
pecan.abort(400, _('Subcloud ID required'))
try:
if subcloud_ref.isdigit():
subcloud = db_api.subcloud_get(context, subcloud_ref)
else:
subcloud = db_api.subcloud_get_by_name(context, subcloud_ref)
except (exceptions.SubcloudNotFound, exceptions.SubcloudNameNotFound):
pecan.abort(404, _('Subcloud not found'))
if verb == ABORT:
subcloud = self._deploy_abort(context, subcloud)
elif verb == RESUME:
subcloud = self._deploy_resume(context, pecan.request, subcloud)
elif verb == INSTALL:
subcloud = self._deploy_install(context, pecan.request, subcloud)
elif verb == BOOTSTRAP:
subcloud = self._deploy_bootstrap(context, pecan.request, subcloud)
elif verb == CONFIG:
subcloud = self._deploy_config(context, pecan.request, subcloud)
elif verb == COMPLETE:
subcloud = self._deploy_complete(context, subcloud)
else:
pecan.abort(400, _('Invalid request'))
return subcloud