diff --git a/lib/ansible/module_utils/network/mlnxos/mlnxos.py b/lib/ansible/module_utils/network/mlnxos/mlnxos.py index 7c461e63861..b389d35fbf8 100644 --- a/lib/ansible/module_utils/network/mlnxos/mlnxos.py +++ b/lib/ansible/module_utils/network/mlnxos/mlnxos.py @@ -17,9 +17,9 @@ # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # +import json from ansible.module_utils._text import to_text -from ansible.module_utils.basic import env_fallback from ansible.module_utils.connection import Connection, ConnectionError from ansible.module_utils.network.common.utils import to_list, EntityCollection @@ -77,3 +77,153 @@ def load_config(module, config): conn.edit_config(config) except ConnectionError as exc: module.fail_json(msg=to_text(exc)) + + +def _parse_json_output(out): + out_list = out.split('\n') + first_index = 0 + opening_char = None + lines_count = len(out_list) + while first_index < lines_count: + first_line = out_list[first_index].strip() + if not first_line or first_line[0] not in ("[", "{"): + first_index += 1 + continue + opening_char = first_line[0] + break + if not opening_char: + return "null" + closing_char = ']' if opening_char == '[' else '}' + last_index = lines_count - 1 + found = False + while last_index > first_index: + last_line = out_list[last_index].strip() + if not last_line or last_line[0] != closing_char: + last_index -= 1 + continue + found = True + break + if not found: + return opening_char + closing_char + return "".join(out_list[first_index:last_index + 1]) + + +def show_cmd(module, cmd, json_fmt=True, fail_on_error=True): + if json_fmt: + cmd += " | json-print" + conn = get_connection(module) + command_obj = to_commands(module, to_list(cmd))[0] + try: + out = conn.get(**command_obj) + except ConnectionError: + if fail_on_error: + raise + return None + + if json_fmt: + out = _parse_json_output(out) + try: + cfg = json.loads(out) + except ValueError: + module.fail_json( + msg="got invalid json", + stderr=to_text(out, errors='surrogate_then_replace')) + else: + cfg = to_text(out, errors='surrogate_then_replace').strip() + return cfg + + +def get_interfaces_config(module, interface_type, flags=None, json_fmt=True): + cmd = "show interfaces %s" % interface_type + if flags: + cmd += " %s" % flags + return show_cmd(module, cmd, json_fmt) + + +def get_bgp_summary(module): + cmd = "show ip bgp summary" + return show_cmd(module, cmd, json_fmt=False, fail_on_error=False) + + +class BaseMlnxosModule(object): + + def __init__(self): + self._module = None + self._commands = list() + self._current_config = None + self._required_config = None + + def init_module(self): + pass + + def load_current_config(self): + pass + + def get_required_config(self): + pass + + # pylint: disable=unused-argument + def check_declarative_intent_params(self, result): + return None + + def validate_param_values(self, obj, param=None): + if param is None: + param = self._module.params + for key in obj: + # validate the param value (if validator func exists) + try: + validator = getattr(self, 'validate_%s' % key) + if callable(validator): + validator(param.get(key)) + except AttributeError: + pass + + @classmethod + def get_config_attr(cls, item, arg): + return item.get(arg) + + @classmethod + def get_mtu(cls, item): + mtu = cls.get_config_attr(item, "MTU") + mtu_parts = mtu.split() + try: + return int(mtu_parts[0]) + except ValueError: + return None + + def validate_mtu(self, value): + if value and not 1500 <= int(value) <= 9612: + self._module.fail_json(msg='mtu must be between 1500 and 9612') + + def generate_commands(self): + pass + + def run(self): + self.init_module() + + result = {'changed': False} + + self.get_required_config() + self.load_current_config() + + self.generate_commands() + result['commands'] = self._commands + + if self._commands: + if not self._module.check_mode: + load_config(self._module, self._commands) + result['changed'] = True + + failed_conditions = self.check_declarative_intent_params(result) + + if failed_conditions: + msg = 'One or more conditional statements have not been satisfied' + self._module.fail_json(msg=msg, + failed_conditions=failed_conditions) + + self._module.exit_json(**result) + + @classmethod + def main(cls): + app = cls() + app.run() diff --git a/lib/ansible/modules/network/mlnxos/mlnxos_interface.py b/lib/ansible/modules/network/mlnxos/mlnxos_interface.py new file mode 100644 index 00000000000..3d84a7b0664 --- /dev/null +++ b/lib/ansible/modules/network/mlnxos/mlnxos_interface.py @@ -0,0 +1,404 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# (c) 2017, Ansible by Red Hat, inc +# +# This file is part of Ansible by Red Hat +# +# 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': 'network'} + +DOCUMENTATION = """ +--- +module: mlnxos_interface +version_added: "2.5" +author: "Samer Deeb (@samerd)" +short_description: Manage Interfaces on Mellanox MLNX-OS network devices +description: + - This module provides declarative management of Interfaces + on Mellanox MLNX-OS network devices. +notes: +options: + name: + description: + - Name of the Interface. + required: true + description: + description: + - Description of Interface. + enabled: + description: + - Interface link status. + type: bool + speed: + description: + - Interface link speed. + choices: ['1G', '10G', '25G', '40G', '50G', '56G', '100G'] + mtu: + description: + - Maximum size of transmit packet. + aggregate: + description: List of Interfaces definitions. + duplex: + description: + - Interface link status + default: auto + choices: ['full', 'half', 'auto'] + tx_rate: + description: + - Transmit rate in bits per second (bps). + rx_rate: + description: + - Receiver rate in bits per second (bps). + delay: + description: + - Time in seconds to wait before checking for the operational state on + remote device. This wait is applicable for operational state argument + which are I(state) with values C(up)/C(down). + default: 10 + purge: + description: + - Purge Interfaces not defined in the aggregate parameter. + This applies only for logical interface. + default: no + state: + description: + - State of the Interface configuration, C(up) means present and + operationally up and C(down) means present and operationally C(down) + default: present + choices: ['present', 'absent', 'up', 'down'] +""" + +EXAMPLES = """ +- name: configure interface + mlnxos_interface: + name: Eth1/2 + description: test-interface + speed: 100 GB + mtu: 512 + +- name: make interface up + mlnxos_interface: + name: Eth1/2 + enabled: True + +- name: make interface down + mlnxos_interface: + name: Eth1/2 + enabled: False + +- name: Check intent arguments + mlnxos_interface: + name: Eth1/2 + state: up + +- name: Config + intent + mlnxos_interface: + name: Eth1/2 + enabled: False + state: down +""" + +RETURN = """ +commands: + description: The list of configuration mode commands to send to the device. + returned: always + type: list + sample: + - interface Eth1/2 + - description test-interface + - mtu 512 +""" + +from copy import deepcopy +import re +from time import sleep + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.network.common.utils import conditional, \ + remove_default_spec + +from ansible.module_utils.network.mlnxos.mlnxos import BaseMlnxosModule, \ + get_interfaces_config + + +class MlnxosInterfaceModule(BaseMlnxosModule): + ETH_IF_NAME_REGEX = re.compile(r'^Eth(\d\/\d+(|\/\d))$') + + @classmethod + def _get_element_spec(cls): + return dict( + name=dict(type='str'), + description=dict(), + speed=dict(choices=['1G', '10G', '25G', '40G', '50G', '56G', '100G']), + mtu=dict(type='int'), + enabled=dict(default=True, type='bool'), + delay=dict(default=10, type='int'), + state=dict(default='present', + choices=['present', 'absent', 'up', 'down']), + tx_rate=dict(), + rx_rate=dict(), + ) + + @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): + """ main entry point for module execution + """ + 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 validate_name(self, value): + if not self.ETH_IF_NAME_REGEX.match(value): + self._module.fail_json(msg='Invalid interface name!') + + def validate_purge(self, value): + if value: + self._module.fail_json( + msg='Purge is not supported for ethernet interfaces!') + + def validate_duplex(self, value): + if value != 'auto': + self._module.fail_json( + msg='Duplex is not supported for ethernet interfaces') + + def validate_state(self, value): + if value == 'absent': + self._module.fail_json( + msg='Cannot remove physical interfaces') + + def get_required_config(self): + self._required_config = list() + module_params = self._module.params + 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] + + self.validate_param_values(item, item) + req_item = item.copy() + self._required_config.append(req_item) + + else: + params = { + 'name': module_params['name'], + 'description': module_params['description'], + 'speed': module_params['speed'], + 'mtu': module_params['mtu'], + 'state': module_params['state'], + 'delay': module_params['delay'], + 'enabled': module_params['enabled'], + 'tx_rate': module_params['tx_rate'], + 'rx_rate': module_params['rx_rate'], + } + + self.validate_param_values(params) + self._required_config.append(params) + + @classmethod + def get_if_name(cls, item): + return cls.get_config_attr(item, "header") + + @classmethod + def get_if_cmd(cls, if_name): + return if_name.replace("Eth", "interface ethernet ") + + @classmethod + def get_admin_state(cls, item): + admin_state = cls.get_config_attr(item, "Admin state") + return str(admin_state).lower() == "enabled" + + @classmethod + def get_oper_state(cls, item): + oper_state = cls.get_config_attr(item, "Operational state") + return str(oper_state).lower() + + def add_command_to_interface(self, interface, cmd): + if interface not in self._commands: + self._commands.append(interface) + self._commands.append(cmd) + + def get_speed(self, item): + speed = self.get_config_attr(item, 'Actual speed') + try: + speed = int(speed.split()[0]) + return "%dG" % speed + except ValueError: + return None + + def _create_if_data(self, name, item): + return { + 'name': name, + 'description': self.get_config_attr(item, 'Description'), + 'speed': self.get_speed(item), + 'mtu': self.get_mtu(item), + 'enabled': self.get_admin_state(item), + 'state': self.get_oper_state(item) + } + + def _get_interfaces_config(self): + return get_interfaces_config(self._module, "ethernet") + + def load_current_config(self): + self._current_config = dict() + config = self._get_interfaces_config() + + for item in config: + name = self.get_if_name(item) + self._current_config[name] = self._create_if_data(name, item) + + def generate_commands(self): + for req_if in self._required_config: + name = req_if['name'] + curr_if = self._current_config.get(name) + if not curr_if: + self._module.fail_json( + msg='could not find interface %s' % name) + continue + self._generate_if_commands(name, req_if, curr_if) + + def _generate_if_commands(self, name, req_if, curr_if): + args = ('speed', 'description', 'mtu') + enabled = req_if['enabled'] + add_exit = False + interface_prefix = self.get_if_cmd(name) + + for attr_name in args: + candidate = req_if.get(attr_name) + running = curr_if.get(attr_name) + if candidate != running: + if candidate: + cmd = attr_name + ' ' + str(candidate) + if attr_name in ('mtu', 'speed'): + cmd = cmd + ' ' + 'force' + self.add_command_to_interface(interface_prefix, cmd) + add_exit = True + curr_enabled = curr_if.get('enabled', False) + if enabled != curr_enabled: + cmd = 'shutdown' + if enabled: + cmd = "no %s" % cmd + self.add_command_to_interface(interface_prefix, cmd) + add_exit = True + if add_exit: + self._commands.append('exit') + + def _get_interfaces_rates(self): + return get_interfaces_config(self._module, "ethernet", "rates") + + def _get_interfaces_status(self): + return get_interfaces_config(self._module, "ethernet", "status") + + def check_declarative_intent_params(self, result): + failed_conditions = [] + delay_called = False + rates = None + statuses = None + for req_if in self._required_config: + want_state = req_if.get('state') + want_tx_rate = req_if.get('tx_rate') + want_rx_rate = req_if.get('rx_rate') + name = req_if['name'] + if want_state not in ('up', 'down') and not want_tx_rate and not \ + want_rx_rate: + continue + if not delay_called and result['changed']: + delay_called = True + delay = req_if['delay'] + if delay > 0: + sleep(delay) + if want_state in ('up', 'down'): + if not statuses: + statuses = self._get_interfaces_status() + curr_if = statuses.get(name) + curr_state = None + if curr_if: + curr_if = curr_if[0] + curr_state = self.get_oper_state(curr_if) + if curr_state is None or not \ + conditional(want_state, curr_state.strip()): + failed_conditions.append( + 'state ' + 'eq(%s)' % want_state) + if_rates = None + if want_tx_rate or want_rx_rate: + if not rates: + rates = self._get_interfaces_rates() + if_rates = rates.get(name) + if if_rates: + if_rates = if_rates[0] + if want_tx_rate: + have_tx_rate = None + if if_rates: + have_tx_rate = if_rates.get('egress rate') + if have_tx_rate: + have_tx_rate = have_tx_rate.split()[0] + if have_tx_rate is None or not \ + conditional(want_tx_rate, have_tx_rate.strip(), + cast=int): + failed_conditions.append('tx_rate ' + want_tx_rate) + + if want_rx_rate: + have_rx_rate = None + if if_rates: + have_rx_rate = if_rates.get('ingress rate') + if have_rx_rate: + have_rx_rate = have_rx_rate.split()[0] + if have_rx_rate is None or not \ + conditional(want_rx_rate, have_rx_rate.strip(), + cast=int): + failed_conditions.append('rx_rate ' + want_rx_rate) + + return failed_conditions + + +def main(): + """ main entry point for module execution + """ + MlnxosInterfaceModule.main() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_rates.cfg b/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_rates.cfg new file mode 100644 index 00000000000..11131cc5e5e --- /dev/null +++ b/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_rates.cfg @@ -0,0 +1,10 @@ +{ + "Eth1/1": [ + { + "ingress rate": "9000 b/s", + "egress pkts/sec": "10", + "egress rate": "10000 b/s", + "ingress pkts/sec": "10" + } + ] +} \ No newline at end of file diff --git a/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_show.cfg b/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_show.cfg new file mode 100644 index 00000000000..1ba7bf93d3e --- /dev/null +++ b/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_show.cfg @@ -0,0 +1,44 @@ +[ + { + "Fec": "auto", + "Mac address": "7c:fe:90:f0:54:fc", + "60 seconds ingress rate": "0 bits/sec, 0 bytes/sec, 0 packets/sec", + "Last clearing of \"show interface\" counters": "Never", + "Actual speed": "40 Gbps", + "MTU": "1500 bytes(Maximum packet size 1522 bytes)", + "header": "Eth1/1", + "Telemetry threshold": "Disabled\t TCs: N\\A", + "Telemetry threshold level": "N\\A", + "Flow-control": "receive off send off", + "Forwarding mode": "inherited cut-through", + "60 seconds egress rate": "0 bits/sec, 0 bytes/sec, 0 packets/sec", + "Last change in operational status": "Never", + "Boot delay time": "0 sec", + "Description": "N\\A", + "Admin state": "Enabled", + "Telemetry sampling": "Disabled\t TCs: N\\A", + "Operational state": "Down", + "Width reduction mode": "Not supported", + "Tx": { + "error packets": "0", + "packets": "0", + "bytes": "0", + "multicast packets": "0", + "unicast packets": "0", + "discard packets": "0", + "hoq discard packets": "0", + "broadcast packets": "0" + }, + "MAC learning mode": "Enabled", + "Switchport mode": "access", + "Rx": { + "error packets": "0", + "packets": "0", + "bytes": "0", + "multicast packets": "0", + "unicast packets": "0", + "discard packets": "0", + "broadcast packets": "0" + } + } +] diff --git a/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_status.cfg b/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_status.cfg new file mode 100644 index 00000000000..4946f940e78 --- /dev/null +++ b/test/units/modules/network/mlnxos/fixtures/mlnxos_interfaces_status.cfg @@ -0,0 +1,9 @@ +{ + "Eth1/1": [ + { + "Negotiation": "No-Negotiation", + "Operational state": "Down", + "Speed": "100 Gbps" + } + ] +} \ No newline at end of file diff --git a/test/units/modules/network/mlnxos/test_mlnxos_interface.py b/test/units/modules/network/mlnxos/test_mlnxos_interface.py new file mode 100644 index 00000000000..a3bd7dc7695 --- /dev/null +++ b/test/units/modules/network/mlnxos/test_mlnxos_interface.py @@ -0,0 +1,114 @@ +# +# (c) 2016 Red Hat 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 . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json + +from ansible.compat.tests.mock import patch +from ansible.modules.network.mlnxos import mlnxos_interface +from ansible.module_utils.network.mlnxos import mlnxos as mlnxos_utils +from units.modules.utils import set_module_args +from .mlnxos_module import TestMlnxosModule, load_fixture + + +class TestMlnxosInterfaceModule(TestMlnxosModule): + + module = mlnxos_interface + + def setUp(self): + super(TestMlnxosInterfaceModule, self).setUp() + self.mock_get_config = patch.object( + mlnxos_interface.MlnxosInterfaceModule, "_get_interfaces_config") + self.get_config = self.mock_get_config.start() + + self.mock_get_interfaces_status = patch.object( + mlnxos_interface.MlnxosInterfaceModule, "_get_interfaces_status") + self.get_interfaces_status = self.mock_get_interfaces_status.start() + + self.mock_get_interfaces_rates = patch.object( + mlnxos_interface.MlnxosInterfaceModule, "_get_interfaces_rates") + self.get_interfaces_rates = self.mock_get_interfaces_rates.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(TestMlnxosInterfaceModule, self).tearDown() + self.mock_get_config.stop() + self.mock_load_config.stop() + + def load_fixtures(self, commands=None, transport='cli'): + config_file = 'mlnxos_interfaces_show.cfg' + self.get_config.return_value = load_fixture(config_file) + self.load_config.return_value = None + + def test_mtu_no_change(self): + set_module_args(dict(name='Eth1/1', mtu=1500)) + self.execute_module(changed=False) + + def test_mtu_change(self): + set_module_args(dict(name='Eth1/1', mtu=1522)) + commands = ['interface ethernet 1/1', 'mtu 1522 force', 'exit'] + self.execute_module(changed=True, commands=commands) + + def test_speed_no_change(self): + set_module_args(dict(name='Eth1/1', speed='40G')) + self.execute_module(changed=False) + + def test_speed_change(self): + set_module_args(dict(name='Eth1/1', speed='100G')) + commands = ['interface ethernet 1/1', 'speed 100G force', 'exit'] + self.execute_module(changed=True, commands=commands) + + def test_mtu_speed_change(self): + set_module_args(dict(name='Eth1/1', speed='100G', mtu=1522)) + commands = ['interface ethernet 1/1', 'speed 100G force', + 'mtu 1522 force', 'exit'] + self.execute_module(changed=True, commands=commands) + + def test_admin_state_no_change(self): + set_module_args(dict(name='Eth1/1', enabled=True)) + self.execute_module(changed=False) + + def test_admin_state_change(self): + set_module_args(dict(name='Eth1/1', enabled=False)) + commands = ['interface ethernet 1/1', 'shutdown', 'exit'] + self.execute_module(changed=True, commands=commands) + + def test_oper_state_check(self): + set_module_args(dict(name='Eth1/1', enabled=True, state='down')) + config_file = 'mlnxos_interfaces_status.cfg' + self.get_interfaces_status.return_value = load_fixture(config_file) + commands = ['interface ethernet 1/1', 'shutdown', 'exit'] + self.execute_module(changed=False) + + def test_rx_rate_check(self): + set_module_args(dict(name='Eth1/1', enabled=True, rx_rate='ge(9000)')) + config_file = 'mlnxos_interfaces_rates.cfg' + self.get_interfaces_rates.return_value = load_fixture(config_file) + self.execute_module(changed=False) + + def test_tx_rate_check(self): + set_module_args(dict(name='Eth1/1', enabled=True, tx_rate='ge(10000)')) + config_file = 'mlnxos_interfaces_rates.cfg' + self.get_interfaces_rates.return_value = load_fixture(config_file) + self.execute_module(changed=False)