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