From 611b8b1043ddc2116e3f3f0de1875936936c25e2 Mon Sep 17 00:00:00 2001 From: Aldinson Esto Date: Fri, 25 Feb 2022 08:40:15 +0000 Subject: [PATCH] Add a Sample Ansible Driver Added a sample Ansible Driver as an option for users who want to use ansible for configuration of VNFs. In this Sample Ansible Driver, the following key LCMs are supported: - instantiate_end - scale_start - scale_end - heal_end - terminate_start Implements: blueprint add-ansible-mgmt-driver-sample Spec: https://review.opendev.org/c/openstack/tacker-specs/+/814689 Change-Id: I539f1ab5442196865155f12fe0b2b4106feedeae --- ...ample-ansible-driver-f204be6350c8a546.yaml | 13 + samples/mgmt_driver/ansible/__init__.py | 0 samples/mgmt_driver/ansible/ansible.py | 144 ++++++ .../ansible/ansible_config_parser.py | 468 ++++++++++++++++++ .../ansible/ansible_config_parser_cfg.py | 40 ++ samples/mgmt_driver/ansible/ansible_driver.py | 290 +++++++++++ .../ansible/config_actions/__init__.py | 0 .../ansible/config_actions/abstract_config.py | 4 + .../config_actions/vm_app_config/__init__.py | 0 .../vm_app_config/ansible_playbook_exec.py | 160 ++++++ .../vm_app_config/config_walker.py | 45 ++ .../config_actions/vm_app_config/executor.py | 361 ++++++++++++++ .../vm_app_config/vm_app_config.py | 143 ++++++ .../mgmt_driver/ansible/config_validator.py | 60 +++ .../ansible/config_validator_schema.py | 135 +++++ samples/mgmt_driver/ansible/event_handler.py | 25 + samples/mgmt_driver/ansible/exceptions.py | 144 ++++++ samples/mgmt_driver/ansible/heat_client.py | 56 +++ samples/mgmt_driver/ansible/utils.py | 47 ++ 19 files changed, 2135 insertions(+) create mode 100644 releasenotes/notes/add-sample-ansible-driver-f204be6350c8a546.yaml create mode 100644 samples/mgmt_driver/ansible/__init__.py create mode 100644 samples/mgmt_driver/ansible/ansible.py create mode 100644 samples/mgmt_driver/ansible/ansible_config_parser.py create mode 100644 samples/mgmt_driver/ansible/ansible_config_parser_cfg.py create mode 100644 samples/mgmt_driver/ansible/ansible_driver.py create mode 100644 samples/mgmt_driver/ansible/config_actions/__init__.py create mode 100644 samples/mgmt_driver/ansible/config_actions/abstract_config.py create mode 100644 samples/mgmt_driver/ansible/config_actions/vm_app_config/__init__.py create mode 100644 samples/mgmt_driver/ansible/config_actions/vm_app_config/ansible_playbook_exec.py create mode 100644 samples/mgmt_driver/ansible/config_actions/vm_app_config/config_walker.py create mode 100644 samples/mgmt_driver/ansible/config_actions/vm_app_config/executor.py create mode 100644 samples/mgmt_driver/ansible/config_actions/vm_app_config/vm_app_config.py create mode 100644 samples/mgmt_driver/ansible/config_validator.py create mode 100644 samples/mgmt_driver/ansible/config_validator_schema.py create mode 100644 samples/mgmt_driver/ansible/event_handler.py create mode 100644 samples/mgmt_driver/ansible/exceptions.py create mode 100644 samples/mgmt_driver/ansible/heat_client.py create mode 100644 samples/mgmt_driver/ansible/utils.py diff --git a/releasenotes/notes/add-sample-ansible-driver-f204be6350c8a546.yaml b/releasenotes/notes/add-sample-ansible-driver-f204be6350c8a546.yaml new file mode 100644 index 000000000..fa6bd86ca --- /dev/null +++ b/releasenotes/notes/add-sample-ansible-driver-f204be6350c8a546.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + Add a Sample Ansible Driver as an option for users who want to use ansible + for configuration of VNFs. This Ansible Driver supports the key LCMs such as + instantiate_end, scale_start, scale_end, heal_end and terminate_start. + A Sample VNF package which contains sample usage of Ansible Driver is + provided. User manual is also provided to explain the steps in preparing the + environment to use Ansible Driver. +issues: + - | + Regarding Sample Ansible Driver, currently, deployment flavors share only + one config.yaml due to a limitation in Management Driver. diff --git a/samples/mgmt_driver/ansible/__init__.py b/samples/mgmt_driver/ansible/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/samples/mgmt_driver/ansible/ansible.py b/samples/mgmt_driver/ansible/ansible.py new file mode 100644 index 000000000..e0feb4fce --- /dev/null +++ b/samples/mgmt_driver/ansible/ansible.py @@ -0,0 +1,144 @@ +# 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. + +from oslo_log import log as logging +from oslo_utils import encodeutils +from tacker.common import log +from tacker.vnfm.mgmt_drivers import constants as mgmt_constants +from tacker.vnfm.mgmt_drivers import vnflcm_abstract_driver + +from tacker.vnfm.mgmt_drivers.ansible import ansible_driver + +LOG = logging.getLogger(__name__) + + +def get_class(): + '''Returns the class name of the action driver''' + return 'DeviceMgmtAnsible' + + +class DeviceMgmtAnsible(vnflcm_abstract_driver.VnflcmMgmtAbstractDriver): + def get_type(self): + return "ansible_driver" + + def get_name(self): + return "ansible_driver" + + def get_description(self): + return "Tacker VNFMgmt Ansible Driver" + + def instantiate_start(self, context, vnf_instance, + instantiate_vnf_request, grant, + grant_request, **kwargs): + pass + + @log.log + def instantiate_end(self, context, vnf_instance, + instantiate_vnf_request, grant, + grant_request, **kwargs): + try: + LOG.debug("Start of Ansible Driver") + driver = ansible_driver.AnsibleDriver() + driver._driver_process_flow(context, vnf_instance, + mgmt_constants.ACTION_INSTANTIATE_VNF, + instantiate_vnf_request, **kwargs) + except Exception as e: + raise Exception("Ansible Driver Error: %s", + encodeutils.exception_to_unicode(e)) + + @log.log + def terminate_start(self, context, vnf_instance, + terminate_vnf_request, grant, + grant_request, **kwargs): + try: + LOG.debug("Start of Ansible Driver") + driver = ansible_driver.AnsibleDriver() + driver._driver_process_flow(context, vnf_instance, + mgmt_constants.ACTION_TERMINATE_VNF, + terminate_vnf_request, **kwargs) + except Exception as e: + raise Exception("Ansible Driver Error: %s", + encodeutils.exception_to_unicode(e)) + + def terminate_end(self, context, vnf_instance, + terminate_vnf_request, grant, + grant_request, **kwargs): + pass + + @log.log + def scale_start(self, context, vnf_instance, + scale_vnf_request, grant, + grant_request, **kwargs): + try: + LOG.debug("Start of Ansible Driver") + driver = ansible_driver.AnsibleDriver() + driver._driver_process_flow(context, vnf_instance, + mgmt_constants.ACTION_SCALE_IN_VNF, + scale_vnf_request, **kwargs) + except Exception as e: + raise Exception("Ansible Driver Error: %s", + encodeutils.exception_to_unicode(e)) + + @log.log + def scale_end(self, context, vnf_instance, + scale_vnf_request, grant, + grant_request, **kwargs): + try: + LOG.debug("Start of Ansible Driver") + driver = ansible_driver.AnsibleDriver() + driver._driver_process_flow(context, vnf_instance, + mgmt_constants.ACTION_SCALE_OUT_VNF, + scale_vnf_request, **kwargs) + except Exception as e: + raise Exception("Ansible Driver Error: %s", + encodeutils.exception_to_unicode(e)) + + def heal_start(self, context, vnf_instance, + heal_vnf_request, grant, + grant_request, **kwargs): + pass + + @log.log + def heal_end(self, context, vnf_instance, + heal_vnf_request, grant, + grant_request, **kwargs): + try: + LOG.debug("Start of Ansible Driver") + driver = ansible_driver.AnsibleDriver() + driver._driver_process_flow(context, vnf_instance, + mgmt_constants.ACTION_HEAL_VNF, + heal_vnf_request, **kwargs) + except Exception as e: + raise Exception("Ansible Driver Error: %s", + encodeutils.exception_to_unicode(e)) + + def change_external_connectivity_start( + self, context, vnf_instance, + change_ext_conn_request, grant, + grant_request, **kwargs): + pass + + def change_external_connectivity_end( + self, context, vnf_instance, + change_ext_conn_request, grant, + grant_request, **kwargs): + pass + + def modify_information_start( + self, context, vnf_instance, + modify_vnf_request, **kwargs): + pass + + def modify_information_end( + self, context, vnf_instance, + modify_vnf_request, **kwargs): + pass diff --git a/samples/mgmt_driver/ansible/ansible_config_parser.py b/samples/mgmt_driver/ansible/ansible_config_parser.py new file mode 100644 index 000000000..35b35836d --- /dev/null +++ b/samples/mgmt_driver/ansible/ansible_config_parser.py @@ -0,0 +1,468 @@ +# 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 ast +import datetime +import re +import six +import yaml + +from heatclient import client as hclient +from keystoneauth1 import loading +from keystoneauth1 import session +from oslo_log import log as logging + +from tacker.vnfm.mgmt_drivers.ansible import event_handler +from tacker.vnfm.mgmt_drivers.ansible import exceptions + +from tacker.vnfm.mgmt_drivers.ansible.ansible_config_parser_cfg\ + import CONFIG_PARSER_MAP + +LOG = logging.getLogger(__name__) +EVENT_HANDLER = event_handler.AnsibleEventHandler() + +RE_PATTERN = r"(_VAR_[a-zA-Z_.0-9-<>]+)" +STND_MSG = "Ansible Config Parser: {}" + + +class ConfigParser(): + + def __init__(self): + self._vim_id = "" + self._auth_url = "" + self._username = "" + self._password = "" + self._project_name = "" + self._project_domain_name = "" + self._user_domain_name = "" + self._sess = "" + self._handle_map = {} + self._vnf = {} + self._config_yaml = {} + self._current_target_ip = "" + self._current_target_vdu = "" + self._failed_vdu_name = "" + self._context = {} + + def configure(self, context, vnf, plugin, config_yaml, stack_map): + LOG.info(STND_MSG.format("Configuring parser")) + try: + # load context + self._context = context + + # load configurable_properties + self._config_yaml = config_yaml + + # get vim info + access_info = plugin.get_vim(context, vnf) + + # get vim details + vim_auth = access_info["vim_auth"] + self._auth_url = vim_auth["auth_url"] + self._username = vim_auth["username"] + self._password = vim_auth["password"] + self._project_name = vim_auth["project_name"] + self._project_domain_name = vim_auth["project_domain_name"] + self._user_domain_name = vim_auth["user_domain_name"] + + # changed password type bytes -> string + if isinstance(self._password, bytes): + self._password = self._password.decode('utf-8') + + # append failed_vdu_name,failed_vdu_instance_ip to vnf_dict + failed_vdu_name = vnf.get('failed_vdu_name', "") + + # make sure vnf['mgmt_ip_address'] is of type string + vnf_mgmt_ip_address = vnf['mgmt_ip_address'] + + # make sure vnf['mgmt_ip_address'] is of type string + if isinstance(vnf_mgmt_ip_address, bytes): + vnf['mgmt_ip_address'] = vnf_mgmt_ip_address.decode('utf-8') + + # if vnf has scaling policy, + # get failed vdu instance ip from heat stack_map + # else the vnf has no scaling policy, + # directly get mgmt_ip address from vnf_dict + if stack_map: + vnf['failed_vdu_instance_ip'] = stack_map.get(failed_vdu_name, + [""])[0] if failed_vdu_name else "" + else: + if vnf_mgmt_ip_address: + failed_vdu_mgmt_ip_list = ast.literal_eval( + vnf['mgmt_ip_address']).get(failed_vdu_name, "") + if isinstance(failed_vdu_mgmt_ip_list, list): + vnf['failed_vdu_instance_ip'] = \ + (failed_vdu_mgmt_ip_list[0] + if failed_vdu_name else "") + else: + vnf['failed_vdu_instance_ip'] = \ + (failed_vdu_mgmt_ip_list + if failed_vdu_name else "") + + # load vnf dict + self._vnf = vnf + + LOG.debug("Auth: {} {} {} {} {} {}".format( + self._auth_url, self._username, self._password, + self._project_name, self._project_domain_name, + self._user_domain_name)) + + # validate config file + config = CONFIG_PARSER_MAP + if not config and not type(config): + raise exceptions.InternalErrorException( + details="Configuration file is not valid.") + + for section, options in config.items(): + create_function = getattr(self, "_handle_{}".format(section)) + self._handle_map[section] = ConfigHandle( + value_map=options, create_function=create_function) + LOG.info(STND_MSG.format("Parser configured")) + + except exceptions.AnsibleDriverException: + raise + except Exception as ex: + raise exceptions.ConfigParserConfigurationError( + ex_type=type(ex), details=ex) + + def substitute(self, command, **kwargs): + try: + int_flag = False + list_flag = False + dict_flag = False + + if isinstance(command, int): + int_flag = True + elif isinstance(command, list): + list_flag = True + elif isinstance(command, dict): + dict_flag = True + elif not isinstance(command, six.string_types): + raise Exception( + "Value '{}' of type '{}' is not yet supported " + " for parameter substitution." + .format(command, type(command))) + txt = str(command) + target_dict = {} + + # check for variables fetched @ runtime + self._current_target_ip = kwargs.get("mgmt_ip_address", "") + self._current_target_vdu = kwargs.get("vdu", "") + + # get equivalent value for each _VAR_XXX + res = re.findall(RE_PATTERN, txt) + + # remove duplicates and sorted the _VAR_XXX in + # descending order based on the string length + res = list(dict.fromkeys(res)) + res.sort(key=len, reverse=True) + + for key in res: + target_dict[key] = self._get_value(key) + + # substitute text with fetch value + for key, val in target_dict.items(): + txt = re.sub(key, val, txt) + + if int_flag: + txt = int(txt) + if list_flag or dict_flag: + txt = eval(txt) + return txt + + except Exception as ex: + raise exceptions.ConfigParserParsingError( + cmd=command, ex_type=type(ex), details=ex) + + def _get_value(self, key, **kwargs): + val = "" + + # Get Resource Type, and varible name + raw_var = key.rsplit("_VAR_", 1) + if len(raw_var) != 2: + raise Exception("{} is not valid".format(key)) + + # handle key with no attributes, default handle to 'default' + if raw_var[1].find(".") == -1: + handle_name = 'default' + var = raw_var[1] + + if var == 'VDU_INSTANCE_IP': + return self._get_vdu_instance_ip() + + if var == 'VDU_INSTANCE_NAME': + return self._get_vdu_instance_name() + + else: + handle_name, var = raw_var[1].split(".", 1) + + # get Handle based on resource type, and get value based on parameter + # Return Handle Execution Result + try: + val = self._handle_map[handle_name].get_value(var) + except KeyError: + LOG.error("Cannot get value for {}".format(var)) + raise + + return val + + def _get_vdu_instance_ip(self): + if not self._current_target_ip: + LOG.error("Cannot get value for {}".format('VDU_INSTANCE_IP')) + raise KeyError('VDU_INSTANCE_IP') + return self._current_target_ip + + def _get_vdu_instance_name(self): + if not self._current_target_vdu: + LOG.error("Cannot get value for {}".format('VDU_INSTANCE_NAME')) + raise KeyError('VDU_INSTANCE_NAME') + return self._current_target_vdu + + # The following defines handle create functions, parameter + # -> value_map, return value_dict + def _handle_vnf_resource(self, value_map=None): + value_dict = {} + source = yaml.safe_load(self._vnf['attributes']['heat_template']) + + if not source: + raise ValueError("Data source is not valid!") + for option, values in value_map.items(): + value_dict[option] = self._generate_data(source, values) + + return value_dict + + def _handle_vnf(self, value_map=None): + source = self._vnf + value_dict = {} + + if not source: + raise ValueError("Data source is not valid!") + # if no specific value map, load everything + if not value_map: + for option, values in source.items(): + value_dict[option] = self._generate_data(source, option) + return value_dict + + for roption, values in value_map.items(): + # check if item is optional,'*' + if len(roption.split('*', 1)) == 2: + option = roption.split('*', 1)[1] + required = False + else: + option = roption + required = True + + value_dict[option] = self._generate_data(source, values, required) + + return value_dict + + def _handle_resource(self, value_map=None): + # get necessary details to create resource tree + value_dict = {} + vnf_instance_id = self._vnf['instance_id'] + loader = loading.get_plugin_loader('password') + auth = loader.load_from_options( + auth_url=self._auth_url, + username=self._username, + password=self._password, + project_name=self._project_name, + user_domain_name=self._user_domain_name, + project_domain_name=self._project_domain_name) + sess = session.Session(auth=auth) + heat = hclient.Client('1', session=sess) + + # if no scaling group defined + if not self._vnf['attributes'].get('scaling_group_names'): + # get stack id + target_stack = next( + heat.stacks.list(filters={'id': vnf_instance_id})) + if not target_stack: + raise ValueError("Internal Error: Target Stack not found!") + # create resource tree from resource names + resources = yaml.safe_load( + self._vnf['attributes']['heat_template'])['resources'] + if not resources: + raise ValueError("Internal Error: Resources not fetched!") + for resource in resources.keys(): + resource_info = \ + heat.resources.get(target_stack.id, resource).to_dict() + value_dict[resource] = resource_info['attributes'] + + return value_dict + + def _handle_default(self, value_map=None): + value_dict = {} + configurable_properties = \ + self._config_yaml.get("configurable_properties", {}) + + if not configurable_properties: + return value_dict + + for option_raw, value in configurable_properties.items(): + # len(re.findall(RE_PATTERN,option_raw)) > 0 + option = option_raw.rsplit("_VAR_", 1) + if len(option) != 2: + msg = \ + "Config file validation error. {} is not valid "\ + "configurable_properties key".format(option_raw) + LOG.error(msg) + raise ValueError(msg) + + value_dict[option[1]] = value + + return value_dict + + def _generate_data(self, source, value, required=True): + # get filters, "... where key=value" + if len(value.rsplit(" where ", 1)) == 2: + raw_attributes, raw_filter = value.rsplit(" where ", 1) + else: + raw_filter = "" + raw_attributes = value.strip() + + # get qualifiers, "(key/val) in ..." + if len(raw_attributes.split(" in ", 1)) == 2: + attribute_option, attributes = raw_attributes.split(" in ", 1) + else: + # default None, return everything + attribute_option = "" + attributes = raw_attributes.strip() + + builder = "{}".format('source') + + for attribute in attributes.strip().split('.'): + builder = builder + "['{}']".format(attribute) + + # evalute string as python expression + try: + items = eval(builder) + except KeyError: + if required: + raise KeyError("{} not found".format(builder)) + else: + return "" + + # create filter funciton + def filter_func(target_item): + if raw_filter and len(raw_filter.strip().split('=')) == 2: + filter_key, filter_val = raw_filter.strip().split('=') + if target_item[1][filter_key] == filter_val: + return True + else: + return False + else: + return True + + # apply filters + if isinstance(items, dict): + raw_result = dict(filter(filter_func, items.items())) + elif isinstance(items, six.string_types): + # check if instance is string dict, else return as string, + # since item is a single value + try: + raw_result = dict( + filter(filter_func, ast.literal_eval(items).items())) + except Exception as ex: + LOG.warning( + 'Defaulting item to \'{}\' since cannot be evaluated. "\ + "Error: {}'.format(items, ex)) + return items + elif isinstance(items, (datetime.datetime, type(None))): + return items + else: + raise Exception( + "Source items data struct of type {} is not supported." + .format(type(items))) + # apply qualifiers + result = [] + + # apply qualifier if present and return result as list + for raw_res_key, raw_res_val in raw_result.items(): + if attribute_option.strip() == 'key': + result.append(raw_res_key) + elif attribute_option.strip() == 'val': + result.append(raw_res_val) + + # else default, return all as dict + if not attribute_option.strip(): + result = {} + for raw_res_key, raw_res_val in raw_result.items(): + result[raw_res_key] = raw_res_val + + return result + + +class ConfigHandle(): + + def __init__(self, value_map, create_function=None): + """parameter + + value_map: contains hash map for finding value + value_dict: contains the fetch value based on value_map + create_function: + ld the function to generate value_dict, parameter is value_map + """ + + self._value_map = value_map + self._value_dict = {} + self._create_function = create_function + + # Populate Value Dict + if not create_function: + raise ValueError("Config Handle: Create function is Null!") + self._value_dict = self._create_function(self._value_map) + + LOG.debug("Value Dict: {}".format(self._value_dict)) + + def get_value(self, key): + # this function returns the string equivalent of the data requested + + # handle exec + if key in self._value_dict.keys(): + if isinstance(self._value_dict[key], str) and\ + self._value_dict[key].find(" exec ") != -1: + custom_func = \ + self._value_dict[key].rsplit(" exec ", 1)[1].strip() + # execute custom function + try: + res = getattr(self, "_custom_{}".format(custom_func))() + except Exception as ex: + LOG.exception(ex) + raise Exception( + "Config Handle: error encountered on " + "executing custom function {}".format(custom_func)) + return res + + # for normal operation + res = "" + builder = "{}".format('self._value_dict') + for attribute in key.strip().split('.'): + builder = builder + "['{}']".format(attribute) + + items = eval(builder) + + # if item is a type of dict, tuple, or list, seriliaze values + # into space delimeted string + # else if string, return as is + if isinstance(items, (dict, list, tuple)): + for item in items: + if not res: + res = res + "{}".format(item) + else: + res = res + ",{}".format(item) + elif isinstance(items, six.string_types): + res = items + else: + raise Exception( + "Result items of type {} data struct is not supported." + .format(type(items))) + return res diff --git a/samples/mgmt_driver/ansible/ansible_config_parser_cfg.py b/samples/mgmt_driver/ansible/ansible_config_parser_cfg.py new file mode 100644 index 000000000..494582170 --- /dev/null +++ b/samples/mgmt_driver/ansible/ansible_config_parser_cfg.py @@ -0,0 +1,40 @@ +# 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. + +CONFIG_PARSER_MAP = { + 'vnf': { + # Data source -> vnf_dict + # '*' Denotes optional parameters + # 'Comment all entries to load everything from vnf_dict + 'mgmt_ip_address': 'mgmt_ip_address', + '*failed_vdu_name': 'failed_vdu_name', + '*failed_vdu_instance_ip': 'failed_vdu_instance_ip', + '*vduname': 'vdu_name' + }, + 'vnf_resource': { + # Data source -> yaml.safe_load( + # vnf_dict['attributes']['heat_template']) + 'RESOURCE_LIST': 'key in resources', + 'VDUNAME_LIST': 'key in resources where type=OS::Nova::Server', + 'CPNAME_LIST': 'key in resources where type=OS::Neutron::Port', + 'ALARMNAME_LIST': 'key in resources where type=OS::Aodh::EventAlarm', + 'VBNAME_LIST': 'key in resources where type=OS::Cinder::Volume', + 'CBNAME_LIST': 'key in resources where ' + 'type=OS::Cinder::VolumeAttachment', + }, + 'resource': {}, + # Data source -> heat.resources.get( + # target_stack.id,).to_dict() + # Loads all resources + 'default': {} + # Default Data source -> config_yaml.get("configurable_properties", {}) +} diff --git a/samples/mgmt_driver/ansible/ansible_driver.py b/samples/mgmt_driver/ansible/ansible_driver.py new file mode 100644 index 000000000..fa6190c11 --- /dev/null +++ b/samples/mgmt_driver/ansible/ansible_driver.py @@ -0,0 +1,290 @@ +# 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 os +import yaml + +from oslo_log import log as logging +from oslo_serialization import jsonutils + +from tacker.vnfm.mgmt_drivers.ansible import ansible_config_parser +from tacker.vnfm.mgmt_drivers.ansible import config_validator +from tacker.vnfm.mgmt_drivers.ansible import event_handler +from tacker.vnfm.mgmt_drivers.ansible import exceptions +from tacker.vnfm.mgmt_drivers.ansible import heat_client +from tacker.vnfm.mgmt_drivers.ansible import utils + +from tacker.vnfm.mgmt_drivers.ansible.config_actions.\ + vm_app_config import vm_app_config + +from tacker.vnflcm import utils as vnflcm_utils +from tacker.vnfm.mgmt_drivers import constants as mgmt_constants +from tacker.vnfm import plugin + +LOG = logging.getLogger(__name__) +EVENT_HANDLER = event_handler.AnsibleEventHandler() +SUPPORTED_ACTIONS = [ + mgmt_constants.ACTION_INSTANTIATE_VNF, + mgmt_constants.ACTION_TERMINATE_VNF, + mgmt_constants.ACTION_HEAL_VNF, + mgmt_constants.ACTION_UPDATE_VNF, + mgmt_constants.ACTION_SCALE_IN_VNF, + mgmt_constants.ACTION_SCALE_OUT_VNF, +] + + +class AnsibleDriver(object): + + def __init__(self): + self._config_queue = {} + self._has_error = False + self._cfg_parser = ansible_config_parser.ConfigParser() + self._cfg_validator = config_validator.AnsibleConfigValidator() + + self._config_actions = {} + self._config_actions["vm_app_config"] = \ + vm_app_config.VmAppConfigAction() + + self._vnf = None + self._plugin = plugin.VNFMPlugin() + self._vnf_instance = None + self._context = None + + LOG.debug("Ansible Driver initialized successfully!") + + def get_type(self): + """Mgmt driver for ansible""" + pass + + def get_name(self): + """Ansible Mgmt Driver""" + pass + + def get_description(self): + pass + + def _driver_process_flow(self, context, vnf_instance, action, + request_obj, **kwargs): + # set global, prevent passing for every function call + self._vnf = kwargs['vnf'] + self._vnf_instance = vnf_instance + self._context = context + + start_msg = ("Ansible Management Driver invoked for configuration of" + "VNF: {}".format(self._vnf.get("name"))) + + insta_info = self._vnf_instance.instantiated_vnf_info + + if action == mgmt_constants.ACTION_HEAL_VNF: + for vnfc_info in insta_info.vnfc_resource_info: + is_exist = [instance_id for instance_id in + request_obj.vnfc_instance_id + if instance_id == vnfc_info.id] + + if (not len(request_obj.vnfc_instance_id) + or len(is_exist)): + self._vnf['failed_vdu_name'] = vnfc_info.vdu_id + break + + EVENT_HANDLER.create_event(context, self._vnf, + utils.get_event_by_action(action, + self._vnf.get("failed_vdu_name", None)), + start_msg) + + # get the mgmt_url + vnf_mgmt_ip_address = self._vnf.get("mgmt_ip_address", None) + if vnf_mgmt_ip_address is not None: + mgmt_url = jsonutils.loads(vnf_mgmt_ip_address) + LOG.debug("mgmt_url %s", mgmt_url) + else: + LOG.info("Unable to retrieve mgmt_ip_address of VNF") + return + + # load the configuration file + config_yaml = self._load_ansible_config(request_obj) + if not config_yaml: + return + + # validate config file + self._cfg_validator.validate(config_yaml) + + # filter VDUs + if (action != mgmt_constants.ACTION_SCALE_IN_VNF and + action != mgmt_constants.ACTION_SCALE_OUT_VNF): + config_yaml = self._cfg_validator.filter_vdus(context, self._vnf, + utils.get_event_by_action(action, + self._vnf.get("failed_vdu_name", None)), mgmt_url, + config_yaml) + + # load stack map + stack_map = self._get_stack_map(action, **kwargs) + + # configure config parser for vnf parameter passing + self._cfg_parser.configure(self._context, self._vnf, self._plugin, + config_yaml, stack_map) + + self._sort_config(config_yaml) + + self._process_config(stack_map, config_yaml, mgmt_url, action) + + def _sort_config(self, config_yaml): + self._config_queue = {} + for vdu, vdu_dict in config_yaml.get("vdus", {}).items(): + self._add_to_config_queue(vdu, vdu_dict.get("config", {})) + + def _process_config(self, stack_map, config_yaml, mgmt_url, action): + for vdu_order in sorted(self._config_queue): + config_info_list = self._config_queue[vdu_order] + for config_info in config_info_list: + vdu = config_info["vdu"] + config = config_info["config"] + + for key, conf_value in config.items(): + if key not in self._config_actions: + continue + + LOG.debug("Processing configuration: {}".format(key)) + self._config_actions[key].execute( + vdu=vdu, + vnf=self._vnf, + context=self._context, + conf_value=conf_value, + mgmt_url=mgmt_url, + cfg_parser=self._cfg_parser, + stack_map=stack_map, + config_yaml=config_yaml, + action=action, + ) + + def _add_to_config_queue(self, vdu, config): + if "order" not in config: + raise exceptions.MandatoryKeyNotDefinedError(vdu=vdu, key="order") + + try: + order = int(config["order"]) + except ValueError: + raise exceptions.InvalidValueError(vdu=vdu, key="order") + + config_info = { + "vdu": vdu, + "config": config + } + + if order in self._config_queue: + self._config_queue[order].append(config_info) + else: + entity_list = [] + entity_list.append(config_info) + self._config_queue[order] = entity_list + + def _get_stack_map(self, action, **kwargs): + stack_id_map = {} + stack_id = None + stack_id_list = None + scaling_actions = [ + mgmt_constants.ACTION_SCALE_IN_VNF, + mgmt_constants.ACTION_SCALE_OUT_VNF, + ] + + if action in scaling_actions: + stack_id = kwargs["scale_stack_id"] + else: + stack_id = self._vnf_instance.instantiated_vnf_info.instance_id + + LOG.debug("stack_id: {}".format(stack_id)) + if stack_id: + if not isinstance(stack_id, list): + stack_id_list = [stack_id] + else: + stack_id_list = stack_id + + hc = heat_client.AnsibleHeatClient(self._context, self._plugin, + self._vnf) + + for stack_ids in stack_id_list: + parent_stack_id = hc.get_parent_stack_id(stack_ids) + for resource in hc.get_resource_list(parent_stack_id): + if resource.physical_resource_id == stack_ids: + attr = hc.get_resource_attributes( + parent_stack_id, resource.resource_name) + stack_id_map = self._add_to_stack_map(stack_id_map, attr) + + LOG.debug("stack_id_map: {}".format(stack_id_map)) + return stack_id_map + + def _add_to_stack_map(self, map, attributes): + for key, value in attributes.items(): + if "mgmt_ip-" in key: + vdu_name = key.replace("mgmt_ip-", "") + if vdu_name in map: + map[vdu_name].append(value) + else: + map[vdu_name] = [value] + return map + + def _get_config(self, config_data, config_params, vnf_package_path): + configurable_properties = config_data.get('configurable_properties') + + # add vnf_package_path to vnf_configurable properties + if not configurable_properties: + configurable_properties = { + '_VAR_vnf_package_path': vnf_package_path + '/' + } + else: + configurable_properties.update( + {'_VAR_vnf_package_path': vnf_package_path + '/'}) + + for k, v in config_params.items(): + var_key = '_VAR_' + k + configurable_properties.update({var_key: v}) + + config_data.update( + {'configurable_properties': configurable_properties}) + + LOG.debug('Modified config {}'.format(config_data)) + + return yaml.dump(config_data) + + def _load_ansible_config(self, request_obj): + # load vnf package path + vnf_package_path = vnflcm_utils._get_vnf_package_path(self._context, + self._vnf_instance.vnfd_id) + script_ansible_path = os.path.join(vnf_package_path, + utils.CONFIG_FOLDER) + + script_ansible_config = None + + # load ScriptANSIBLE/config.yaml + if os.path.exists(script_ansible_path): + for file in os.listdir(script_ansible_path): + if file.endswith('yaml') and file.startswith('config'): + with open( + os.path.join( + script_ansible_path, file)) as file_obj: + script_ansible_config = yaml.safe_load(file_obj) + + if script_ansible_config is None: + LOG.error("not defined ansible script config") + + config_params = {} + + if hasattr(self._vnf_instance.instantiated_vnf_info, + 'additional_params'): + config_params = self._vnf_instance.instantiated_vnf_info.\ + additional_params + elif hasattr(request_obj, 'additional_params'): + config_params = request_obj.additional_params + + self._vnf['attributes']['config'] = self._get_config( + script_ansible_config, config_params, vnf_package_path) + + return script_ansible_config diff --git a/samples/mgmt_driver/ansible/config_actions/__init__.py b/samples/mgmt_driver/ansible/config_actions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/samples/mgmt_driver/ansible/config_actions/abstract_config.py b/samples/mgmt_driver/ansible/config_actions/abstract_config.py new file mode 100644 index 000000000..41c56243c --- /dev/null +++ b/samples/mgmt_driver/ansible/config_actions/abstract_config.py @@ -0,0 +1,4 @@ + +class AbstractConfigAction(object): + def execute(self, **kwargs): + raise NotImplementedError diff --git a/samples/mgmt_driver/ansible/config_actions/vm_app_config/__init__.py b/samples/mgmt_driver/ansible/config_actions/vm_app_config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/samples/mgmt_driver/ansible/config_actions/vm_app_config/ansible_playbook_exec.py b/samples/mgmt_driver/ansible/config_actions/vm_app_config/ansible_playbook_exec.py new file mode 100644 index 000000000..e81036d2b --- /dev/null +++ b/samples/mgmt_driver/ansible/config_actions/vm_app_config/ansible_playbook_exec.py @@ -0,0 +1,160 @@ +# 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 os + +from oslo_log import log as logging +from six import iteritems +from tacker.vnfm.mgmt_drivers.ansible import event_handler +from tacker.vnfm.mgmt_drivers.ansible import exceptions + +from tacker.vnfm.mgmt_drivers.ansible.config_actions.\ + vm_app_config import executor + +LOG = logging.getLogger(__name__) +EVENT_HANDLER = event_handler.AnsibleEventHandler() + + +class AnsiblePlaybookExecutor(executor.Executor): + def _get_default_execute_host(self, conf_value=None): + user = self._conf_opts["user"] + password = self._conf_opts["password"] + host = self._conf_opts["host"] + host_private_key_file = self._conf_opts["private_key_file"] + return user, password, host, host_private_key_file + + def _get_target_host(self, conf_value): + target_user = conf_value.get("username", "") + target_password = conf_value.get("password", "") + target_private_key_file = conf_value.get("priv_key_file", "") + target_host = { + "target_user": target_user, + "target_password": target_password, + "target_private_key_file": target_private_key_file + } + + return target_host + + def _get_playbook_target_hosts(self, playbook_cmd): + target_hosts = playbook_cmd.get("target_hosts", "") + host_ips = "" + if not target_hosts: + host_ips = ",{}".format(self._mgmt_ip_address) + else: + for host_name in target_hosts: + # process host + if host_name in self._mgmt_url: + # the given config value "host" is a VDU name + host_ip = self._mgmt_url.get(host_name, "") + else: + # the given config value "host" is an address + host_ip = host_name + host_ips = host_ips + ",{}".format(host_ip) + return host_ips + + def _get_node_pair_ip(self, conf_value, mgmt_url): + node_pair_ip = None + if "node_pair" in conf_value: + node_pair_ip = mgmt_url.get(conf_value["node_pair"], "") + + return node_pair_ip + + def _get_params(self, playbook_cmd): + params = "" + obj_params = playbook_cmd.get("params", "") + if obj_params: + for key, value in iteritems(obj_params): + if isinstance(value, dict): + str_value = json.dumps( + value, separators=(',', ':')).replace('"', '\\"') + else: + str_value = "{}".format(value) + + params += "{}={} ".format(key, str_value) + return params + + def _convert_mgmt_url_to_extra_vars(self, mgmt_url): + return json.dumps(mgmt_url) + + def _get_playbook_path(self, playbook_cmd): + path = playbook_cmd.get("path", "") + if not path: + raise exceptions.ConfigValidationError( + vdu=self._vdu, + details="Playbook {} did not specify path".format(playbook_cmd) + ) + return path + + def _get_final_command(self, playbook_cmd): + init_cmd = ("cd {} ; ansible-playbook -i {} -vvv {} " + "--extra-vars \"host={} node_pair_ip={}".format( + os.path.dirname(self._get_playbook_path(playbook_cmd)), + self._get_playbook_target_hosts(playbook_cmd), + self._get_playbook_path(playbook_cmd), + self._mgmt_ip_address, + self._node_pair_ip)) + ssh_args = ("ansible_ssh_extra_args='-o StrictHostKeyChecking=no " + "-o UserKnownHostsFile=/dev/null'") + + target_host_param = False + ssh_creds = "" + + if self._target_host: + if self._target_host["target_user"]: + ssh_creds = "ansible_ssh_user={}".format( + self._target_host["target_user"] + ) + target_host_param = True + + if self._target_host["target_private_key_file"]: + if not target_host_param: + ssh_creds = "ansible_ssh_private_key_file={}".format( + self._target_host["target_private_key_file"] + ) + target_host_param = True + else: + ssh_creds = ssh_creds + " ansible_ssh_private_key_" + "file={}".format( + self._target_host["target_private_key_file"]) + + if self._target_host["target_password"]: + if not target_host_param: + ssh_creds = "ansible_ssh_pass={}".format( + self._target_host["target_password"] + ) + else: + ssh_creds = ssh_creds + " ansible_ssh_pass={}".format( + self._target_host["target_password"] + ) + + ssh_creds = ssh_creds + "\"" + mgmt_url_vars = " --extra-vars '{}'".format( + self._convert_mgmt_url_to_extra_vars(self._mgmt_url)) + + cmd_raw = "{} {} {} {} {}".format( + init_cmd, + ssh_args, + self._get_params(playbook_cmd), + ssh_creds, + mgmt_url_vars + ) + + # substitute passed VNF parameter to its corresponding value + inline_param = { + 'mgmt_ip_address': self._mgmt_ip_address, + 'vdu': self._vdu + } + return self._cfg_parser.substitute(cmd_raw, **inline_param) + + def _is_execution_error(self, res_code): + return False if res_code == 0 else True diff --git a/samples/mgmt_driver/ansible/config_actions/vm_app_config/config_walker.py b/samples/mgmt_driver/ansible/config_actions/vm_app_config/config_walker.py new file mode 100644 index 000000000..1398bbfd3 --- /dev/null +++ b/samples/mgmt_driver/ansible/config_actions/vm_app_config/config_walker.py @@ -0,0 +1,45 @@ +# 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. + +from oslo_log import log as logging + +from tacker.vnfm.mgmt_drivers.ansible import exceptions + +LOG = logging.getLogger(__name__) + + +class VmAppConfigWalker(object): + def __init__(self): + self._config = None + + def set_config(self, config_value): + self._config = config_value.get("vdus", {}) + + def get_creds_from_vdu(self, vdu, vdu_name): + vdu_dict = self._config.get(vdu_name, {}).get("config", {}).get( + "vm_app_config", {}) + if not vdu_dict: + exceptions.DataRetrievalError( + vdu=vdu, + details=("Unable to retrieve configuration information" + "for VDU: {}".format(vdu_name)) + ) + LOG.debug("vdu_dict: {}".format(vdu_dict)) + + creds = { + "username": vdu_dict.get("username", ""), + "password": vdu_dict.get("password", ""), + "priv_key_file": vdu_dict.get("priv_key_file", "") + } + + LOG.debug("creds: {}".format(creds)) + return creds diff --git a/samples/mgmt_driver/ansible/config_actions/vm_app_config/executor.py b/samples/mgmt_driver/ansible/config_actions/vm_app_config/executor.py new file mode 100644 index 000000000..e08574508 --- /dev/null +++ b/samples/mgmt_driver/ansible/config_actions/vm_app_config/executor.py @@ -0,0 +1,361 @@ +# 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. + +from oslo_config import cfg +from oslo_log import log as logging + +from tacker.vnfm.mgmt_drivers.ansible import event_handler +from tacker.vnfm.mgmt_drivers.ansible import exceptions +from tacker.vnfm.mgmt_drivers.ansible import utils + +from tacker.vnfm.mgmt_drivers.ansible.config_actions.\ + vm_app_config import config_walker + +import subprocess + +LOG = logging.getLogger(__name__) +EVENT_HANDLER = event_handler.AnsibleEventHandler() +OPTS = [ + cfg.StrOpt("user", default="root", + help="user name to login ansible server"), + cfg.StrOpt("password", default="root123", + help="password to login ansible server"), + cfg.StrOpt("host", default="127.0.0.1", + help="host of the ansible server"), + cfg.StrOpt("private_key_file", default="", + help="private_key_file of the ansible server"), + cfg.IntOpt("retry_count", default=120, help="maximum no. of retries"), + cfg.IntOpt("retry_interval", default=30, + help="time in seconds before next retry"), + cfg.IntOpt("connection_wait_timeout", default=3600, + help="time in seconds before ssh timeout"), + cfg.IntOpt("command_execution_wait_timeout", default=3600, + help="maximum time allocated to a command to return result"), +] +cfg.CONF.register_opts(OPTS, "ansible") + + +class Executor(config_walker.VmAppConfigWalker): + def __init__(self): + self._queue = {} + self._execute_host = {} + self._local_execute_host = {} + self._target_host = {} + self._conf_opts = {} + self._node_pair_ip = None + + self._action_key = "" + self._skip_execute = False + self._failed_vdu_name = None + + self._vdu = None + self._vnf = None + self._context = None + self._mgmt_ip_address = None + self._conf_value = None + self._mgmt_url = None + self._cfg_parser = None + self._mgmt_executor_type = None + + super(Executor, self).__init__() + + def execute(self, **kwargs): + self._vdu = kwargs["vdu"] + self._vnf = kwargs["vnf"] + self._context = kwargs["context"] + self._mgmt_ip_address = kwargs["mgmt_ip_address"] + self._conf_value = kwargs["conf_value"] + self._mgmt_url = kwargs["mgmt_url"] + self._cfg_parser = kwargs["cfg_parser"] + self._action_key = kwargs["action_key"] + self._skip_execute = kwargs["skip_execute"] + self._failed_vdu_name = kwargs["failed_vdu_name"] + self._mgmt_executor_type = kwargs["mgmt_executor_type"] + self.set_config(kwargs["config_yaml"]) + + if self._skip_execute: + LOG.debug("Skip execution for VDU: {}".format(self._vdu)) + return + + self._conf_opts = { + "user": cfg.CONF.ansible.user, + "password": cfg.CONF.ansible.password, + "host": cfg.CONF.ansible.host, + "private_key_file": cfg.CONF.ansible.private_key_file + } + retry_count = self._conf_value.get("retry_count", + cfg.CONF.ansible.retry_count) + retry_interval = self._conf_value.get("retry_interval", + cfg.CONF.ansible.retry_interval) + connection_wait_timeout = self._conf_value.get( + "connection_wait_timeout", + cfg.CONF.ansible.connection_wait_timeout) + command_execution_wait_timeout = \ + self._conf_value.get("command_execution_wait_timeout", + cfg.CONF.ansible.command_execution_wait_timeout) + + self._execute_host = self._get_execute_host( + execute_host=self._conf_value.get("execute-host", {}), + conf_value=self._conf_value) + self._target_host = self._get_target_host(self._conf_value) + self._node_pair_ip = self._get_node_pair_ip(self._conf_value, + self._mgmt_url) + + # translate some params + inline_param = { + 'mgmt_ip_address': self._mgmt_ip_address, + 'vdu': self._vdu + } + + retry_count = self._cfg_parser.substitute(retry_count, **inline_param) + retry_interval = self._cfg_parser.substitute(retry_interval, + **inline_param) + connection_wait_timeout = self._cfg_parser.substitute( + connection_wait_timeout, **inline_param) + command_execution_wait_timeout = self._cfg_parser.substitute( + command_execution_wait_timeout, **inline_param) + + LOG.debug("Command execution settings - retry count: {}".format( + retry_count)) + LOG.debug("Command execution settings - retry interval: {}".format( + retry_interval)) + LOG.debug("Command execution settings - " + "connection_wait_timeout: {}".format(connection_wait_timeout)) + LOG.debug("Command execution settings - " + "command_execution_wait_timeout: {}".format( + command_execution_wait_timeout)) + + playbook_cmd_list = self._conf_value.get(self._action_key, None) + if not playbook_cmd_list: + msg = "No '{}' configuration defined for VDU '{}' " + "with IP Address '{}'" + EVENT_HANDLER.create_event( + self._context, + self._vnf, + utils.get_event_by_action_key(self._action_key), + msg.format(self._action_key, self._vdu, self._mgmt_ip_address) + ) + else: + LOG.debug("conf_value @ {} {}".format(self._action_key, + playbook_cmd_list)) + + self._sort_playbook_cmd_list(playbook_cmd_list) + LOG.debug("Sorted playbooks/commands: {}".format( + self._queue.values())) + + self._execute(retry_count, retry_interval, + connection_wait_timeout, command_execution_wait_timeout) + + def _execute(self, retry_count, retry_interval, connection_wait_timeout, + command_execution_wait_timeout): + for order in sorted(self._queue): + playbook_cmd_list = self._queue[order] + + for playbook_cmd in playbook_cmd_list: + LOG.debug("playbook/command: {}".format(playbook_cmd)) + + self._pre_execution(playbook_cmd) + + cmd = self._get_final_command(playbook_cmd) + LOG.debug("command for execution: {}".format(cmd)) + + res_code = -1 + try: + res_code, host = self._execute_cmd( + cmd, + retry_count, + retry_interval, + connection_wait_timeout, + command_execution_wait_timeout + ) + except exceptions.AnsibleDriverException: + raise + except Exception as ex: + raise exceptions.CommandExecutionError(vdu=self._vdu, + details=ex) + + self._post_execution(cmd, res_code, host) + + if self._is_execution_error(res_code): + raise exceptions.CommandExecutionError( + vdu=self._vdu, + details="Non-zero return code" + ) + + def _sort_playbook_cmd_list(self, playbook_cmd_list): + self._queue = {} + for playbook_cmd in playbook_cmd_list: + self._add_to_queue(playbook_cmd) + + def _add_to_queue(self, playbook_cmd): + if "order" not in playbook_cmd: + raise exceptions.MandatoryKeyNotDefinedError(vdu=self._vdu, + key="order") + + try: + order = int(playbook_cmd["order"]) + except ValueError: + raise exceptions.InvalidValueError(vdu=self._vdu, key="order") + + if order in self._queue: + self._queue[order].append(playbook_cmd) + else: + entity_list = [] + entity_list.append(playbook_cmd) + self._queue[order] = entity_list + + def _execute_cmd(self, cmd, retry_count, retry_interval, + connection_wait_timeout, command_execution_wait_timeout): + + if self._local_execute_host: + user = self._local_execute_host["user"] + host = self._local_execute_host["host"] + password = self._local_execute_host["password"] + host_private_key_file = \ + self._local_execute_host["host_private_key_file"] + + else: + user = self._execute_host["user"] + host = self._execute_host["host"] + password = self._execute_host["password"] + host_private_key_file = \ + self._execute_host["host_private_key_file"] + + LOG.debug("Executing command: {} for VDU {}, host: {}, " + "username: {}, password: {}, private_key_file {}".format( + cmd, self._vdu, host, user, password, host_private_key_file)) + + # create command executor + result = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, shell=True, universal_newlines=True) + std_out, std_err = result.communicate() + + LOG.debug("command execution result code: {}".format( + result.returncode)) + LOG.debug("command execution result code: {}".format(std_out)) + LOG.debug("command execution result code: {}".format(std_err)) + + return result.returncode, host + + def _post_execution(self, cmd, res_code, host): + + msg = ("Command executed for the VDU '{}' with IP Address " + "'{}' on execute-host '{}' and result code '{}' => {}".format( + self._vdu, + self._mgmt_ip_address, + host, + res_code, + cmd)) + + EVENT_HANDLER.create_event( + self._context, + self._vnf, + utils.get_event_by_action_key(self._action_key), + msg, + self._is_execution_error(res_code) + ) + + # reset local execute-host details + self._local_execute_host = None + + def _get_execute_host(self, execute_host, conf_value=None, + use_default=True): + # some inline variables for parameter translation + inline_param = { + 'mgmt_ip_address': self._mgmt_ip_address, + 'vdu': self._vdu + } + + if not execute_host: + if use_default: + user, password, host, host_private_key_file = \ + self._get_default_execute_host(conf_value) + else: + return None + else: + host_name = execute_host.get("host", "") + # process host + if host_name in self._mgmt_url: + # the given config value "host" is a VDU name + host = self._mgmt_url.get(host_name, "") + vdu_creds = self.get_creds_from_vdu(self._vdu, host_name) + user = vdu_creds.get("username", "") + password = vdu_creds.get("password", "") + host_private_key_file = vdu_creds.get("priv_key_file", "") + else: + # the given config value "host" is an address + host = host_name + user = execute_host.get("username", "") + password = execute_host.get("password", "") + host_private_key_file = execute_host.get("priv_key_file", "") + + # validate config + if not host: + raise exceptions.DataRetrievalError( + vdu=self._vdu, + details="Unable to retrieve 'host' for execute-host" + ) + + if not user: + raise exceptions.DataRetrievalError( + vdu=self._vdu, + details="Unable to retrieve 'username' for execute-host" + ) + + if not password and not host_private_key_file: + raise exceptions.DataRetrievalError( + vdu=self._vdu, + details="Unable to retrieve either 'password' or " + "'priv_key_file' for execute-host" + ) + + user = self._cfg_parser.substitute(user, **inline_param) + password = self._cfg_parser.substitute(password, **inline_param) + host = self._cfg_parser.substitute(host, **inline_param) + host_private_key_file = self._cfg_parser.substitute( + host_private_key_file, **inline_param) + + execute_host = { + "user": user, + "password": password, + "host": host, + "host_private_key_file": host_private_key_file + } + + LOG.debug("execute-host->user: {}".format(user)) + LOG.debug("execute-host->password: {}".format(password)) + LOG.debug("execute-host->host: {}".format(host)) + LOG.debug("execute-host->pkeyfile: {}".format(host_private_key_file)) + + return execute_host + + def _pre_execution(self, playbook_cmd): + local_execute_host = playbook_cmd.get("execute-host", {}) + LOG.debug('conf_value local_execute_host: {}'.format( + local_execute_host)) + self._local_execute_host = self._get_execute_host( + execute_host=local_execute_host, use_default=False) + + def _get_final_command(self, playbook_cmd): + raise NotImplementedError + + def _is_execution_error(self, res_code): + raise NotImplementedError + + def _get_target_host(self, conf_value): + raise NotImplementedError + + def _get_node_pair_ip(self, conf_value, mgmt_url): + raise NotImplementedError + + def _get_default_execute_host(self, conf_value=None): + raise NotImplementedError diff --git a/samples/mgmt_driver/ansible/config_actions/vm_app_config/vm_app_config.py b/samples/mgmt_driver/ansible/config_actions/vm_app_config/vm_app_config.py new file mode 100644 index 000000000..570b732c8 --- /dev/null +++ b/samples/mgmt_driver/ansible/config_actions/vm_app_config/vm_app_config.py @@ -0,0 +1,143 @@ +# 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. + +from oslo_log import log as logging +from tacker.vnfm.mgmt_drivers import constants + +from tacker.vnfm.mgmt_drivers.ansible import event_handler +from tacker.vnfm.mgmt_drivers.ansible import exceptions + +from tacker.vnfm.mgmt_drivers.ansible.config_actions import abstract_config +from tacker.vnfm.mgmt_drivers.ansible.config_actions.\ + vm_app_config import ansible_playbook_exec + +LOG = logging.getLogger(__name__) +EVENT_HANDLER = event_handler.AnsibleEventHandler() + + +class VmAppConfigAction(abstract_config.AbstractConfigAction): + def __init__(self): + self._mgmt_executors = {} + self._mgmt_executors["ansible"] = \ + ansible_playbook_exec.AnsiblePlaybookExecutor() + + self._vdu = None + self._vnf = None + self._context = None + self._mgmt_executor_type = None + + def execute(self, **kwargs): + vdu = kwargs["vdu"] + vnf = kwargs["vnf"] + context = kwargs["context"] + conf_value = kwargs["conf_value"] + mgmt_url = kwargs["mgmt_url"] + action = kwargs["action"] + failed_vdu_name = vnf.get("failed_vdu_name", "") + cfg_parser = kwargs["cfg_parser"] + stack_map = kwargs["stack_map"] + config_yaml = kwargs["config_yaml"] + + self._vdu = vdu + self._vnf = vnf + self._context = context + + mgmt_executor = \ + self._get_mgmt_executor(conf_value.get("type", "ansible")) + mgmt_executor_type = self._mgmt_executor_type + action_key = self._get_action_key(action, failed_vdu_name) + skip_execute = self._is_skipped(action_key, failed_vdu_name, stack_map) + + mgmt_ip_address = "" + + mgmt_ip_address_list = self._get_mgmt_ip_address(skip_execute, + mgmt_url, stack_map) + + for mgmt_ip_address in mgmt_ip_address_list: + mgmt_executor.execute( + vdu=vdu, + vnf=vnf, + context=context, + mgmt_ip_address=mgmt_ip_address, + conf_value=conf_value, + mgmt_url=mgmt_url, + cfg_parser=cfg_parser, + failed_vdu_name=failed_vdu_name, + action_key=action_key, + skip_execute=skip_execute, + config_yaml=config_yaml, + mgmt_executor_type=mgmt_executor_type + ) + + def _get_mgmt_executor(self, mgmt_type): + self._mgmt_executor_type = mgmt_type + mgmt_executor = self._mgmt_executors.get(mgmt_type, None) + + if not mgmt_executor: + raise exceptions.InvalidKeyError(vdu=self._vdu, key=mgmt_type) + + return mgmt_executor + + def _get_mgmt_ip_address(self, skip_execute, mgmt_url, stack_map): + mgmt_ip_address = [] + if not skip_execute: + if stack_map: + mgmt_ip_address = stack_map.get(self._vdu, "") + else: + mgmt_ip_address = mgmt_url.get(self._vdu, "") + + if not mgmt_ip_address: + raise exceptions.DataRetrievalError( + vdu=self._vdu, + details="Cannot get mgmt address for the VDU: {}".format( + self._vdu) + ) + + if not isinstance(mgmt_ip_address, list): + # put ip inside list + mgmt_ip_address = [mgmt_ip_address] + return mgmt_ip_address + + def _get_action_key(self, action, failed_vdu_name): + # if no key for a particular VNF operation is found, + # use the default 'playbooks' + action_key = None + if action == constants.ACTION_HEAL_VNF: + if failed_vdu_name: + LOG.debug("failed vdu name: %s", failed_vdu_name) + action_key = "healing" + elif action == constants.ACTION_INSTANTIATE_VNF: + action_key = "instantiation" + elif action == constants.ACTION_TERMINATE_VNF: + action_key = "termination" + elif action == constants.ACTION_SCALE_IN_VNF: + action_key = "scale-in" + elif action == constants.ACTION_SCALE_OUT_VNF: + action_key = "scale-out" + return action_key + + def _is_skipped(self, action_key, failed_vdu_name, stack_map): + is_skipped = False + if action_key: + if action_key == "healing": + if self._vdu != failed_vdu_name: + is_skipped = True + LOG.debug("VDU %s did not fail.", self._vdu) + elif action_key == "scale-in" or action_key == "scale-out": + if self._vdu not in stack_map: + is_skipped = True + LOG.debug("VDU is not scaling target: {}".format( + self._vdu)) + else: + is_skipped = True + LOG.debug("Action not supported.") + return is_skipped diff --git a/samples/mgmt_driver/ansible/config_validator.py b/samples/mgmt_driver/ansible/config_validator.py new file mode 100644 index 000000000..0426b80df --- /dev/null +++ b/samples/mgmt_driver/ansible/config_validator.py @@ -0,0 +1,60 @@ +# 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 yaml + +from jsonschema import exceptions as json_exp +from jsonschema import validate +from oslo_log import log as logging + +from tacker.vnfm.mgmt_drivers.ansible import event_handler +from tacker.vnfm.mgmt_drivers.ansible import exceptions + +from tacker.vnfm.mgmt_drivers.ansible.config_validator_schema import SCHEMA + +LOG = logging.getLogger(__name__) +EVENT_HANDLER = event_handler.AnsibleEventHandler() + + +class AnsibleConfigValidator(object): + def __init__(self): + self._schema = yaml.safe_load(SCHEMA) + + def validate(self, config_yaml): + try: + validate(config_yaml, self._schema) + except json_exp.ValidationError as err: + raise exceptions.ConfigValidationError( + details=str(err).splitlines()[0]) + + def filter_vdus(self, context, vnf, event, mgmt_url, config_yaml): + vdu_config = config_yaml.get("vdus", {}) + filtered_vdu_config = {} + + if vdu_config: + for vdu in mgmt_url: + if vdu in vdu_config: + filtered_vdu_config[vdu] = vdu_config.get(vdu) + else: + msg = ("Could not find configuration entry for VDU '{}' " + "with IP Address '{}': skipping configuration") + EVENT_HANDLER.create_event( + context, + vnf, + event, + msg.format(vdu, json.dumps(mgmt_url.get(vdu, ""))) + ) + + config_yaml["vdus"] = filtered_vdu_config + LOG.debug("filtered config yaml: {}".format(config_yaml)) + return config_yaml diff --git a/samples/mgmt_driver/ansible/config_validator_schema.py b/samples/mgmt_driver/ansible/config_validator_schema.py new file mode 100644 index 000000000..9e1bc213a --- /dev/null +++ b/samples/mgmt_driver/ansible/config_validator_schema.py @@ -0,0 +1,135 @@ +# 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. + +SCHEMA = """ +type: object +required: + - vdus +properties: + vdus: + type: object + additionalProperties: false + patternProperties: + ^[!-~]+$: + type: object + additionalProperties: false + required: + - config + properties: + config: + type: object + additionalProperties: false + required: + - order + - vm_app_config + properties: + order: + type: integer + vm_app_config: + type: object + additionalProperties: false + anyOf: + - required: + - instantiation + - required: + - termination + - required: + - healing + - required: + - scale-in + - required: + - scale-out + properties: + type: + type: string + enum: + - ansible + - remote-command + node_pair: + type: string + username: + type: string + password: + type: string + priv_key_file: + type: string + retry_count: + type: + - integer + - string + retry_interval: + type: + - integer + - string + connection_wait_timeout: + type: + - integer + - string + command_execution_wait_timeout: + type: + - integer + - string + execute-host: + type: object + additionalProperties: false + required: + - host + properties: + host: + type: string + username: + type: string + password: + type: string + priv_key_file: + type: string + patternProperties: + ^instantiation|termination|healing|scale-in|scale-out$: + type: array + items: + type: object + additionalProperties: false + required: + - order + oneOf: + - required: + - path + - required: + - command + properties: + path: + type: string + params: + type: object + command: + type: string + order: + type: integer + target_hosts: + type: array + items: + type: string + execute-host: + type: object + additionalProperties: false + required: + - host + properties: + host: + type: string + username: + type: string + password: + type: string + priv_key_file: + type: string +""" diff --git a/samples/mgmt_driver/ansible/event_handler.py b/samples/mgmt_driver/ansible/event_handler.py new file mode 100644 index 000000000..ac0b93e66 --- /dev/null +++ b/samples/mgmt_driver/ansible/event_handler.py @@ -0,0 +1,25 @@ +# 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. + +from oslo_log import log as logging + +from tacker.plugins.common import constants +from tacker.vnfm import utils + + +LOG = logging.getLogger(__name__) + + +class AnsibleEventHandler(object): + def create_event(self, context, vnf, event, msg, error_flag=False): + vnf["status"] = constants.ERROR if error_flag else vnf["status"] + utils.log_events(context, vnf, event, msg) diff --git a/samples/mgmt_driver/ansible/exceptions.py b/samples/mgmt_driver/ansible/exceptions.py new file mode 100644 index 000000000..22b02b194 --- /dev/null +++ b/samples/mgmt_driver/ansible/exceptions.py @@ -0,0 +1,144 @@ +# 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. + + +class AnsibleDriverException(Exception): + def __init__(self, vdu=None, **kwargs): + if "details" not in kwargs or not kwargs["details"]: + kwargs["details"] = "No error information available." + + self.vdu = vdu + self.message = self.message % kwargs + super(AnsibleDriverException, self).__init__(self.message) + + +class InternalErrorException(AnsibleDriverException): + """Internal Error. + + Define the following upon using this exception: + - details: the exception message or error information + """ + message = "Management driver internal error: %(details)s" + + +class ConfigParserConfigurationError(AnsibleDriverException): + """Config parser configuration error. + + Define the following upon using this exception: + - ex_type: the exception type + - details: the exception message or error information + """ + message = "Parameter conversion error. " + "Error encountered in configuring parser: [%(ex_type)s, %(details)s]" + + +class ConfigParserParsingError(AnsibleDriverException): + """Config parser parsing error. + + Define the following upon using this exception: + - cmd: the command being parsed that resulted in error + - ex_type: the exception type + - details: the exception message or error information + """ + message = "Parameter conversion error. " + "Encountered error in parsing '%(cmd)s': [%(ex_type)s, %(details)s]" + + +class ConfigValidationError(AnsibleDriverException): + """Config file validation error. + + Define the following upon using this exception: + - details: the exception message or error information + """ + message = "Config file validation error: %(details)s" + + +class MandatoryKeyNotDefinedError(AnsibleDriverException): + """Config file validation error. Mandatory key is not defined. + + Define the following upon using this exception: + - key: the offending key + """ + message = "Config file validation error. " + "The key '%(key)s' is not defined." + + +class InvalidValueError(AnsibleDriverException): + """Config file validation error. Invalid value for a key is defined. + + Define the following upon using this exception: + - key: the offending key + """ + message = "Config file validation error. " + "Invalid value of '%(key)s' is defined." + + +class PlaybooksCommandsNotFound(AnsibleDriverException): + """Config file validation error. Playbooks or commands not found. + + Define the following upon using this exception: + - key: the offending action key + """ + message = "Config file validation error. " + "Playbooks or commands not found for action key: %(key)s" + + +class InvalidKeyError(AnsibleDriverException): + """Config file validation error. Invalid key error. + + Define the following upon using this exception: + - key: the offending key + """ + message = "Config file validation error. The key '%(key)s' is not valid." + + +class DataRetrievalError(AnsibleDriverException): + """Data retrieval error. + + Define the following upon using this exception: + - details: the exception message or error information + """ + message = "Data retrieval error: %(details)s" + + +class CommandExecutionError(AnsibleDriverException): + """Command execution error. + + Define the following upon using this exception: + - details: the exception message or error information + """ + message = "Command execution error: %(details)s" + + +class CommandExecutionTimeoutError(AnsibleDriverException): + """Command execution timeout error. + + Define the following upon using this exception: + - host: the target host for execution + - cmd: the command executed that caused the error + """ + message = "Command execution has reached timeout. " + "Target: %(host)s Command: %(cmd)s" + + +class CommandConnectionLimitReached(AnsibleDriverException): + """Command connection attempt limit reached.""" + message = "Connection attempt has reached its limit." + + +class CommandConnectionError(AnsibleDriverException): + """Command connection error. + + Define the following upon using this exception: + - details: the exception message or error information + """ + message = "Connection attempt error: %(details)s" diff --git a/samples/mgmt_driver/ansible/heat_client.py b/samples/mgmt_driver/ansible/heat_client.py new file mode 100644 index 000000000..3c4275bc5 --- /dev/null +++ b/samples/mgmt_driver/ansible/heat_client.py @@ -0,0 +1,56 @@ +# 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. + +from oslo_log import log as logging + +from tacker.common import clients + +LOG = logging.getLogger(__name__) + + +class AnsibleHeatClient(): + + def __init__(self, context, plugin, vnf): + access_info = plugin.get_vim(context, vnf) + + vim_auth = access_info["vim_auth"] + auth_attr = { + "username": vim_auth["username"], + "password": vim_auth["password"], + "project_name": vim_auth["project_name"], + "cert_verify": vim_auth["cert_verify"], + "user_domain_name": vim_auth["user_domain_name"], + "auth_url": vim_auth["auth_url"], + "project_id": vim_auth["project_id"], + "project_domain_name": vim_auth["project_domain_name"] + } + + region_name = vnf.get('placement_attr', {}).get('region_name', None) + + self._heat_client = \ + clients.OpenstackClients(auth_attr, region_name).heat + + def get_parent_stack_id(self, stack_id): + stack = self._heat_client.stacks.get(stack_id) + return stack.parent + + def get_resource_list(self, stack_id): + resource_list = [] + if stack_id: + resource_list = self._heat_client.resources.list(stack_id) + return resource_list + + def get_resource(self, stack_id, resource_name): + return self._heat_client.resources.get(stack_id, resource_name) + + def get_resource_attributes(self, stack_id, resource_name): + return self.get_resource(stack_id, resource_name).attributes diff --git a/samples/mgmt_driver/ansible/utils.py b/samples/mgmt_driver/ansible/utils.py new file mode 100644 index 000000000..a0d658061 --- /dev/null +++ b/samples/mgmt_driver/ansible/utils.py @@ -0,0 +1,47 @@ +# 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. + +from tacker.plugins.common import constants as plg_constants +from tacker.vnfm.mgmt_drivers import constants as mgmt_constants + +CONFIG_FOLDER = "ScriptANSIBLE" + + +def get_event_by_action(action, failed_vdu_name): + event_type = None + if action == mgmt_constants.ACTION_INSTANTIATE_VNF: + event_type = plg_constants.RES_EVT_INSTANTIATE + elif action == mgmt_constants.ACTION_TERMINATE_VNF: + event_type = plg_constants.RES_EVT_TERMINATE + elif action == mgmt_constants.ACTION_HEAL_VNF: + if failed_vdu_name: + event_type = plg_constants.RES_EVT_HEAL + elif action == mgmt_constants.ACTION_SCALE_IN_VNF: + event_type = plg_constants.RES_EVT_SCALE + elif action == mgmt_constants.ACTION_SCALE_OUT_VNF: + event_type = plg_constants.RES_EVT_SCALE + return event_type + + +def get_event_by_action_key(action_key): + event_type = None + if action_key == "instantiation": + event_type = plg_constants.RES_EVT_INSTANTIATE + elif action_key == "termination": + event_type = plg_constants.RES_EVT_TERMINATE + elif action_key == "healing": + event_type = plg_constants.RES_EVT_HEAL + elif action_key == "scale-in": + event_type = plg_constants.RES_EVT_SCALE + elif action_key == "scale-out": + event_type = plg_constants.RES_EVT_SCALE + return event_type