# Copyright 2016 Red Hat, 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. import json import logging import os import shutil import tempfile import yaml from heatclient import exc as heatexceptions from mistral.workflow import utils as mistral_workflow_utils from mistralclient.api import base as mistralclient_base from oslo_concurrency import processutils import six from swiftclient import exceptions as swiftexceptions from tripleo_common.actions import base from tripleo_common import constants from tripleo_common import exception from tripleo_common.utils import swift as swiftutils from tripleo_common.utils import tarball from tripleo_common.utils.validations import pattern_validator LOG = logging.getLogger(__name__) default_container_headers = { constants.TRIPLEO_META_USAGE_KEY: 'plan' } class PlanEnvMixin(object): @staticmethod def get_plan_env_dict(swift, container): """Retrieves the plan environment from Swift. Loads a plan environment file with a given container name from Swift. Makes sure that the file contains valid YAML and that the mandatory fields are present in the environment. If the plan environment file is missing from Swift, fall back to the capabilities-map.yaml. Returns the plan environment dictionary, and a boolean indicator whether the plan environment file was missing from Swift. """ plan_env_missing = False try: plan_env = swift.get_object(container, constants.PLAN_ENVIRONMENT)[1] except swiftexceptions.ClientException: # If the plan environment file is missing from Swift, look for # capabilities-map.yaml instead plan_env_missing = True try: plan_env = swift.get_object(container, 'capabilities-map.yaml')[1] except swiftexceptions.ClientException as err: raise exception.PlanOperationError( "File missing from container: %s" % err) try: plan_env_dict = yaml.safe_load(plan_env) except yaml.YAMLError as err: raise exception.PlanOperationError( "Error parsing the yaml file: %s" % err) if plan_env_missing: plan_env_dict = { 'environments': [{'path': plan_env_dict['root_environment']}], 'template': plan_env_dict['root_template'], 'version': 1.0 } for key in ('environments', 'template', 'version'): if key not in plan_env_dict: raise exception.PlanOperationError( "%s missing key: %s" % (constants.PLAN_ENVIRONMENT, key)) return plan_env_dict, plan_env_missing class CreateContainerAction(base.TripleOAction): """Creates an object container This action creates an object container for a given name. If a container with the same name already exists an exception is raised. """ def __init__(self, container): super(CreateContainerAction, self).__init__() self.container = container def run(self, context): oc = self.get_object_client(context) # checks to see if a container has a valid name if not pattern_validator(constants.PLAN_NAME_PATTERN, self.container): message = ("Unable to create plan. The plan name must " "only contain letters, numbers or dashes") return mistral_workflow_utils.Result(error=message) # checks to see if a container with that name exists if self.container in [container["name"] for container in oc.get_account()[1]]: result_string = ("A container with the name %s already" " exists.") % self.container return mistral_workflow_utils.Result(error=result_string) oc.put_container(self.container, headers=default_container_headers) class CreatePlanAction(base.TripleOAction, PlanEnvMixin): """Creates a plan Given a container, creates a Mistral environment with the same name. The contents of the environment are imported from the plan environment file, which must contain entries for `template`, `environments` and `version` at a minimum. """ def __init__(self, container): super(CreatePlanAction, self).__init__() self.container = container def run(self, context): swift = self.get_object_client(context) mistral = self.get_workflow_client(context) env_data = { 'name': self.container, } if not pattern_validator(constants.PLAN_NAME_PATTERN, self.container): message = ("Unable to create plan. The plan name must " "only contain letters, numbers or dashes") return mistral_workflow_utils.Result(error=message) # Check to see if an environment with that name already exists try: mistral.environments.get(self.container) except mistralclient_base.APIException: # The environment doesn't exist, as expected. Proceed. pass else: message = ("Unable to create plan. The Mistral environment " "already exists") return mistral_workflow_utils.Result(error=message) # Get plan environment from Swift try: plan_env_dict, plan_env_missing = self.get_plan_env_dict( swift, self.container) except exception.PlanOperationError as err: return mistral_workflow_utils.Result(error=six.text_type(err)) # Create mistral environment env_data['variables'] = json.dumps(plan_env_dict, sort_keys=True) try: mistral.environments.create(**env_data) except Exception as err: message = "Error occurred creating plan: %s" % err return mistral_workflow_utils.Result(error=message) # Delete the plan environment file from Swift, as it is no long needed. # (If we were to leave the environment file behind, we would have to # take care to keep it in sync with the actual contents of the Mistral # environment. To avoid that, we simply delete it.) # TODO(akrivoka): Once the 'Deployment plan management changes' spec # (https://review.openstack.org/#/c/438918/) is implemented, we will no # longer use Mistral environments for holding the plan data, so this # code can go away. if not plan_env_missing: try: swift.delete_object(self.container, constants.PLAN_ENVIRONMENT) except swiftexceptions.ClientException as err: message = "Error deleting file from container: %s" % err return mistral_workflow_utils.Result(error=message) class UpdatePlanAction(base.TripleOAction, PlanEnvMixin): """Updates a plan Given a container, update the Mistral environment with the same name. The contents of the environment are imported (overwritten) from the plan environment file, which must contain entries for `template`, `environments` and `version` at a minimum. """ def __init__(self, container): super(UpdatePlanAction, self).__init__() self.container = container def run(self, context): swift = self.get_object_client(context) mistral = self.get_workflow_client(context) # Get plan environment from Swift try: plan_env_dict, plan_env_missing = self.get_plan_env_dict( swift, self.container) except exception.PlanOperationError as err: return mistral_workflow_utils.Result(error=six.text_type(err)) # Update mistral environment with contents from plan environment file variables = json.dumps(plan_env_dict, sort_keys=True) self.cache_delete(context, self.container, "tripleo.parameters.get") try: mistral.environments.update( name=self.container, variables=variables) except mistralclient_base.APIException: message = "Error updating mistral environment: %s" % self.container return mistral_workflow_utils.Result(error=message) # Delete the plan environment file from Swift, as it is no long needed. # (If we were to leave the environment file behind, we would have to # take care to keep it in sync with the actual contents of the Mistral # environment. To avoid that, we simply delete it.) # TODO(akrivoka): Once the 'Deployment plan management changes' spec # (https://review.openstack.org/#/c/438918/) is implemented, we will no # longer use Mistral environments for holding the plan data, so this # code can go away. if not plan_env_missing: try: swift.delete_object(self.container, constants.PLAN_ENVIRONMENT) except swiftexceptions.ClientException as err: message = "Error deleting file from container: %s" % err return mistral_workflow_utils.Result(error=message) class ListPlansAction(base.TripleOAction): """Lists deployment plans This action lists all deployment plans residing in the undercloud. A deployment plan consists of a container marked with metadata 'x-container-meta-usage-tripleo' and a mistral environment with the same name as the container. """ def run(self, context): # plans consist of a container object and mistral environment # with the same name. The container is marked with metadata # to ensure it isn't confused with another container plan_list = [] oc = self.get_object_client(context) mc = self.get_workflow_client(context) for item in oc.get_account()[1]: container = oc.get_container(item['name'])[0] if constants.TRIPLEO_META_USAGE_KEY in container.keys(): plan_list.append(item['name']) return list(set(plan_list).intersection( [env.name for env in mc.environments.list()])) class DeletePlanAction(base.TripleOAction): """Deletes a plan and associated files Deletes a plan by deleting the container matching plan_name. It will not delete the plan if a stack exists with the same name. Raises StackInUseError if a stack with the same name as plan_name exists. """ def __init__(self, container): super(DeletePlanAction, self).__init__() self.container = container def run(self, context): error_text = None # heat throws HTTPNotFound if the stack is not found try: stack = self.get_orchestration_client(context).stacks.get( self.container ) except heatexceptions.HTTPNotFound: pass else: if stack is not None: raise exception.StackInUseError(name=self.container) try: swift = self.get_object_client(context) swiftutils.delete_container(swift, self.container) # if mistral environment exists, delete it too mistral = self.get_workflow_client(context) if self.container in [env.name for env in mistral.environments.list()]: # deletes environment mistral.environments.delete(self.container) except swiftexceptions.ClientException as ce: LOG.exception("Swift error deleting plan.") error_text = ce.msg except Exception as err: LOG.exception("Error deleting plan.") error_text = six.text_type(err) if error_text: return mistral_workflow_utils.Result(error=error_text) class ListRolesAction(base.TripleOAction): """Returns a deployment plan's roles Parses overcloud.yaml and returns the Heat resources where type = OS::Heat::ResourceGroup :param container: name of the Swift container / plan name :return: list of roles in the container's deployment plan """ def __init__(self, container=constants.DEFAULT_CONTAINER_NAME): super(ListRolesAction, self).__init__() self.container = container def run(self, context): try: mc = self.get_workflow_client(context) mistral_env = mc.environments.get(self.container) template_name = mistral_env.variables['template'] oc = self.get_object_client(context) resources = yaml.safe_load( oc.get_object(self.container, template_name)[1])['resources'] except Exception as mistral_err: err_msg = ("Error retrieving deployment plan: %s" % mistral_err) LOG.exception(err_msg) return mistral_workflow_utils.Result(error=err_msg) roles = [] for resource, details in resources.items(): if details['type'] == constants.RESOURCE_GROUP_TYPE: roles.append(resource) return roles class ExportPlanAction(base.TripleOAction): """Exports a deployment plan This action exports a deployment plan with a given name. First, the plan templates are downloaded from the Swift container. Then the plan environment file is generated from the associated Mistral environment. Finally, both the templates and the plan environment file are packaged up in a tarball and uploaded to Swift. """ def __init__(self, plan, delete_after, exports_container): super(ExportPlanAction, self).__init__() self.plan = plan self.delete_after = delete_after self.exports_container = exports_container def _download_templates(self, swift, tmp_dir): """Download templates to a temp folder.""" template_files = swift.get_container(self.plan)[1] for tf in template_files: filename = tf['name'] contents = swift.get_object(self.plan, filename)[1] path = os.path.join(tmp_dir, filename) dirname = os.path.dirname(path) if not os.path.exists(dirname): os.makedirs(dirname) with open(path, 'w') as f: f.write(contents) def _generate_plan_env_file(self, mistral, tmp_dir): """Generate plan environment file and add it to specified folder.""" environment = mistral.environments.get(self.plan).variables yaml_string = yaml.safe_dump(environment, default_flow_style=False) path = os.path.join(tmp_dir, constants.PLAN_ENVIRONMENT) with open(path, 'w') as f: f.write(yaml_string) def _create_and_upload_tarball(self, swift, tmp_dir): """Create a tarball containing the tmp_dir and upload it to Swift.""" tarball_name = '%s.tar.gz' % self.plan headers = {'X-Delete-After': self.delete_after} # make sure the root container which holds all plan exports exists try: swift.get_container(self.exports_container) except swiftexceptions.ClientException: swift.put_container(self.exports_container) with tempfile.NamedTemporaryFile() as tmp_tarball: tarball.create_tarball(tmp_dir, tmp_tarball.name) swift.put_object(self.exports_container, tarball_name, tmp_tarball, headers=headers) def run(self, context): swift = self.get_object_client(context) mistral = self.get_workflow_client(context) tmp_dir = tempfile.mkdtemp() try: self._download_templates(swift, tmp_dir) self._generate_plan_env_file(mistral, tmp_dir) self._create_and_upload_tarball(swift, tmp_dir) except swiftexceptions.ClientException as err: msg = "Error attempting an operation on container: %s" % err return mistral_workflow_utils.Result(error=msg) except mistralclient_base.APIException: msg = ("The Mistral environment %s could not be found." % self.plan) return mistral_workflow_utils.Result(error=msg) except (OSError, IOError) as err: msg = "Error while writing file: %s" % err return mistral_workflow_utils.Result(error=msg) except processutils.ProcessExecutionError as err: msg = "Error while creating a tarball: %s" % err return mistral_workflow_utils.Result(error=msg) except Exception as err: msg = "Error exporting plan: %s" % err return mistral_workflow_utils.Result(error=msg) finally: shutil.rmtree(tmp_dir)