1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3
4# (c) 2017, Ansible by Red Hat, inc
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10
11ANSIBLE_METADATA = {'metadata_version': '1.1',
12                    'status': ['deprecated'],
13                    'supported_by': 'network'}
14
15DOCUMENTATION = """
16---
17module: ios_l2_interface
18extends_documentation_fragment: ios
19version_added: "2.5"
20short_description: Manage Layer-2 interface on Cisco IOS devices.
21description:
22  - This module provides declarative management of Layer-2 interfaces on
23    Cisco IOS devices.
24deprecated:
25  removed_in: '2.13'
26  alternative: ios_l2_interfaces
27  why: Newer and updated modules released with more functionality in Ansible 2.9
28author:
29  - Nathaniel Case (@Qalthos)
30options:
31  name:
32    description:
33      - Full name of the interface excluding any logical
34        unit number, i.e. GigabitEthernet0/1.
35    required: true
36    aliases: ['interface']
37  mode:
38    description:
39      - Mode in which interface needs to be configured.
40    default: access
41    choices: ['access', 'trunk']
42  access_vlan:
43    description:
44      - Configure given VLAN in access port.
45        If C(mode=access), used as the access VLAN ID.
46  trunk_vlans:
47    description:
48      - List of VLANs to be configured in trunk port.
49        If C(mode=trunk), used as the VLAN range to ADD or REMOVE
50        from the trunk.
51  native_vlan:
52    description:
53      - Native VLAN to be configured in trunk port.
54        If C(mode=trunk), used as the trunk native VLAN ID.
55  trunk_allowed_vlans:
56    description:
57      - List of allowed VLANs in a given trunk port.
58        If C(mode=trunk), these are the only VLANs that will be
59        configured on the trunk, i.e. "2-10,15".
60  aggregate:
61    description:
62      - List of Layer-2 interface definitions.
63  state:
64    description:
65      - Manage the state of the Layer-2 Interface configuration.
66    default:  present
67    choices: ['present','absent', 'unconfigured']
68"""
69
70EXAMPLES = """
71- name: Ensure GigabitEthernet0/5 is in its default l2 interface state
72  ios_l2_interface:
73    name: GigabitEthernet0/5
74    state: unconfigured
75- name: Ensure GigabitEthernet0/5 is configured for access vlan 20
76  ios_l2_interface:
77    name: GigabitEthernet0/5
78    mode: access
79    access_vlan: 20
80- name: Ensure GigabitEthernet0/5 only has vlans 5-10 as trunk vlans
81  ios_l2_interface:
82    name: GigabitEthernet0/5
83    mode: trunk
84    native_vlan: 10
85    trunk_allowed_vlans: 5-10
86- name: Ensure GigabitEthernet0/5 is a trunk port and ensure 2-50 are being tagged (doesn't mean others aren't also being tagged)
87  ios_l2_interface:
88    name: GigabitEthernet0/5
89    mode: trunk
90    native_vlan: 10
91    trunk_vlans: 2-50
92- name: Ensure these VLANs are not being tagged on the trunk
93  ios_l2_interface:
94    name: GigabitEthernet0/5
95    mode: trunk
96    trunk_vlans: 51-4094
97    state: absent
98"""
99
100RETURN = """
101commands:
102  description: The list of configuration mode commands to send to the device
103  returned: always, except for the platforms that use Netconf transport to manage the device.
104  type: list
105  sample:
106    - interface GigabitEthernet0/5
107    - switchport access vlan 20
108"""
109
110import re
111from copy import deepcopy
112
113from ansible.module_utils.basic import AnsibleModule
114from ansible.module_utils.network.common.utils import remove_default_spec
115from ansible.module_utils.network.ios.ios import load_config, run_commands
116from ansible.module_utils.network.ios.ios import ios_argument_spec
117
118
119def get_interface_type(interface):
120    intf_type = 'unknown'
121    if interface.upper()[:2] in ('ET', 'GI', 'FA', 'TE', 'FO', 'HU', 'TWE', 'TW'):
122        intf_type = 'ethernet'
123    elif interface.upper().startswith('VL'):
124        intf_type = 'svi'
125    elif interface.upper().startswith('LO'):
126        intf_type = 'loopback'
127    elif interface.upper()[:2] in ('MG', 'MA'):
128        intf_type = 'management'
129    elif interface.upper().startswith('PO'):
130        intf_type = 'portchannel'
131    elif interface.upper().startswith('NV'):
132        intf_type = 'nve'
133
134    return intf_type
135
136
137def is_switchport(name, module):
138    intf_type = get_interface_type(name)
139
140    if intf_type in ('ethernet', 'portchannel'):
141        config = run_commands(module, ['show interface {0} switchport'.format(name)])[0]
142        match = re.search(r'Switchport: Enabled', config)
143        return bool(match)
144    return False
145
146
147def interface_is_portchannel(name, module):
148    if get_interface_type(name) == 'ethernet':
149        config = run_commands(module, ['show run interface {0}'.format(name)])[0]
150        if any(c in config for c in ['channel group', 'channel-group']):
151            return True
152    return False
153
154
155def get_switchport(name, module):
156    config = run_commands(module, ['show interface {0} switchport'.format(name)])[0]
157    mode = re.search(r'Administrative Mode: (?:.* )?(\w+)$', config, re.M)
158    access = re.search(r'Access Mode VLAN: (\d+)', config)
159    native = re.search(r'Trunking Native Mode VLAN: (\d+)', config)
160    trunk = re.search(r'Trunking VLANs Enabled: (.+)$', config, re.M)
161    if mode:
162        mode = mode.group(1)
163    if access:
164        access = access.group(1)
165    if native:
166        native = native.group(1)
167    if trunk:
168        trunk = trunk.group(1)
169    if trunk == 'ALL':
170        trunk = '1-4094'
171
172    switchport_config = {
173        "interface": name,
174        "mode": mode,
175        "access_vlan": access,
176        "native_vlan": native,
177        "trunk_vlans": trunk,
178    }
179
180    return switchport_config
181
182
183def remove_switchport_config_commands(name, existing, proposed, module):
184    mode = proposed.get('mode')
185    commands = []
186    command = None
187
188    if mode == 'access':
189        av_check = existing.get('access_vlan') == proposed.get('access_vlan')
190        if av_check:
191            command = 'no switchport access vlan {0}'.format(existing.get('access_vlan'))
192            commands.append(command)
193
194    elif mode == 'trunk':
195        # Supported Remove Scenarios for trunk_vlans_list
196        # 1) Existing: 1,2,3 Proposed: 1,2,3 - Remove all
197        # 2) Existing: 1,2,3 Proposed: 1,2   - Remove 1,2 Leave 3
198        # 3) Existing: 1,2,3 Proposed: 2,3   - Remove 2,3 Leave 1
199        # 4) Existing: 1,2,3 Proposed: 4,5,6 - None removed.
200        # 5) Existing: None  Proposed: 1,2,3 - None removed.
201        existing_vlans = existing.get('trunk_vlans_list')
202        proposed_vlans = proposed.get('trunk_vlans_list')
203        vlans_to_remove = set(proposed_vlans).intersection(existing_vlans)
204
205        if vlans_to_remove:
206            proposed_allowed_vlans = proposed.get('trunk_allowed_vlans')
207            remove_trunk_allowed_vlans = proposed.get('trunk_vlans', proposed_allowed_vlans)
208            command = 'switchport trunk allowed vlan remove {0}'.format(remove_trunk_allowed_vlans)
209            commands.append(command)
210
211        native_check = existing.get('native_vlan') == proposed.get('native_vlan')
212        if native_check and proposed.get('native_vlan'):
213            command = 'no switchport trunk native vlan {0}'.format(existing.get('native_vlan'))
214            commands.append(command)
215
216    if commands:
217        commands.insert(0, 'interface ' + name)
218    return commands
219
220
221def get_switchport_config_commands(name, existing, proposed, module):
222    """Gets commands required to config a given switchport interface
223    """
224
225    proposed_mode = proposed.get('mode')
226    existing_mode = existing.get('mode')
227    commands = []
228    command = None
229
230    if proposed_mode != existing_mode:
231        if proposed_mode == 'trunk':
232            command = 'switchport mode trunk'
233        elif proposed_mode == 'access':
234            command = 'switchport mode access'
235
236    if command:
237        commands.append(command)
238
239    if proposed_mode == 'access':
240        av_check = str(existing.get('access_vlan')) == str(proposed.get('access_vlan'))
241        if not av_check:
242            command = 'switchport access vlan {0}'.format(proposed.get('access_vlan'))
243            commands.append(command)
244
245    elif proposed_mode == 'trunk':
246        tv_check = existing.get('trunk_vlans_list') == proposed.get('trunk_vlans_list')
247
248        if not tv_check:
249            if proposed.get('allowed'):
250                command = 'switchport trunk allowed vlan {0}'.format(proposed.get('trunk_allowed_vlans'))
251                commands.append(command)
252
253            else:
254                existing_vlans = existing.get('trunk_vlans_list')
255                proposed_vlans = proposed.get('trunk_vlans_list')
256                vlans_to_add = set(proposed_vlans).difference(existing_vlans)
257                if vlans_to_add:
258                    command = 'switchport trunk allowed vlan add {0}'.format(proposed.get('trunk_vlans'))
259                    commands.append(command)
260
261        native_check = str(existing.get('native_vlan')) == str(proposed.get('native_vlan'))
262        if not native_check and proposed.get('native_vlan'):
263            command = 'switchport trunk native vlan {0}'.format(proposed.get('native_vlan'))
264            commands.append(command)
265
266    if commands:
267        commands.insert(0, 'interface ' + name)
268    return commands
269
270
271def is_switchport_default(existing):
272    """Determines if switchport has a default config based on mode
273    Args:
274        existing (dict): existing switchport configuration from Ansible mod
275    Returns:
276        boolean: True if switchport has OOB Layer 2 config, i.e.
277           vlan 1 and trunk all and mode is access
278    """
279
280    c1 = str(existing['access_vlan']) == '1'
281    c2 = str(existing['native_vlan']) == '1'
282    c3 = existing['trunk_vlans'] == '1-4094'
283    c4 = existing['mode'] == 'access'
284
285    default = c1 and c2 and c3 and c4
286
287    return default
288
289
290def default_switchport_config(name):
291    commands = []
292    commands.append('interface ' + name)
293    commands.append('switchport mode access')
294    commands.append('switch access vlan 1')
295    commands.append('switchport trunk native vlan 1')
296    commands.append('switchport trunk allowed vlan all')
297    return commands
298
299
300def vlan_range_to_list(vlans):
301    result = []
302    if vlans:
303        for part in vlans.split(','):
304            if part.lower() == 'none':
305                break
306            if part:
307                if '-' in part:
308                    start, stop = (int(i) for i in part.split('-'))
309                    result.extend(range(start, stop + 1))
310                else:
311                    result.append(int(part))
312    return sorted(result)
313
314
315def get_list_of_vlans(module):
316    config = run_commands(module, ['show vlan'])[0]
317    vlans = set()
318
319    lines = config.strip().splitlines()
320    for line in lines:
321        line_parts = line.split()
322        if line_parts:
323            try:
324                int(line_parts[0])
325            except ValueError:
326                continue
327            vlans.add(line_parts[0])
328
329    return list(vlans)
330
331
332def flatten_list(commands):
333    flat_list = []
334    for command in commands:
335        if isinstance(command, list):
336            flat_list.extend(command)
337        else:
338            flat_list.append(command)
339    return flat_list
340
341
342def map_params_to_obj(module):
343    obj = []
344
345    aggregate = module.params.get('aggregate')
346    if aggregate:
347        for item in aggregate:
348            for key in item:
349                if item.get(key) is None:
350                    item[key] = module.params[key]
351
352            obj.append(item.copy())
353    else:
354        obj.append({
355            'name': module.params['name'],
356            'mode': module.params['mode'],
357            'access_vlan': module.params['access_vlan'],
358            'native_vlan': module.params['native_vlan'],
359            'trunk_vlans': module.params['trunk_vlans'],
360            'trunk_allowed_vlans': module.params['trunk_allowed_vlans'],
361            'state': module.params['state']
362        })
363
364    return obj
365
366
367def main():
368    """ main entry point for module execution
369    """
370    element_spec = dict(
371        name=dict(type='str', aliases=['interface']),
372        mode=dict(choices=['access', 'trunk']),
373        access_vlan=dict(type='str'),
374        native_vlan=dict(type='str'),
375        trunk_vlans=dict(type='str'),
376        trunk_allowed_vlans=dict(type='str'),
377        state=dict(choices=['absent', 'present', 'unconfigured'], default='present')
378    )
379
380    aggregate_spec = deepcopy(element_spec)
381
382    # remove default in aggregate spec, to handle common arguments
383    remove_default_spec(aggregate_spec)
384
385    argument_spec = dict(
386        aggregate=dict(type='list', elements='dict', options=aggregate_spec),
387    )
388
389    argument_spec.update(element_spec)
390    argument_spec.update(ios_argument_spec)
391
392    module = AnsibleModule(argument_spec=argument_spec,
393                           mutually_exclusive=[['access_vlan', 'trunk_vlans'],
394                                               ['access_vlan', 'native_vlan'],
395                                               ['access_vlan', 'trunk_allowed_vlans']],
396                           supports_check_mode=True)
397
398    warnings = list()
399    commands = []
400    result = {'changed': False, 'warnings': warnings}
401
402    want = map_params_to_obj(module)
403    for w in want:
404        name = w['name']
405        mode = w['mode']
406        access_vlan = w['access_vlan']
407        state = w['state']
408        trunk_vlans = w['trunk_vlans']
409        native_vlan = w['native_vlan']
410        trunk_allowed_vlans = w['trunk_allowed_vlans']
411
412        args = dict(name=name, mode=mode, access_vlan=access_vlan,
413                    native_vlan=native_vlan, trunk_vlans=trunk_vlans,
414                    trunk_allowed_vlans=trunk_allowed_vlans)
415
416        proposed = dict((k, v) for k, v in args.items() if v is not None)
417
418        name = name.lower()
419
420        if mode == 'access' and state == 'present' and not access_vlan:
421            module.fail_json(msg='access_vlan param is required when mode=access && state=present')
422
423        if mode == 'trunk' and access_vlan:
424            module.fail_json(msg='access_vlan param not supported when using mode=trunk')
425
426        if not is_switchport(name, module):
427            module.fail_json(msg='Ensure interface is configured to be a L2'
428                             '\nport first before using this module. You can use'
429                             '\nthe ios_interface module for this.')
430
431        if interface_is_portchannel(name, module):
432            module.fail_json(msg='Cannot change L2 config on physical '
433                             '\nport because it is in a portchannel. '
434                             '\nYou should update the portchannel config.')
435
436        # existing will never be null for Eth intfs as there is always a default
437        existing = get_switchport(name, module)
438
439        # Safeguard check
440        # If there isn't an existing, something is wrong per previous comment
441        if not existing:
442            module.fail_json(msg='Make sure you are using the FULL interface name')
443
444        if trunk_vlans or trunk_allowed_vlans:
445            if trunk_vlans:
446                trunk_vlans_list = vlan_range_to_list(trunk_vlans)
447            elif trunk_allowed_vlans:
448                trunk_vlans_list = vlan_range_to_list(trunk_allowed_vlans)
449                proposed['allowed'] = True
450
451            existing_trunks_list = vlan_range_to_list((existing['trunk_vlans']))
452
453            existing['trunk_vlans_list'] = existing_trunks_list
454            proposed['trunk_vlans_list'] = trunk_vlans_list
455
456        current_vlans = get_list_of_vlans(module)
457
458        if state == 'present':
459            if access_vlan and access_vlan not in current_vlans:
460                module.fail_json(msg='You are trying to configure a VLAN'
461                                 ' on an interface that\ndoes not exist on the '
462                                 ' switch yet!', vlan=access_vlan)
463            elif native_vlan and native_vlan not in current_vlans:
464                module.fail_json(msg='You are trying to configure a VLAN'
465                                 ' on an interface that\ndoes not exist on the '
466                                 ' switch yet!', vlan=native_vlan)
467            else:
468                command = get_switchport_config_commands(name, existing, proposed, module)
469                commands.append(command)
470        elif state == 'unconfigured':
471            is_default = is_switchport_default(existing)
472            if not is_default:
473                command = default_switchport_config(name)
474                commands.append(command)
475        elif state == 'absent':
476            command = remove_switchport_config_commands(name, existing, proposed, module)
477            commands.append(command)
478
479        if trunk_vlans or trunk_allowed_vlans:
480            existing.pop('trunk_vlans_list')
481            proposed.pop('trunk_vlans_list')
482
483    cmds = flatten_list(commands)
484    if cmds:
485        if module.check_mode:
486            module.exit_json(changed=True, commands=cmds)
487        else:
488            result['changed'] = True
489            load_config(module, cmds)
490            if 'configure' in cmds:
491                cmds.pop(0)
492
493    result['commands'] = cmds
494
495    module.exit_json(**result)
496
497
498if __name__ == '__main__':
499    main()
500