From 663f8464ee7ab2cb086857f04393e643407fba0f Mon Sep 17 00:00:00 2001 From: Trishna Guha Date: Wed, 20 Dec 2017 19:43:30 +0530 Subject: [PATCH] eos_linkagg DI module (#33007) * eos_linkagg DI module Signed-off-by: Trishna Guha * integration test Signed-off-by: Trishna Guha * pep8 fixes * Push fix for Qalthos's comment Signed-off-by: Trishna Guha --- .../modules/network/eos/eos_linkagg.py | 343 ++++++++++++++++++ test/integration/eos.yaml | 8 + .../targets/eos_linkagg/defaults/main.yaml | 3 + .../targets/eos_linkagg/meta/main.yaml | 2 + .../targets/eos_linkagg/tasks/cli.yaml | 22 ++ .../targets/eos_linkagg/tasks/main.yaml | 2 + .../targets/eos_linkagg/tests/cli/basic.yaml | 200 ++++++++++ 7 files changed, 580 insertions(+) create mode 100644 lib/ansible/modules/network/eos/eos_linkagg.py create mode 100644 test/integration/targets/eos_linkagg/defaults/main.yaml create mode 100644 test/integration/targets/eos_linkagg/meta/main.yaml create mode 100644 test/integration/targets/eos_linkagg/tasks/cli.yaml create mode 100644 test/integration/targets/eos_linkagg/tasks/main.yaml create mode 100644 test/integration/targets/eos_linkagg/tests/cli/basic.yaml diff --git a/lib/ansible/modules/network/eos/eos_linkagg.py b/lib/ansible/modules/network/eos/eos_linkagg.py new file mode 100644 index 00000000000..45fd02d53fd --- /dev/null +++ b/lib/ansible/modules/network/eos/eos_linkagg.py @@ -0,0 +1,343 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2017, Ansible by Red Hat, inc +# 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': 'network'} + +DOCUMENTATION = """ +--- +module: eos_linkagg +version_added: "2.5" +author: "Trishna Guha (@trishnaguha)" +short_description: Manage link aggregation groups on Arist EOS network devices +description: + - This module provides declarative management of link aggregation groups + on Arista EOS network devices. +notes: + - Tested against EOS 4.15 +options: + group: + description: + - Channel-group number for the port-channel + Link aggregation group. Range 1-2000. + mode: + description: + - Mode of the link aggregation group. + choices: ['active', 'on', 'passive'] + members: + description: + - List of members of the link aggregation group. + min_links: + description: + - Minimum number of ports required up + before bringing up the link aggregation group. + aggregate: + description: List of link aggregation definitions. + state: + description: + - State of the link aggregation group. + default: present + choices: ['present', 'absent'] +""" + +EXAMPLES = """ +- name: create link aggregation group + eos_linkagg: + group: 10 + state: present + +- name: delete link aggregation group + eos_linkagg: + group: 10 + state: absent + +- name: set link aggregation group to members + eos_linkagg: + group: 200 + min_links: 3 + mode: active + members: + - Ethernet0 + - Ethernet1 + +- name: remove link aggregation group from Ethernet0 + eos_linkagg: + group: 200 + min_links: 3 + mode: active + members: + - Ethernet1 + +- name: Create aggregate of linkagg definitions + eos_linkagg: + aggregate: + - { group: 3, mode: on, members: [Ethernet1] } + - { group: 100, mode: passive, min_links: 3, members: [Ethernet2] } + +- name: Remove aggregate of linkagg definitions + eos_linkagg: + aggregate: + - { group: 3, mode: on, members: [Ethernet1] } + - { group: 100, mode: passive, min_links: 3, members: [Ethernet2] } + state: absent +""" + +RETURN = """ +commands: + description: The list of configuration mode commands to send to the device + returned: always, except for the platforms that use Netconf transport to manage the device. + type: list + sample: + - interface port-channel 30 + - port-channel min-links 5 + - interface Ethernet3 + - channel-group 30 mode on + - no interface port-channel 30 +""" + +import re +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.eos.eos import get_config, load_config +from ansible.module_utils.network.eos.eos import eos_argument_spec + + +def search_obj_in_list(group, lst): + for o in lst: + if o['group'] == group: + return o + + +def map_obj_to_commands(updates, module): + commands = list() + want, have = updates + purge = module.params['purge'] + + for w in want: + group = w['group'] + mode = w['mode'] + min_links = w['min_links'] + members = w.get('members') or [] + state = w['state'] + del w['state'] + + obj_in_have = search_obj_in_list(group, have) + + if state == 'absent': + if obj_in_have: + commands.append('no interface port-channel {}'.format(group)) + + elif state == 'present': + cmd = ['interface port-channel {}'.format(group), + 'end'] + if not obj_in_have: + if not group: + module.fail_json(msg='group is a required option') + commands.extend(cmd) + + if min_links != 'None': + commands.append('port-channel min-links {}'.format(min_links)) + + if members: + for m in members: + commands.append('interface {}'.format(m)) + commands.append('channel-group {0} mode {1}'.format(group, mode)) + + else: + if members: + if 'members' not in obj_in_have.keys(): + for m in members: + commands.extend(cmd) + commands.append('interface {}'.format(m)) + commands.append('channel-group {0} mode {1}'.format(group, mode)) + + elif set(members) != set(obj_in_have['members']): + missing_members = list(set(members) - set(obj_in_have['members'])) + for m in missing_members: + commands.extend(cmd) + commands.append('interface {}'.format(m)) + commands.append('channel-group {0} mode {1}'.format(group, mode)) + + superfluous_members = list(set(obj_in_have['members']) - set(members)) + for m in superfluous_members: + commands.extend(cmd) + commands.append('interface {}'.format(m)) + commands.append('no channel-group {}'.format(group)) + + if purge: + for h in have: + obj_in_want = search_obj_in_list(h['group'], want) + if not obj_in_want: + commands.append('no interface port-channel {}'.format(h['group'])) + + return commands + + +def map_params_to_obj(module, required_together=None): + 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] + + module._check_required_together(required_together, item) + d = item.copy() + d['group'] = str(d['group']) + d['min_links'] = str(d['min_links']) + + obj.append(d) + else: + obj.append({ + 'group': str(module.params['group']), + 'mode': module.params['mode'], + 'min_links': str(module.params['min_links']), + 'members': module.params['members'], + 'state': module.params['state'] + }) + + return obj + + +def parse_mode(group, member, config): + mode = None + + for line in config.strip().split('!'): + match_int = re.findall(r'interface {}\\b'.format(member), line, re.M) + if match_int: + match = re.search(r'channel-group {} mode (\S+)'.format(group), line, re.M) + if match: + mode = match.group(1) + + return mode + + +def parse_members(group, config): + members = [] + + for line in config.strip().split('!'): + match_group = re.findall(r'channel-group {} mode'.format(group), line, re.M) + if match_group: + match = re.search(r'interface (\S+)', line, re.M) + if match: + members.append(match.group(1)) + + return members + + +def get_channel(group, module): + channel = {} + config = get_config(module, flags=['| section channel-group']) + + for line in config.split('\n'): + l = line.strip() + match = re.search(r'interface (\S+)', l, re.M) + + if match: + member = match.group(1) + channel['mode'] = parse_mode(group, member, config) + channel['members'] = parse_members(group, config) + + return channel + + +def parse_min_links(group, config): + min_links = '' + + for line in config.strip().split('!'): + match_pc = re.findall(r'interface Port-Channel{}\\b'.format(group), line, re.M) + if match_pc: + match = re.search(r'port-channel min-links (\S+)', line, re.M) + if match: + min_links = match.group(1) + + return min_links + + +def map_config_to_obj(module): + objs = list() + config = get_config(module, flags=['| section port-channel']) + + for line in config.split('\n'): + l = line.strip() + match = re.search(r'interface Port-Channel(\S+)', l, re.M) + if match: + obj = {} + group = match.group(1) + obj['group'] = group + obj['min_links'] = parse_min_links(group, config) + obj.update(get_channel(group, module)) + objs.append(obj) + + return objs + + +def main(): + """ main entry point for module execution + """ + element_spec = dict( + group=dict(type='int'), + mode=dict(choices=['active', 'on', 'passive']), + min_links=dict(type='int'), + members=dict(type='list'), + state=dict(default='present', + choices=['present', 'absent']) + ) + + aggregate_spec = deepcopy(element_spec) + aggregate_spec['group'] = 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) + argument_spec.update(eos_argument_spec) + + required_one_of = [['group', 'aggregate']] + required_together = [['members', 'mode']] + mutually_exclusive = [['group', 'aggregate']] + module = AnsibleModule(argument_spec=argument_spec, + required_one_of=required_one_of, + required_together=required_together, + 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: + commit = not module.check_mode + response = load_config(module, commands, commit=commit) + if response.get('diff') and module._diff: + result['diff'] = {'prepared': response.get('diff')} + result['session_name'] = response.get('session') + result['changed'] = True + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/test/integration/eos.yaml b/test/integration/eos.yaml index 0fbcc80584c..606b9936236 100644 --- a/test/integration/eos.yaml +++ b/test/integration/eos.yaml @@ -112,6 +112,14 @@ failed_modules: "{{ failed_modules }} + [ 'eos_static_route' ]" test_failed: true + - block: + - include_role: + name: eos_linkagg + when: "limit_to in ['*', 'eos_linkagg']" + rescue: + - set_fact: + failed_modules: "{{ failed_modules }} + [ 'eos_linkagg' ]" + test_failed: true ########### diff --git a/test/integration/targets/eos_linkagg/defaults/main.yaml b/test/integration/targets/eos_linkagg/defaults/main.yaml new file mode 100644 index 00000000000..9ef5ba51651 --- /dev/null +++ b/test/integration/targets/eos_linkagg/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "*" +test_items: [] diff --git a/test/integration/targets/eos_linkagg/meta/main.yaml b/test/integration/targets/eos_linkagg/meta/main.yaml new file mode 100644 index 00000000000..e5c8cd02f04 --- /dev/null +++ b/test/integration/targets/eos_linkagg/meta/main.yaml @@ -0,0 +1,2 @@ +dependencies: + - prepare_eos_tests diff --git a/test/integration/targets/eos_linkagg/tasks/cli.yaml b/test/integration/targets/eos_linkagg/tasks/cli.yaml new file mode 100644 index 00000000000..a6f7ae03517 --- /dev/null +++ b/test/integration/targets/eos_linkagg/tasks/cli.yaml @@ -0,0 +1,22 @@ +--- +- name: collect all cli test cases + find: + paths: "{{ role_path }}/tests/cli" + patterns: "{{ testcase }}.yaml" + register: test_cases + delegate_to: localhost + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test cases (connection=network_cli) + include: "{{ test_case_to_run }} ansible_connection=network_cli" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run + +- name: run test case (connection=local) + include: "{{ test_case_to_run }} ansible_connection=local ansible_become=no" + with_first_found: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/targets/eos_linkagg/tasks/main.yaml b/test/integration/targets/eos_linkagg/tasks/main.yaml new file mode 100644 index 00000000000..415c99d8b12 --- /dev/null +++ b/test/integration/targets/eos_linkagg/tasks/main.yaml @@ -0,0 +1,2 @@ +--- +- { include: cli.yaml, tags: ['cli'] } diff --git a/test/integration/targets/eos_linkagg/tests/cli/basic.yaml b/test/integration/targets/eos_linkagg/tests/cli/basic.yaml new file mode 100644 index 00000000000..9986b61ec92 --- /dev/null +++ b/test/integration/targets/eos_linkagg/tests/cli/basic.yaml @@ -0,0 +1,200 @@ +--- +- debug: msg="START cli/basic.yaml" + +- name: setup - remove config used in test(part1) + eos_config: + lines: + - no interface port-channel 20 + - no interface port-channel 100 + authorize: yes + provider: "{{ cli }}" + become: yes + +- name: setup - remove config used in test(part2) + eos_config: + lines: + - no channel-group 20 + authorize: yes + provider: "{{ cli }}" + parents: "{{ item }}" + loop: + - interface Ethernet1 + - interface Ethernet2 + become: yes + +- name: create linkagg + eos_linkagg: &create + group: 20 + state: present + authorize: yes + provider: "{{ cli }}" + become: yes + register: result + +- assert: + that: + - "result.changed == true" + - "'interface port-channel 20' in result.commands" + +- name: create linkagg(Idempotence) + eos_linkagg: *create + become: yes + register: result + +- assert: + that: + - "result.changed == false" + +- name: set link aggregation group to members + eos_linkagg: &configure_member + group: 20 + mode: active + members: + - Ethernet1 + - Ethernet2 + authorize: yes + provider: "{{ cli }}" + become: yes + register: result + +- assert: + that: + - "result.changed == true" + - "'interface Ethernet1' in result.commands" + - "'channel-group 20 mode active' in result.commands" + - "'interface Ethernet2' in result.commands" + - "'channel-group 20 mode active' in result.commands" + +- name: set link aggregation group to members(Idempotence) + eos_linkagg: *configure_member + become: yes + register: result + +- assert: + that: + - "result.changed == false" + +- name: remove link aggregation group from member + eos_linkagg: &remove_member + group: 20 + mode: active + members: + - Ethernet2 + authorize: yes + provider: "{{ cli }}" + become: yes + register: result + +- assert: + that: + - "result.changed == true" + - "'interface Ethernet1' in result.commands" + - "'no channel-group 20' in result.commands" + +- name: remove link aggregation group from member(Idempotence) + eos_linkagg: *remove_member + become: yes + register: result + +- assert: + that: + - "result.changed == false" + +- name: remove linkagg + eos_linkagg: &remove + group: 20 + state: absent + authorize: yes + provider: "{{ cli }}" + become: yes + register: result + +- assert: + that: + - "result.changed == true" + - "'no interface port-channel 20' in result.commands" + +- name: remove linkagg(Idempotence) + eos_linkagg: *remove + become: yes + register: result + +- assert: + that: + - "result.changed == false" + +- name: create aggregate of linkagg definitions + eos_linkagg: &create_agg + aggregate: + - { group: 20, min_links: 3 } + - { group: 100, min_links: 4 } + authorize: yes + provider: "{{ cli }}" + become: yes + register: result + +- assert: + that: + - "result.changed == true" + - "'interface port-channel 20' in result.commands" + - "'port-channel min-links 3' in result.commands" + - "'interface port-channel 100' in result.commands" + - "'port-channel min-links 4' in result.commands" + +- name: create aggregate of linkagg definitions(Idempotence) + eos_linkagg: *create_agg + become: yes + register: result + +- assert: + that: + - "result.changed == false" + +- name: remove aggregate of linkagg definitions + eos_linkagg: &remove_agg + aggregate: + - { group: 20, min_links: 3 } + - { group: 100, min_links: 4 } + authorize: yes + provider: "{{ cli }}" + state: absent + become: yes + register: result + +- assert: + that: + - "result.changed == true" + - "'no interface port-channel 20' in result.commands" + - "'no interface port-channel 100' in result.commands" + +- name: remove aggregate of linkagg definitions(Idempotence) + eos_linkagg: *remove_agg + become: yes + register: result + +- assert: + that: + - "result.changed == false" + +- name: teardown(part1) + eos_config: + lines: + - no interface port-channel 20 + - no interface port-channel 100 + authorize: yes + provider: "{{ cli }}" + become: yes + +- name: teardown(part2) + eos_config: + lines: + - no channel-group 20 + authorize: yes + provider: "{{ cli }}" + parents: "{{ item }}" + become: yes + loop: + - interface Ethernet1 + - interface Ethernet2 + +- debug: msg="END cli/basic.yaml"