#!/usr/bin/python # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # from __future__ import absolute_import, division, print_function __metaclass__ = type ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} DOCUMENTATION = ''' --- module: cv_server_provision author: "EOS+ CS (ansible-dev@arista.com) (@mharista)" short_description: Provision server port by applying or removing template configuration to an Arista CloudVision Portal configlet that is applied to a switch. description: - This module allows a server team to provision server network ports for new servers without having to access Arista CVP or asking the network team to do it for them. Provide the information for connecting to CVP, switch rack, port the new server is connected to, optional vlan, and an action and the module will apply the configuration to the switch port via CVP. Actions are add (applies template config to port), remove (defaults the interface config) and show (returns the current port config). options: host: description: - The hostname or IP address of the CVP node being connected to. required: true port: description: - The port number to use when making API calls to the CVP node. This will default to the default port for the specified protocol. Port 80 for http and port 443 for https. protocol: description: - The protocol to use when making API calls to CVP. CVP defaults to https and newer versions of CVP no longer support http. default: https choices: [https, http] username: description: - The user that will be used to connect to CVP for making API calls. required: true password: description: - The password of the user that will be used to connect to CVP for API calls. required: true server_name: description: - The hostname or identifier for the server that is having it's switch port provisioned. required: true switch_name: description: - The hostname of the switch is being configured for the server being provisioned. required: true switch_port: description: - The physical port number on the switch that the new server is connected to. required: true port_vlan: description: - The vlan that should be applied to the port for this server. This parameter is dependent on a proper template that supports single vlan provisioning with it. If a port vlan is specified by the template specified does not support this the module will exit out with no changes. If a template is specified that requires a port vlan but no port vlan is specified the module will exit out with no changes. template: description: - A path to a Jinja formatted template file that contains the configuration block that will be applied to the specified switch port. This template will have variable fields replaced by the module before being applied to the switch configuration. required: true action: description: - The action for the module to take. The actions are add, which applies the specified template config to port, remove, which defaults the specified interface configuration, and show, which will return the current port configuration with no changes. default: show choices: [show, add, remove] auto_run: description: - Flag that determines whether or not the module will execute the CVP task spawned as a result of changes to a switch configlet. When an add or remove action is taken which results in a change to a switch configlet, CVP will spawn a task that needs to be executed for the configuration to be applied to the switch. If this option is True then the module will determined the task number created by the configuration change, execute it and wait for the task to complete. If the option is False then the task will remain in the Pending state in CVP for a network administrator to review and execute. type: bool default: 'no' requirements: [Jinja2, cvprac >= 0.7.0] ''' EXAMPLES = ''' - name: Get current configuration for interface Ethernet2 cv_server_provision: host: cvp_node username: cvp_user password: cvp_pass protocol: https server_name: new_server switch_name: eos_switch_1 switch_port: 2 template: template_file.j2 action: show - name: Remove existing configuration from interface Ethernet2. Run task. cv_server_provision: host: cvp_node username: cvp_user password: cvp_pass protocol: https server_name: new_server switch_name: eos_switch_1 switch_port: 2 template: template_file.j2 action: remove auto_run: True - name: Add template configuration to interface Ethernet2. No VLAN. Run task. cv_server_provision: host: cvp_node username: cvp_user password: cvp_pass protocol: https server_name: new_server switch_name: eos_switch_1 switch_port: 2 template: single_attached_trunk.j2 action: add auto_run: True - name: Add template with VLAN configuration to interface Ethernet2. Run task. cv_server_provision: host: cvp_node username: cvp_user password: cvp_pass protocol: https server_name: new_server switch_name: eos_switch_1 switch_port: 2 port_vlan: 22 template: single_attached_vlan.j2 action: add auto_run: True ''' RETURN = ''' changed: description: Signifies if a change was made to the configlet returned: success type: bool sample: true currentConfigBlock: description: The current config block for the user specified interface returned: when action = show type: str sample: | interface Ethernet4 ! newConfigBlock: description: The new config block for the user specified interface returned: when action = add or remove type: str sample: | interface Ethernet3 description example no switchport ! oldConfigBlock: description: The current config block for the user specified interface before any changes are made returned: when action = add or remove type: str sample: | interface Ethernet3 ! fullConfig: description: The full config of the configlet after being updated returned: when action = add or remove type: str sample: | ! interface Ethernet3 ! interface Ethernet4 ! updateConfigletResponse: description: Response returned from CVP when configlet update is triggered returned: when action = add or remove and configuration changes type: str sample: "Configlet veos1-server successfully updated and task initiated." portConfigurable: description: Signifies if the user specified port has an entry in the configlet that Ansible has access to returned: success type: bool sample: true switchConfigurable: description: Signifies if the user specified switch has a configlet applied to it that CVP is allowed to edit returned: success type: bool sample: true switchInfo: description: Information from CVP describing the switch being configured returned: success type: dict sample: {"architecture": "i386", "bootupTimeStamp": 1491264298.21, "complianceCode": "0000", "complianceIndication": "NONE", "deviceInfo": "Registered", "deviceStatus": "Registered", "fqdn": "veos1", "hardwareRevision": "", "internalBuildId": "12-12", "internalVersion": "4.17.1F-11111.4171F", "ipAddress": "192.168.1.20", "isDANZEnabled": "no", "isMLAGEnabled": "no", "key": "00:50:56:5d:e5:e0", "lastSyncUp": 1496432895799, "memFree": 472976, "memTotal": 1893460, "modelName": "vEOS", "parentContainerId": "container_13_5776759195930", "serialNumber": "", "systemMacAddress": "00:50:56:5d:e5:e0", "taskIdList": [], "tempAction": null, "type": "netelement", "unAuthorized": false, "version": "4.17.1F", "ztpMode": "false"} taskCompleted: description: Signifies if the task created and executed has completed successfully returned: when action = add or remove, and auto_run = true, and configuration changes type: bool sample: true taskCreated: description: Signifies if a task was created due to configlet changes returned: when action = add or remove, and auto_run = true or false, and configuration changes type: bool sample: true taskExecuted: description: Signifies if the automation executed the spawned task returned: when action = add or remove, and auto_run = true, and configuration changes type: bool sample: true taskId: description: The task ID created by CVP because of changes to configlet returned: when action = add or remove, and auto_run = true or false, and configuration changes type: str sample: "500" ''' import re import time from ansible.module_utils.basic import AnsibleModule try: import jinja2 from jinja2 import meta HAS_JINJA2 = True except ImportError: HAS_JINJA2 = False try: from cvprac.cvp_client import CvpClient from cvprac.cvp_client_errors import CvpLoginError, CvpApiError HAS_CVPRAC = True except ImportError: HAS_CVPRAC = False def connect(module): ''' Connects to CVP device using user provided credentials from playbook. :param module: Ansible module with parameters and client connection. :return: CvpClient object with connection instantiated. ''' client = CvpClient() try: client.connect([module.params['host']], module.params['username'], module.params['password'], protocol=module.params['protocol'], port=module.params['port']) except CvpLoginError as e: module.fail_json(msg=str(e)) return client def switch_info(module): ''' Get dictionary of switch info from CVP. :param module: Ansible module with parameters and client connection. :return: Dict of switch info from CVP or exit with failure if no info for device is found. ''' switch_name = module.params['switch_name'] switch_info = module.client.api.get_device_by_name(switch_name) if not switch_info: module.fail_json(msg=str("Device with name '%s' does not exist." % switch_name)) return switch_info def switch_in_compliance(module, sw_info): ''' Check if switch is currently in compliance. :param module: Ansible module with parameters and client connection. :param sw_info: Dict of switch info. :return: Nothing or exit with failure if device is not in compliance. ''' compliance = module.client.api.check_compliance(sw_info['key'], sw_info['type']) if compliance['complianceCode'] != '0000': module.fail_json(msg=str('Switch %s is not in compliance. Returned' ' compliance code %s.' % (sw_info['fqdn'], compliance['complianceCode']))) def server_configurable_configlet(module, sw_info): ''' Check CVP that the user specified switch has a configlet assigned to it that Ansible is allowed to edit. :param module: Ansible module with parameters and client connection. :param sw_info: Dict of switch info. :return: Dict of configlet information or None. ''' configurable_configlet = None configlet_name = module.params['switch_name'] + '-server' switch_configlets = module.client.api.get_configlets_by_device_id( sw_info['key']) for configlet in switch_configlets: if configlet['name'] == configlet_name: configurable_configlet = configlet return configurable_configlet def port_configurable(module, configlet): ''' Check configlet if the user specified port has a configuration entry in the configlet to determine if Ansible is allowed to configure the port on this switch. :param module: Ansible module with parameters and client connection. :param configlet: Dict of configlet info. :return: True or False. ''' configurable = False regex = r'^interface Ethernet%s' % module.params['switch_port'] for config_line in configlet['config'].split('\n'): if re.match(regex, config_line): configurable = True return configurable def configlet_action(module, configlet): ''' Take appropriate action based on current state of device and user requested action. Return current config block for specified port if action is show. If action is add or remove make the appropriate changes to the configlet and return the associated information. :param module: Ansible module with parameters and client connection. :param configlet: Dict of configlet info. :return: Dict of information to updated results with. ''' result = dict() existing_config = current_config(module, configlet['config']) if module.params['action'] == 'show': result['currentConfigBlock'] = existing_config return result elif module.params['action'] == 'add': result['newConfigBlock'] = config_from_template(module) elif module.params['action'] == 'remove': result['newConfigBlock'] = ('interface Ethernet%s\n!' % module.params['switch_port']) result['oldConfigBlock'] = existing_config result['fullConfig'] = updated_configlet_content(module, configlet['config'], result['newConfigBlock']) resp = module.client.api.update_configlet(result['fullConfig'], configlet['key'], configlet['name']) if 'data' in resp: result['updateConfigletResponse'] = resp['data'] if 'task' in resp['data']: result['changed'] = True result['taskCreated'] = True return result def current_config(module, config): ''' Parse the full port configuration for the user specified port out of the full configlet configuration and return as a string. :param module: Ansible module with parameters and client connection. :param config: Full config to parse specific port config from. :return: String of current config block for user specified port. ''' regex = r'^interface Ethernet%s' % module.params['switch_port'] match = re.search(regex, config, re.M) if not match: module.fail_json(msg=str('interface section not found - %s' % config)) block_start, line_end = match.regs[0] match = re.search(r'!', config[line_end:], re.M) if not match: return config[block_start:] _, block_end = match.regs[0] block_end = line_end + block_end return config[block_start:block_end] def valid_template(port, template): ''' Test if the user provided Jinja template is valid. :param port: User specified port. :param template: Contents of Jinja template. :return: True or False ''' valid = True regex = r'^interface Ethernet%s' % port match = re.match(regex, template, re.M) if not match: valid = False return valid def config_from_template(module): ''' Load the Jinja template and apply user provided parameters in necessary places. Fail if template is not found. Fail if rendered template does not reference the correct port. Fail if the template requires a VLAN but the user did not provide one with the port_vlan parameter. :param module: Ansible module with parameters and client connection. :return: String of Jinja template rendered with parameters or exit with failure. ''' template_loader = jinja2.FileSystemLoader('./templates') env = jinja2.Environment(loader=template_loader, undefined=jinja2.DebugUndefined) template = env.get_template(module.params['template']) if not template: module.fail_json(msg=str('Could not find template - %s' % module.params['template'])) data = {'switch_port': module.params['switch_port'], 'server_name': module.params['server_name']} temp_source = env.loader.get_source(env, module.params['template'])[0] parsed_content = env.parse(temp_source) temp_vars = list(meta.find_undeclared_variables(parsed_content)) if 'port_vlan' in temp_vars: if module.params['port_vlan']: data['port_vlan'] = module.params['port_vlan'] else: module.fail_json(msg=str('Template %s requires a vlan. Please' ' re-run with vlan number provided.' % module.params['template'])) template = template.render(data) if not valid_template(module.params['switch_port'], template): module.fail_json(msg=str('Template content does not configure proper' ' interface - %s' % template)) return template def updated_configlet_content(module, existing_config, new_config): ''' Update the configlet configuration with the new section for the port specified by the user. :param module: Ansible module with parameters and client connection. :param existing_config: String of current configlet configuration. :param new_config: String of configuration for user specified port to replace in the existing config. :return: String of the full updated configuration. ''' regex = r'^interface Ethernet%s' % module.params['switch_port'] match = re.search(regex, existing_config, re.M) if not match: module.fail_json(msg=str('interface section not found - %s' % existing_config)) block_start, line_end = match.regs[0] updated_config = existing_config[:block_start] + new_config match = re.search(r'!\n', existing_config[line_end:], re.M) if match: _, block_end = match.regs[0] block_end = line_end + block_end updated_config += '\n%s' % existing_config[block_end:] return updated_config def configlet_update_task(module): ''' Poll device info of switch from CVP up to three times to see if the configlet updates have spawned a task. It sometimes takes a second for the task to be spawned after configlet updates. If a task is found return the task ID. Otherwise return None. :param module: Ansible module with parameters and client connection. :return: Task ID or None. ''' for num in range(3): device_info = switch_info(module) if (('taskIdList' in device_info) and (len(device_info['taskIdList']) > 0)): for task in device_info['taskIdList']: if ('Configlet Assign' in task['description'] and task['data']['WORKFLOW_ACTION'] == 'Configlet Push'): return task['workOrderId'] time.sleep(1) return None def wait_for_task_completion(module, task): ''' Poll CVP for the executed task to complete. There is currently no timeout. Exits with failure if task status is Failed or Cancelled. :param module: Ansible module with parameters and client connection. :param task: Task ID to poll for completion. :return: True or exit with failure if task is cancelled or fails. ''' task_complete = False while not task_complete: task_info = module.client.api.get_task_by_id(task) task_status = task_info['workOrderUserDefinedStatus'] if task_status == 'Completed': return True elif task_status in ['Failed', 'Cancelled']: module.fail_json(msg=str('Task %s has reported status %s. Please' ' consult the CVP admins for more' ' information.' % (task, task_status))) time.sleep(2) def main(): """ main entry point for module execution """ argument_spec = dict( host=dict(required=True), port=dict(required=False, default=None), protocol=dict(default='https', choices=['http', 'https']), username=dict(required=True), password=dict(required=True, no_log=True), server_name=dict(required=True), switch_name=dict(required=True), switch_port=dict(required=True), port_vlan=dict(required=False, default=None), template=dict(require=True), action=dict(default='show', choices=['show', 'add', 'remove']), auto_run=dict(type='bool', default=False)) module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=False) if not HAS_JINJA2: module.fail_json(msg='The Jinja2 python module is required.') if not HAS_CVPRAC: module.fail_json(msg='The cvprac python module is required.') result = dict(changed=False) module.client = connect(module) try: result['switchInfo'] = switch_info(module) if module.params['action'] in ['add', 'remove']: switch_in_compliance(module, result['switchInfo']) switch_configlet = server_configurable_configlet(module, result['switchInfo']) if not switch_configlet: module.fail_json(msg=str('Switch %s has no configurable server' ' ports.' % module.params['switch_name'])) result['switchConfigurable'] = True if not port_configurable(module, switch_configlet): module.fail_json(msg=str('Port %s is not configurable as a server' ' port on switch %s.' % (module.params['switch_port'], module.params['switch_name']))) result['portConfigurable'] = True result['taskCreated'] = False result['taskExecuted'] = False result['taskCompleted'] = False result.update(configlet_action(module, switch_configlet)) if module.params['auto_run'] and module.params['action'] != 'show': task_id = configlet_update_task(module) if task_id: result['taskId'] = task_id note = ('Update config on %s with %s action from Ansible.' % (module.params['switch_name'], module.params['action'])) module.client.api.add_note_to_task(task_id, note) module.client.api.execute_task(task_id) result['taskExecuted'] = True task_completed = wait_for_task_completion(module, task_id) if task_completed: result['taskCompleted'] = True else: result['taskCreated'] = False except CvpApiError as e: module.fail_json(msg=str(e)) module.exit_json(**result) if __name__ == '__main__': main()