diff --git a/lib/ansible/modules/network/slxos/slxos_vlan.py b/lib/ansible/modules/network/slxos/slxos_vlan.py new file mode 100644 index 0000000000..fa5c719d83 --- /dev/null +++ b/lib/ansible/modules/network/slxos/slxos_vlan.py @@ -0,0 +1,310 @@ +#!/usr/bin/python +# +# (c) 2018 Extreme Networks Inc. +# +# 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: slxos_vlan +version_added: "2.6" +author: "Lindsay Hill (@lindsayhill)" +short_description: Manage VLANs on Extreme Networks SLX-OS network devices +description: + - This module provides declarative management of VLANs + on Extreme SLX-OS network devices. +notes: + - Tested against SLX-OS 17s.1.02 +options: + name: + description: + - Name of the VLAN. + vlan_id: + description: + - ID of the VLAN. Range 1-4094. + required: true + interfaces: + description: + - List of interfaces that should be associated to the VLAN. + required: true + delay: + description: + - Delay the play should wait to check for declarative intent params values. + default: 10 + aggregate: + description: List of VLANs definitions. + purge: + description: + - Purge VLANs not defined in the I(aggregate) parameter. + type: bool + default: no + state: + description: + - State of the VLAN configuration. + default: present + choices: ['present', 'absent'] +""" + +EXAMPLES = """ +- name: Create vlan + slxos_vlan: + vlan_id: 100 + name: test-vlan + state: present +- name: Add interfaces to VLAN + slxos_vlan: + vlan_id: 100 + interfaces: + - Ethernet 0/1 + - Ethernet 0/2 +- name: Delete vlan + slxos_vlan: + vlan_id: 100 + state: absent +""" + +RETURN = """ +commands: + description: The list of configuration mode commands to send to the device + returned: always + type: list + sample: + - vlan 100 + - name test-vlan +""" + +import re +import time + +from copy import deepcopy + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.network.common.utils import remove_default_spec +from ansible.module_utils.network.slxos.slxos import load_config, run_commands + + +def search_obj_in_list(vlan_id, lst): + obj = list() + for o in lst: + if o['vlan_id'] == vlan_id: + return o + return None + + +def map_obj_to_commands(updates, module): + commands = list() + want, have = updates + purge = module.params['purge'] + + for w in want: + vlan_id = w['vlan_id'] + name = w['name'] + interfaces = w['interfaces'] + state = w['state'] + + obj_in_have = search_obj_in_list(vlan_id, have) + + if state == 'absent': + if obj_in_have: + commands.append('no vlan %s' % vlan_id) + + elif state == 'present': + if not obj_in_have: + commands.append('vlan %s' % vlan_id) + if name: + commands.append('name %s' % name) + + if interfaces: + for i in interfaces: + commands.append('interface %s' % i) + commands.append('switchport') + commands.append('switchport mode access') + commands.append('switchport access vlan %s' % vlan_id) + + else: + if name: + if name != obj_in_have['name']: + commands.append('vlan %s' % vlan_id) + commands.append('name %s' % name) + + if interfaces: + if not obj_in_have['interfaces']: + for i in interfaces: + commands.append('vlan %s ' % vlan_id) + commands.append('interface %s' % i) + commands.append('switchport') + commands.append('switchport mode access') + commands.append('switchport access vlan %s' % vlan_id) + + elif set(interfaces) != set(obj_in_have['interfaces']): + missing_interfaces = list(set(interfaces) - set(obj_in_have['interfaces'])) + for i in missing_interfaces: + commands.append('vlan %s' % vlan_id) + commands.append('interface %s' % i) + commands.append('switchport') + commands.append('switchport mode access') + commands.append('switchport access vlan %s' % vlan_id) + + superfluous_interfaces = list(set(obj_in_have['interfaces']) - set(interfaces)) + for i in superfluous_interfaces: + commands.append('vlan %s' % vlan_id) + commands.append('interface %s' % i) + commands.append('switchport mode access') + commands.append('no switchport access vlan %s' % vlan_id) + + if purge: + for h in have: + obj_in_want = search_obj_in_list(h['vlan_id'], want) + if not obj_in_want and h['vlan_id'] != '1': + commands.append('no vlan %s' % h['vlan_id']) + + return commands + + +def map_params_to_obj(module): + obj = [] + aggregate = module.params.get('aggregate') + if aggregate: + for item in aggregate: + for key in item: + if item.get(key) is None: + item[key] = module.params[key] + + d = item.copy() + d['vlan_id'] = str(d['vlan_id']) + + obj.append(d) + else: + obj.append({ + 'vlan_id': str(module.params['vlan_id']), + 'name': module.params['name'], + 'interfaces': module.params['interfaces'], + 'state': module.params['state'] + }) + + return obj + + +def map_config_to_obj(module): + output = run_commands(module, ['show vlan brief']) + lines = output[0].strip().splitlines()[5:] + + if not lines: + return list() + + objs = list() + obj = {} + + for l in lines: + splitted_line = re.split(r'([0-9]+)? +(\S.{14})? +(ACTIVE|INACTIVE\(.+?\))? +(Eth .+?|Po .+?)\([ut]\)\s*$', l.rstrip()) + if len(splitted_line) == 1: + # Handle situation where VLAN is configured, but has no associated ports + inactive = re.match(r'([0-9]+)? +(\S.{14}) +INACTIVE\(no member port\)$', l.rstrip()) + if inactive: + splitted_line = ['', inactive.groups()[0], inactive.groups()[1], '', ''] + else: + continue + + splitted_line[4] = splitted_line[4].replace('Eth', 'Ethernet').replace('Po', 'Port-channel') + + if splitted_line[1] is None: + obj['interfaces'].append(splitted_line[4]) + continue + + obj = {} + obj['vlan_id'] = splitted_line[1] + obj['name'] = splitted_line[2].strip() + obj['interfaces'] = [splitted_line[4]] + + objs.append(obj) + + return objs + + +def check_declarative_intent_params(want, module): + if module.params['interfaces']: + time.sleep(module.params['delay']) + have = map_config_to_obj(module) + + for w in want: + for i in w['interfaces']: + obj_in_have = search_obj_in_list(w['vlan_id'], have) + if obj_in_have and 'interfaces' in obj_in_have and i not in obj_in_have['interfaces']: + module.fail_json(msg="Interface %s not configured on vlan %s" % (i, w['vlan_id'])) + + +def main(): + """ main entry point for module execution + """ + element_spec = dict( + vlan_id=dict(type='int'), + name=dict(), + interfaces=dict(type='list'), + delay=dict(default=10, type='int'), + state=dict(default='present', + choices=['present', 'absent']) + ) + + aggregate_spec = deepcopy(element_spec) + aggregate_spec['vlan_id'] = dict(required=True) + + # remove default in aggregate spec, to handle common arguments + remove_default_spec(aggregate_spec) + + argument_spec = dict( + aggregate=dict(type='list', elements='dict', options=aggregate_spec), + purge=dict(default=False, type='bool') + ) + + argument_spec.update(element_spec) + + required_one_of = [['vlan_id', 'aggregate']] + mutually_exclusive = [['vlan_id', 'aggregate']] + + module = AnsibleModule(argument_spec=argument_spec, + required_one_of=required_one_of, + mutually_exclusive=mutually_exclusive, + supports_check_mode=True) + warnings = list() + result = {'changed': False} + if warnings: + result['warnings'] = warnings + + want = map_params_to_obj(module) + have = map_config_to_obj(module) + commands = map_obj_to_commands((want, have), module) + result['commands'] = commands + + if commands: + if not module.check_mode: + load_config(module, commands) + result['changed'] = True + + if result['changed']: + check_declarative_intent_params(want, module) + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/slxos/fixtures/show_vlan_brief b/test/units/modules/network/slxos/fixtures/show_vlan_brief index 689e34853c..30427fa9b5 100644 --- a/test/units/modules/network/slxos/fixtures/show_vlan_brief +++ b/test/units/modules/network/slxos/fixtures/show_vlan_brief @@ -9,4 +9,8 @@ VLAN Name State Ports Clas 22 VLAN0022 INACTIVE(no member port) -200 VLAN0200 INACTIVE(member port down) Po 200(u) + +200 VLAN0200 INACTIVE(member port down) Eth 0/8(t) + Po 200(u) + +1001 VLAN1001 INACTIVE(no member port) diff --git a/test/units/modules/network/slxos/test_slxos_vlan.py b/test/units/modules/network/slxos/test_slxos_vlan.py new file mode 100644 index 0000000000..38002a4594 --- /dev/null +++ b/test/units/modules/network/slxos/test_slxos_vlan.py @@ -0,0 +1,145 @@ +# +# (c) 2018 Extreme Networks Inc. +# +# 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 + +import re +import json + +from ansible.compat.tests.mock import patch +from ansible.modules.network.slxos import slxos_vlan +from units.modules.utils import set_module_args +from .slxos_module import TestSlxosModule, load_fixture + + +class TestSlxosVlanModule(TestSlxosModule): + module = slxos_vlan + + def setUp(self): + super(TestSlxosVlanModule, self).setUp() + self._patch_run_commands = patch( + 'ansible.modules.network.slxos.slxos_vlan.run_commands' + ) + self._patch_load_config = patch( + 'ansible.modules.network.slxos.slxos_vlan.load_config' + ) + + self._run_commands = self._patch_run_commands.start() + self._load_config = self._patch_load_config.start() + + def tearDown(self): + super(TestSlxosVlanModule, self).tearDown() + self._patch_run_commands.stop() + self._patch_load_config.stop() + + def load_fixtures(self, commands=None): + config_file = 'show_vlan_brief' + self._run_commands.return_value = [load_fixture(config_file)] + self._load_config.return_value = None + + def test_slxos_vlan_id_with_name(self, *args, **kwargs): + load_fixture('show_vlan_brief') + set_module_args(dict( + vlan_id=100, + name='ONEHUNDRED' + )) + result = self.execute_module(changed=True) + self.assertEqual( + result, + { + 'commands': [ + 'vlan 100', + 'name ONEHUNDRED' + ], + 'changed': True + } + ) + + def test_slxos_vlan_with_members(self, *args, **kwargs): + set_module_args(dict( + vlan_id=100, + name='ONEHUNDRED', + interfaces=[ + 'Ethernet 0/1', + 'Ethernet 0/2' + ] + )) + result = self.execute_module(changed=True) + self.assertEqual( + result, + { + 'commands': [ + 'vlan 100', + 'name ONEHUNDRED', + 'interface Ethernet 0/1', + 'switchport', + 'switchport mode access', + 'switchport access vlan 100', + 'interface Ethernet 0/2', + 'switchport', + 'switchport mode access', + 'switchport access vlan 100' + ], + 'changed': True + } + ) + + def test_slxos_vlan_state_absent(self, *args, **kwargs): + set_module_args(dict( + vlan_id=200, + state='absent' + )) + result = self.execute_module(changed=True) + self.assertEqual( + result, + { + 'commands': [ + 'no vlan 200' + ], + 'changed': True + } + ) + + def test_slxos_vlan_state_absent_nonexistant_vlan(self, *args, **kwargs): + set_module_args(dict( + vlan_id=100, + state='absent' + )) + result = self.execute_module() + self.assertEqual( + result, + { + 'commands': [], + 'changed': False + } + ) + + def test_slxos_interface_invalid_argument(self, *args, **kwargs): + set_module_args(dict( + name='Ethernet 0/1', + shawshank='Redemption' + )) + result = self.execute_module(failed=True) + self.assertEqual(result['failed'], True) + self.assertTrue(re.match( + r'Unsupported parameters for \((basic.py|basic.pyc)\) module: ' + 'shawshank Supported parameters include: aggregate, delay, ' + 'interfaces, name, purge, state, vlan_id', + result['msg'] + ), 'Result did not match expected output. Got: %s' % result['msg'])