diff --git a/lib/ansible/modules/network/mlnxos/mlnxos_linkagg.py b/lib/ansible/modules/network/mlnxos/mlnxos_linkagg.py new file mode 100644 index 0000000000..e7ed0a2aed --- /dev/null +++ b/lib/ansible/modules/network/mlnxos/mlnxos_linkagg.py @@ -0,0 +1,343 @@ +#!/usr/bin/python +# +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = """ +--- +module: mlnxos_linkagg +version_added: "2.5" +author: "Samer Deeb (@samerd)" +short_description: Manage link aggregation groups on Mellanox MLNX-OS network devices +description: + - This module provides declarative management of link aggregation groups + on Mellanox MLNX-OS network devices. +options: + name: + description: + - Name of the link aggregation group. + required: true + mode: + description: + - Mode of the link aggregation group. A value of C(on) will enable LACP. + C(active) configures the link to actively information about the state of the link, + or it can be configured in C(passive) mode ie. send link state information only when + received them from another link. + default: on + choices: ['on', 'active', 'passive'] + members: + description: + - List of members interfaces of the link aggregation group. The value can be + single interface or list of interfaces. + required: true + aggregate: + description: List of link aggregation definitions. + purge: + description: + - Purge link aggregation groups not defined in the I(aggregate) parameter. + default: no + state: + description: + - State of the link aggregation group. + default: present + choices: ['present', 'absent', 'up', 'down'] +""" + +EXAMPLES = """ +- name: configure link aggregation group + mlnxos_linkagg: + name: Po1 + members: + - Eth1/1 + - Eth1/2 + +- name: remove configuration + mlnxos_linkagg: + name: Po1 + state: absent + +- name: Create aggregate of linkagg definitions + mlnxos_linkagg: + aggregate: + - { name: Po1, members: [Eth1/1] } + - { name: Po2, members: [Eth1/2] } + +- name: Remove aggregate of linkagg definitions + mlnxos_linkagg: + aggregate: + - name: Po1 + - name: Po2 + state: absent +""" + +RETURN = """ +commands: + description: The list of configuration mode commands to send to the device + returned: always. + type: list + sample: + - interface port-channel 1 + - exit + - interface ethernet 1/1 channel-group 1 mode on + - interface ethernet 1/2 channel-group 1 mode on +""" + +import re +from copy import deepcopy + +from ansible.module_utils.network.common.utils import remove_default_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import iteritems + +from ansible.module_utils.network.mlnxos.mlnxos import BaseMlnxosModule +from ansible.module_utils.network.mlnxos.mlnxos import get_interfaces_config + + +class MlnxosLinkAggModule(BaseMlnxosModule): + LAG_ID_REGEX = re.compile(r"^\d+ (Po\d+|Mpo\d+)\(([A-Z])\)$") + LAG_NAME_REGEX = re.compile(r"^(Po|Mpo)(\d+)$") + IF_NAME_REGEX = re.compile(r"^(Eth\d+\/\d+|Eth\d+\/\d+\/\d+)(.*)$") + PORT_CHANNEL = 'port-channel' + CHANNEL_GROUP = 'channel-group' + MLAG_PORT_CHANNEL = 'mlag-port-channel' + MLAG_CHANNEL_GROUP = 'mlag-channel-group' + + LAG_TYPE = 'lag' + MLAG_TYPE = 'mlag' + + IF_TYPE_MAP = dict( + lag=PORT_CHANNEL, + mlag=MLAG_PORT_CHANNEL + ) + + _purge = False + + @classmethod + def _get_element_spec(cls): + return dict( + name=dict(type='str'), + members=dict(type='list'), + mode=dict(default='on', choices=['active', 'on', 'passive']), + state=dict(default='present', choices=['present', 'absent']), + ) + + @classmethod + def _get_aggregate_spec(cls, element_spec): + aggregate_spec = deepcopy(element_spec) + aggregate_spec['name'] = dict(required=True) + + # remove default in aggregate spec, to handle common arguments + remove_default_spec(aggregate_spec) + return aggregate_spec + + def init_module(self): + """ module initialization + """ + element_spec = self._get_element_spec() + aggregate_spec = self._get_aggregate_spec(element_spec) + if aggregate_spec: + argument_spec = dict( + aggregate=dict(type='list', elements='dict', + options=aggregate_spec), + purge=dict(default=False, type='bool'), + ) + else: + argument_spec = dict() + argument_spec.update(element_spec) + required_one_of = [['name', 'aggregate']] + mutually_exclusive = [['name', 'aggregate']] + self._module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=required_one_of, + mutually_exclusive=mutually_exclusive, + supports_check_mode=True) + + def _get_lag_type(self, lag_name): + match = self.LAG_NAME_REGEX.match(lag_name) + if match: + prefix = match.group(1) + if prefix == "Po": + return self.LAG_TYPE + return self.MLAG_TYPE + self._module.fail_json( + msg='invalid lag name: %s, lag name should start with Po or ' + 'Mpo' % lag_name) + + def get_required_config(self): + self._required_config = list() + module_params = self._module.params + aggregate = module_params.get('aggregate') + self._purge = module_params.get('purge', False) + if aggregate: + for item in aggregate: + for key in item: + if item.get(key) is None: + item[key] = module_params[key] + self.validate_param_values(item, item) + req_item = item.copy() + req_item['type'] = self._get_lag_type(req_item['name']) + self._required_config.append(req_item) + else: + params = { + 'name': module_params['name'], + 'state': module_params['state'], + 'members': module_params['members'], + 'mode': module_params['mode'], + 'type': self._get_lag_type(module_params['name']), + } + self.validate_param_values(params) + self._required_config.append(params) + + @classmethod + def _extract_lag_name(cls, header): + match = cls.LAG_ID_REGEX.match(header) + state = None + lag_name = None + if match: + state = 'up' if match.group(2) == 'U' else 'down' + lag_name = match.group(1) + return lag_name, state + + @classmethod + def _extract_if_name(cls, member): + match = cls.IF_NAME_REGEX.match(member) + if match: + return match.group(1) + + @classmethod + def _extract_lag_members(cls, lag_type, lag_item): + members = "" + if lag_type == cls.LAG_TYPE: + members = cls.get_config_attr(lag_item, "Member Ports") + else: + for attr_name, attr_val in iteritems(lag_item): + if attr_name.startswith('Local Ports'): + members = attr_val + return [cls._extract_if_name(member) for member in members.split()] + + def _get_port_channels(self, if_type): + return get_interfaces_config(self._module, if_type, flags="summary") + + def _parse_port_channels_summary(self, lag_type, lag_summary): + if lag_type == self.MLAG_TYPE: + lag_summary = lag_summary.get('MLAG Port-Channel Summary', {}) + for lag_key, lag_data in iteritems(lag_summary): + lag_name, state = self._extract_lag_name(lag_key) + if not lag_name: + continue + lag_members = self._extract_lag_members(lag_type, lag_data[0]) + lag_obj = dict( + name=lag_name, + state=state, + members=lag_members + ) + self._current_config[lag_name] = lag_obj + + def load_current_config(self): + self._current_config = dict() + lag_types = set([lag_obj['type'] for lag_obj in self._required_config]) + for lag_type in lag_types: + if_type = self.IF_TYPE_MAP[lag_type] + lag_summary = self._get_port_channels(if_type) + if lag_summary: + self._parse_port_channels_summary(lag_type, lag_summary) + with open('/tmp/linagg.txt', 'w') as fp: + fp.write('current_config: %s\n' % self._current_config) + fp.write('required_config: %s\n' % self._required_config) + + def _get_interface_command_suffix(self, if_name): + if if_name.startswith('Eth'): + return if_name.replace("Eth", "ethernet ") + if if_name.startswith('Po'): + return if_name.replace("Po", "port-channel ") + if if_name.startswith('Mpo'): + return if_name.replace("Mpo", "mlag-port-channel ") + self._module.fail_json( + msg='invalid interface name: %s' % if_name) + + def _get_channel_group(self, if_name): + if if_name.startswith('Po'): + return if_name.replace("Po", "channel-group ") + if if_name.startswith('Mpo'): + return if_name.replace("Mpo", "mlag-channel-group ") + self._module.fail_json( + msg='invalid interface name: %s' % if_name) + + def _generate_no_linkagg_commands(self, lag_name): + suffix = self._get_interface_command_suffix(lag_name) + command = 'no interface %s' % suffix + self._commands.append(command) + + def _generate_linkagg_commands(self, lag_name, req_lag): + curr_lag = self._current_config.get(lag_name, {}) + if not curr_lag: + suffix = self._get_interface_command_suffix(lag_name) + self._commands.append("interface %s" % suffix) + self._commands.append("exit") + curr_members = set(curr_lag.get('members', [])) + req_members = set(req_lag.get('members') or []) + + lag_mode = req_lag['mode'] + if req_members != curr_members: + channel_group = self._get_channel_group(lag_name) + channel_group_type = channel_group.split()[0] + for member in req_members: + if member in curr_members: + continue + suffix = self._get_interface_command_suffix(member) + self._commands.append( + "interface %s %s mode %s" % + (suffix, channel_group, lag_mode)) + for member in curr_members: + if member in req_members: + continue + suffix = self._get_interface_command_suffix(member) + self._commands.append( + "interface %s no %s" % (suffix, channel_group_type)) + req_state = req_lag.get('state') + if req_state in ('up', 'down'): + curr_state = curr_lag.get('state') + if curr_state != req_state: + suffix = self._get_interface_command_suffix(lag_name) + cmd = "interface %s " % suffix + if req_state == 'up': + cmd += 'no shutdown' + else: + cmd += 'shutdown' + self._commands.append(cmd) + + def generate_commands(self): + req_lags = set() + for req_conf in self._required_config: + state = req_conf['state'] + lag_name = req_conf['name'] + if state == 'absent': + if lag_name in self._current_config: + self._generate_no_linkagg_commands(lag_name) + else: + req_lags.add(lag_name) + self._generate_linkagg_commands(lag_name, req_conf) + if self._purge: + for lag_name in self._current_config: + if lag_name not in req_lags: + self._generate_no_linkagg_commands(lag_name) + + def check_declarative_intent_params(self, result): + pass + + +def main(): + """ main entry point for module execution + """ + MlnxosLinkAggModule.main() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/mlnxos/fixtures/mlnxos_mlag_port_channel_show.cfg b/test/units/modules/network/mlnxos/fixtures/mlnxos_mlag_port_channel_show.cfg new file mode 100644 index 0000000000..e883ecf38d --- /dev/null +++ b/test/units/modules/network/mlnxos/fixtures/mlnxos_mlag_port_channel_show.cfg @@ -0,0 +1,18 @@ +{ + "MLAG Port-Channel Flags": "D-Down, U-Up, P-Partial UP, S - suspended by MLAG", + "Port Flags": { + "I": "Individual", + "P": "Up in port-channel (members)", + "S": "Suspend in port-channel (members)", + "D": "Down" + }, + "MLAG Port-Channel Summary": { + "1 Mpo33(S)": [ + { + "Local Ports (D/P/S/I)": "Eth1/8(D)", + "Peer Ports (D/P/S/I)": "N/A", + "Type": "LACP" + } + ] + } +} diff --git a/test/units/modules/network/mlnxos/fixtures/mlnxos_port_channel_show.cfg b/test/units/modules/network/mlnxos/fixtures/mlnxos_port_channel_show.cfg new file mode 100644 index 0000000000..805853f46e --- /dev/null +++ b/test/units/modules/network/mlnxos/fixtures/mlnxos_port_channel_show.cfg @@ -0,0 +1,15 @@ +{ + "Flags": { + "I": "Individual", + "P": "Up in port-channel (members)", + "S": "Suspend in port-channel (members)", + "U": "Up", + "D": "Down" + }, + "1 Po22(D)": [ + { + "Type": "STATIC", + "Member Ports": "Eth1/7(D)" + } + ] +} diff --git a/test/units/modules/network/mlnxos/test_mlnxos_linkagg.py b/test/units/modules/network/mlnxos/test_mlnxos_linkagg.py new file mode 100644 index 0000000000..88e31607a0 --- /dev/null +++ b/test/units/modules/network/mlnxos/test_mlnxos_linkagg.py @@ -0,0 +1,111 @@ +# +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.compat.tests.mock import patch +from ansible.modules.network.mlnxos import mlnxos_linkagg +from units.modules.utils import set_module_args +from .mlnxos_module import TestMlnxosModule, load_fixture + + +class TestMlnxosLinkaggModule(TestMlnxosModule): + + module = mlnxos_linkagg + + def setUp(self): + super(TestMlnxosLinkaggModule, self).setUp() + self.mock_get_config = patch.object( + mlnxos_linkagg.MlnxosLinkAggModule, + "_get_port_channels") + self.get_config = self.mock_get_config.start() + + self.mock_load_config = patch( + 'ansible.module_utils.network.mlnxos.mlnxos.load_config') + self.load_config = self.mock_load_config.start() + + def tearDown(self): + super(TestMlnxosLinkaggModule, self).tearDown() + self.mock_get_config.stop() + self.mock_load_config.stop() + + def load_fixture(self, config_file): + self.get_config.return_value = load_fixture(config_file) + self.load_config.return_value = None + + def load_port_channel_fixture(self): + config_file = 'mlnxos_port_channel_show.cfg' + self.load_fixture(config_file) + + def load_mlag_port_channel_fixture(self): + config_file = 'mlnxos_mlag_port_channel_show.cfg' + self.load_fixture(config_file) + + def test_port_channel_no_change(self): + set_module_args(dict(name='Po22', state='present', + members=['Eth1/7'])) + self.load_port_channel_fixture() + self.execute_module(changed=False) + + def test_port_channel_remove(self): + set_module_args(dict(name='Po22', state='absent')) + self.load_port_channel_fixture() + commands = ['no interface port-channel 22'] + self.execute_module(changed=True, commands=commands) + + def test_port_channel_add(self): + set_module_args(dict(name='Po23', state='present', + members=['Eth1/8'])) + self.load_port_channel_fixture() + commands = ['interface port-channel 23', 'exit', + 'interface ethernet 1/8 channel-group 23 mode on'] + self.execute_module(changed=True, commands=commands) + + def test_port_channel_add_member(self): + set_module_args(dict(name='Po22', state='present', + members=['Eth1/7', 'Eth1/8'])) + self.load_port_channel_fixture() + commands = ['interface ethernet 1/8 channel-group 22 mode on'] + self.execute_module(changed=True, commands=commands) + + def test_port_channel_remove_member(self): + set_module_args(dict(name='Po22', state='present')) + self.load_port_channel_fixture() + commands = ['interface ethernet 1/7 no channel-group'] + self.execute_module(changed=True, commands=commands) + + def test_mlag_port_channel_no_change(self): + set_module_args(dict(name='Mpo33', state='present', + members=['Eth1/8'])) + self.load_mlag_port_channel_fixture() + self.execute_module(changed=False) + + def test_mlag_port_channel_remove(self): + set_module_args(dict(name='Mpo33', state='absent')) + self.load_mlag_port_channel_fixture() + commands = ['no interface mlag-port-channel 33'] + self.execute_module(changed=True, commands=commands) + + def test_mlag_port_channel_add(self): + set_module_args(dict(name='Mpo34', state='present', + members=['Eth1/9'])) + self.load_mlag_port_channel_fixture() + commands = ['interface mlag-port-channel 34', 'exit', + 'interface ethernet 1/9 mlag-channel-group 34 mode on'] + self.execute_module(changed=True, commands=commands) + + def test_mlag_port_channel_add_member(self): + set_module_args(dict(name='Mpo33', state='present', + members=['Eth1/8', 'Eth1/9'])) + self.load_mlag_port_channel_fixture() + commands = ['interface ethernet 1/9 mlag-channel-group 33 mode on'] + self.execute_module(changed=True, commands=commands) + + def test_mlag_port_channel_remove_member(self): + set_module_args(dict(name='Mpo33', state='present')) + self.load_mlag_port_channel_fixture() + commands = ['interface ethernet 1/8 no mlag-channel-group'] + self.execute_module(changed=True, commands=commands)