From 844168d9e66d38faa5c250a135d85b2e7e4fb002 Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Sat, 30 Jan 2021 21:51:28 +0100 Subject: [PATCH 1/9] docs: Use more common spelling "protocol" instead of "protocal" --- README.md | 2 +- lib/cli.py | 6 +++--- lib/hpshell.py | 4 ++-- sshhp | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 167d360..862ca57 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ $ ./sshhp 192.168.1.1 Cannot connect https://192.168.1.1 : [Errno 111] Connection refused HTTPS failed. Try HTTP... ***************************************** -*Warning: Connect through HTTP protocal.* +*Warning: Connect through HTTP protocol.* ***************************************** Password: Type exit/forceexit to quit, help for help. diff --git a/lib/cli.py b/lib/cli.py index de4f250..9708ce7 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -15,7 +15,7 @@ def __init__(self, protocol, host): @staticmethod def testConnection(protocol, host): - url = protocol + PROTOCAL_DELIMETER + host + url = protocol + PROTOCOL_DELIMETER + host ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE @@ -415,7 +415,7 @@ def _httpPostFile(self, operation, post_data, files): return httpRequest(self.session, 'POST', self._getUrl(operation), post_data, files) def _getUrl(self, operation): - return self.protocol + PROTOCAL_DELIMETER + self.host + URLS[operation] + return self.protocol + PROTOCOL_DELIMETER + self.host + URLS[operation] # This function is simply a translation of JS code def _ping_ajax(self, handle_val, host_name_ipaddr): @@ -506,7 +506,7 @@ def _ping_ajax(self, handle_val, host_name_ipaddr): 'set_mgmt_vlan':'/htdocs/pages/base/network_ipv4_cfg.lsp' } -PROTOCAL_DELIMETER = "://" +PROTOCOL_DELIMETER = "://" TEST_CONNECTION_TIMEOUT = 5 # second # private module function diff --git a/lib/hpshell.py b/lib/hpshell.py index 5e9d45e..514b229 100644 --- a/lib/hpshell.py +++ b/lib/hpshell.py @@ -101,8 +101,8 @@ def do_gencert(self, args): cli.genCert() def do_sethttps(self, args): - """Set management connection protocal (HTTP or HTTPS).""" - print("Note: If the new protocal is different from current one, you have to login again.") + """Set management connection protocol (HTTP or HTTPS).""" + print("Note: If the new protocol is different from current one, you have to login again.") available_choice = {'http': ('enabled', 'disabled'), 'https':('disabled', 'enabled'), 'both':('enabled', 'enabled')} choice = input("http only[http]/https only[https]/both[both]?") while choice not in available_choice: diff --git a/sshhp b/sshhp index 1e16ccc..da64da0 100755 --- a/sshhp +++ b/sshhp @@ -7,7 +7,7 @@ from lib import hpshell def checkArgument(): if len(sys.argv) != 2: - print("http - Connect a switch through HTTP protocal") + print("http - Connect a switch through HTTP protocol") print("Usage: http [user@]host") print("(If not specified, username is admin.)") sys.exit(0) @@ -35,7 +35,7 @@ if __name__ == "__main__": if Cli.testConnection('http', host): cli = Cli('http', host) print("*****************************************") - print("*Warning: Connect through HTTP protocal.*") + print("*Warning: Connect through HTTP protocol.*") print("*****************************************") else: print("Error: Cannot connect to remote host through HTTP and HTTPS.") From 1df7ae6e17e4e810fade1548b9f3312e7c2666ee Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Thu, 4 Feb 2021 00:08:18 +0100 Subject: [PATCH 2/9] feat: Wrote Ansible module hpe1820_port_vlans Happy automating. Tests can be run with `nosetests3` --- lib/cli.py | 145 +++++++++++++++++++++++++++++++--- library/hpe1820_port_vlans.py | 138 ++++++++++++++++++++++++++++++++ playbooks/netbox_vlans.yml | 50 ++++++++++++ tests/test_cli.py | 106 +++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 13 deletions(-) create mode 100644 library/hpe1820_port_vlans.py create mode 100644 playbooks/netbox_vlans.yml create mode 100644 tests/test_cli.py diff --git a/lib/cli.py b/lib/cli.py index 9708ce7..d766d3f 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -1,3 +1,10 @@ +# -*- coding: utf-8 -*- +# +# SPDX-FileCopyrightText: 2016 Bookgin +# SPDX-FileCopyrightText: 2021 Robin Schneider +# +# SPDX-License-Identifier: MIT + import requests import json import time @@ -5,8 +12,10 @@ from bs4 import BeautifulSoup import urllib.request, urllib.error, ssl from math import isnan -import threading +import re import os +import functools + class Cli: def __init__(self, protocol, host): @@ -28,6 +37,35 @@ def testConnection(protocol, host): else: return True + @staticmethod + def _parse_port_range(port_range_str): + port_range = [] + for port_range_part in port_range_str.replace(' ', '').split(','): + _re = re.search(r""" + ^ + (?P[A-Z]*) + (?P[0-9]+) + (?: + - + (?P=port_prefix) + (?P[0-9]+) + )? + $ + """, port_range_part, flags=re.VERBOSE) + if not _re: + raise Exception(f"port_range_part has unknown format: {port_range_part}") + matches = _re.groupdict() + if matches['last_if'] is None: + matches['last_if'] = matches['first_if'] + + port_range_iter = range( + int(matches['first_if']), + int(matches['last_if']) + 1) + for port_item in port_range_iter: + port_range.append(f"{matches['port_prefix']}{port_item}") + + return port_range + def login(self, username, password): try: raw_response = self._httpPost('login', {'username': username, 'password': password}) @@ -75,8 +113,97 @@ def showPortStatistic(self): def showDashboard(self): printDashboard(self._httpGet('dashboard')) + def _set_vlan_port_in_variable(self, port_vlans, vlan, index, mode): + vid = int(vlan[0]) + if vlan[index] != '': + for vlan_port in self._parse_port_range(vlan[index]): + port_vlans.setdefault(vlan_port, {}) + if mode == 'untagged': + port_vlans[vlan_port][mode] = vid + elif mode == 'tagged': + port_vlans[vlan_port].setdefault(mode, []) + port_vlans[vlan_port][mode].append(vid) + + def get_interfaces_vlan_membership(self): + port_vlans = {} + vlan_membership = self.getVlanMembership() + for vlan in vlan_membership: + if len(vlan) != 4: + continue + self._set_vlan_port_in_variable(port_vlans, vlan, 1, 'tagged') + self._set_vlan_port_in_variable(port_vlans, vlan, 2, 'untagged') + self._set_vlan_port_in_variable(port_vlans, vlan, 3, 'exclude') + return port_vlans + + def get_interface_vlan_membership_change_actions(self, interface, port_vlans, desired_port_vlans): + change_actions = [] + vlan_vids = [int(vlan[0]) for vlan in self.getVlans() if len(vlan) == 3] + if 'untagged' in desired_port_vlans and port_vlans.get('untagged') != desired_port_vlans['untagged']: + if desired_port_vlans['untagged'] not in vlan_vids: + change_actions.append(('addVlan', desired_port_vlans['untagged'])) + change_actions.append(('accessVlan', 'untagged', interface, desired_port_vlans['untagged'])) + if 'tagged' in desired_port_vlans: + for desired_tagged_vlan in desired_port_vlans['tagged']: + if desired_tagged_vlan not in port_vlans.get('tagged', []): + if desired_tagged_vlan not in vlan_vids: + change_actions.append(('addVlan', desired_tagged_vlan)) + change_actions.append(('accessVlan', 'tagged', interface, desired_tagged_vlan)) + for tagged_vlan in port_vlans.get('tagged', []): + if tagged_vlan not in desired_port_vlans['tagged']: + change_actions.append(('accessVlan', 'exclude', interface, tagged_vlan)) + return change_actions + + def ensure_interfaces_vlan_membership(self, desired_port_vlans, dry_run=False): + change_actions = [] + interfaces_vlan_membership = self.get_interfaces_vlan_membership() + interfaces_not_existing_on_switch = [] + for interface in desired_port_vlans.keys(): + if interface not in interfaces_vlan_membership: + interfaces_not_existing_on_switch.append(interface) + if len(interfaces_not_existing_on_switch) > 0: + raise Exception(f"The switch does not have the following ports: {','.join(interfaces_not_existing_on_switch)}") + for interface, port_vlans in interfaces_vlan_membership.items(): + change_actions.extend(self.get_interface_vlan_membership_change_actions( + interface, + port_vlans, + desired_port_vlans.get(interface, {}), + )) + if not dry_run: + for change_action in change_actions: + getattr(self, change_action[0])(*change_action[1:]) + if len(change_actions) > 0: + self.saveConfig() + + return change_actions + + @functools.lru_cache() + def _get_all_config(self): + return self._httpGet('all_config') + + # TODO: Refactor out of Cli class. + def getVlanMembership(self): + html = BeautifulSoup(self._get_all_config(), 'html.parser') + data = [] + for table in html.find_all("table"): + if table["id"] != "sorttable12": + continue + for row in table.find_all("tr"): + data.append([col.get_text() for col in row.find_all("td")]) + return data + + def getVlans(self): + html = BeautifulSoup(self._get_all_config(), 'html.parser') + data = [] + for table in html.find_all("table"): + if table["id"] != "sorttable10": + continue + for row in table.find_all("tr"): + data.append([col.get_text() for col in row.find_all("td")]) + return data + def showVlanMembership(self): - printVlanMembership(self._httpGet('all_config')) + first_row = ['VLAN ID', 'Tagged Ports', 'Untagged Ports', 'Exclude Participation'] + printTable(first_row, self.getVlanMembership()) # DEPRECATED: This method uses the same API as showDashboard() def getSwitchName(self): @@ -173,6 +300,7 @@ def accessVlan(self, mode, interfaces, vlan_id): 'b_modal1_clicked': 'b_modal1_submit' } self._httpPost('access_vlan', post_data) + self._get_all_config.cache_clear() # @param example: 5-18 or 7 or 1,4,7 def addVlan(self, vlan_id_str): @@ -182,6 +310,7 @@ def addVlan(self, vlan_id_str): 'b_modal1_clicked': 'b_modal1_submit' } self._httpPost('add_vlan', post_data) + self._get_all_config.cache_clear() # @param example: 5-18 or 7 or 1,4,7 def delVlan(self, vlan_id_str): @@ -192,6 +321,7 @@ def delVlan(self, vlan_id_str): 'b_form1_clicked': 'b_form1_dt_remove' } self._httpPost('del_vlan', post_data) + self._get_all_config.cache_clear() # Generate https SSL certificate. def genCert(self): @@ -570,14 +700,3 @@ def printDashboard(raw_response): print(val.input['value']) else: print(val.get_text().replace('\n', '')) - -def printVlanMembership(raw_response): - html = BeautifulSoup(raw_response, 'html.parser') - first_row = ['VLAN ID', 'Tagged Ports', 'Untagged Ports', 'Exclude Participation'] - data = [] - for table in html.find_all("table"): - if table["id"] != "sorttable12": - continue - for row in table.find_all("tr"): - data.append([col.get_text() for col in row.find_all("td")]) - printTable(first_row, data) diff --git a/library/hpe1820_port_vlans.py b/library/hpe1820_port_vlans.py new file mode 100644 index 0000000..4f6e070 --- /dev/null +++ b/library/hpe1820_port_vlans.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# SPDX-FileCopyrightText: 2021 Robin Schneider +# +# SPDX-License-Identifier: MIT + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: my_sample_module + +short_description: Assign VLANs to ports on HPE 1820 web managed switches. + +# version_added: "2.4" + +description: + - "This module provides declarative management of VLANs on HPE 1820 switches." + - "Known limitation: The switch does not clear the session which means it runs out of sessions after 5 runs?." + - "Known limitation: No option to remove VLANs from the table exists yet." + +options: + port_vlans: + description: + - Port to VLANs mapping dict. See example for the structure. + required: true + host: + description: + - Hostname of the switch. + required: true + username: + description: + - Username to login on the switch as. + required: true + password: + description: + - Password to use for login. + required: true + +author: + - Robin Schneider (@ypid) +''' + +EXAMPLES = ''' +- name: Ensure port 2 has two VLANs assigned + hpe1820_port_vlans: + port_vlans: + '2': + untagged: 2900 + tagged: + - 295 + host: '{{ inventory_hostname }}' + username: '{{ hpe1820__username | d(omit) }}' + password: '{{ hpe1820__password }}' + delegate_to: 'localhost' + +# The --diff output might look like this: +# +# --- before +# +++ after +# @@ -0,0 +1,3 @@ +# +vlan_per_port('untagged', '2', 2900) +# +vlan_per_port('tagged', '2', 295) +# +vlan_per_port('exclude', '2', 2902) +''' + +# RETURN = ''' +# port_vlans: +# description: All port VLANs of the switch after modifications. +# type: dict +# ''' + + +from ansible.module_utils.basic import AnsibleModule +import lib.cli + + +def run_module(): + + result = { + 'changed': False, + } + + module = AnsibleModule( + argument_spec=dict( + port_vlans=dict(required=True, type='dict'), + host=dict(required=True, type='str'), + username=dict(required=False, type='str', default='admin'), + password=dict(required=True, type='str', no_log=True), + ), + supports_check_mode=True, + ) + + port_vlans = module.params['port_vlans'] + host = module.params['host'] + username = module.params['username'] + password = module.params['password'] + + # Always try https first! + if lib.cli.Cli.testConnection('https', host): + cli = lib.cli.Cli('https', host) + else: + if lib.cli.Cli.testConnection('http', host): + cli = lib.cli.Cli('http', host) + else: + module.fail_json(msg="Error: Cannot connect to remote host through HTTP and HTTPS.", **result) + + cli.login(username, password) + + change_actions = cli.ensure_interfaces_vlan_membership(port_vlans, dry_run=module.check_mode) + + # This only slows it down. Could be made conditional if anybody has a valid use case for it. + # result['port_vlans'] = cli.get_interfaces_vlan_membership() + + if len(change_actions) > 0: + result['changed'] = True + result['diff'] = { + 'before': '', + 'after': '\n'.join([f"vlan_per_port{s}" for s in change_actions]) + '\n', + } + + cli.logout() + cli.close() + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/playbooks/netbox_vlans.yml b/playbooks/netbox_vlans.yml new file mode 100644 index 0000000..501d96c --- /dev/null +++ b/playbooks/netbox_vlans.yml @@ -0,0 +1,50 @@ +--- +# SPDX-FileCopyrightText: 2021 Robin Schneider +# +# SPDX-License-Identifier: MIT + +- name: Manage HPE 1820 port VLANs from NetBox + hosts: [ 'platform_hpe1820' ] + become: False + gather_facts: False + vars: + hpe1820__port_vlans: | + {% macro print_config() %} + {% for interface in interfaces|sort(attribute="id") %} + {% if interface.untagged_vlan|d() or interface.tagged_vlans|d() %} + '{{ interface.name }}': + {% if interface.untagged_vlan|d() %} + untagged: {{ interface.untagged_vlan.vid }} + {% endif %} + {% if interface.tagged_vlans|d() %} + tagged: + {% for vlan in (interface.tagged_vlans) if vlan %} + - {{ vlan.vid }} + {% endfor %} + {% else %} + tagged: [] + {% endif %} + {% endif %} + {% endfor %} + {% endmacro %} + {{ print_config() | from_yaml }} + + environment: '{{ inventory__environment | d({}) + | combine(inventory__group_environment | d({})) + | combine(inventory__host_environment | d({})) }}' + + tasks: + + - debug: + var: hpe1820__port_vlans + tags: test + + - name: Ensure VLANs to ports + hpe1820_port_vlans: + port_vlans: '{{ hpe1820__port_vlans }}' + host: '{{ inventory_hostname }}' + username: '{{ hpe1820__username | d(omit) }}' + password: '{{ hpe1820__password }}' + delegate_to: 'localhost' + register: tmp + tags: port_vlans diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..6a6f4e4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# +# SPDX-FileCopyrightText: 2021 Robin Schneider +# +# SPDX-License-Identifier: MIT + +from unittest import mock, TestCase +import textwrap + +import yaml +import nose.tools + +from lib.cli import Cli + + +class Test(TestCase): + PORT_VLANS = yaml.safe_load( + textwrap.dedent(""" + '1': + untagged: 5 + tagged: + - 142 + - 999 + '10': + untagged: 5 + tagged: + - 142 + """) + ) + + def setUp(self): + self.c = Cli('https', 'localhost') + + def test__parse_port_range__exception(self): + with self.assertRaises(Exception) as context: + self.c._parse_port_range('TIRK5-TRK8') + + nose.tools.assert_in('port_range_part has unknown format: ', str(context.exception)) + + def test__parse_port_range(self): + nose.tools.assert_equal( + self.c._parse_port_range('1, 9-11, 17-20, TRK2, TRK5-TRK8'), + ['1', '9', '10', '11', '17', '18', '19', '20', 'TRK2', 'TRK5', 'TRK6', 'TRK7', 'TRK8'] + ) + + def test_get_interfaces_vlan_membership(self): + vlan_membership = yaml.safe_load( + textwrap.dedent(""" + - [] + - - '5' + - '' + - '1,10' + - '' + - - '142' + - '1,10' + - '' + - '' + - - '999' + - '1' + - '' + - '' + """) + ) + with mock.patch.object(Cli, 'getVlanMembership', return_value=vlan_membership): + nose.tools.assert_equal(self.c.get_interfaces_vlan_membership(), self.PORT_VLANS) + + def test_ensure_interfaces_vlan_membership(self): + desired_port_vlans = yaml.safe_load( + textwrap.dedent(""" + '1': + untagged: 90 + tagged: + - 123 + - 142 + """) + ) + expected_change_actions = [ + ('addVlan', 90), + ('accessVlan', 'untagged', '1', 90), + ('addVlan', 123), + ('accessVlan', 'tagged', '1', 123), + ('accessVlan', 'exclude', '1', 999), + ] + with mock.patch.object(Cli, 'get_interfaces_vlan_membership', return_value=self.PORT_VLANS): + with mock.patch.object(Cli, 'getVlans', return_value=[]): + change_actions = self.c.ensure_interfaces_vlan_membership(desired_port_vlans, dry_run=True) + nose.tools.assert_equal(change_actions, expected_change_actions) + + def test_ensure_interfaces_vlan_membership_wrong_port(self): + desired_port_vlans = yaml.safe_load( + textwrap.dedent(""" + '99': + untagged: 90 + tagged: + - 123 + - 142 + """) + ) + with mock.patch.object(Cli, 'get_interfaces_vlan_membership', return_value=self.PORT_VLANS): + with self.assertRaises(Exception) as context: + self.c.ensure_interfaces_vlan_membership(desired_port_vlans) + + nose.tools.assert_equal( + 'The switch does not have the following ports: 99', + str(context.exception) + ) From 27ae4de07a2d91fb951f62ba12370df927ed67ab Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Sun, 23 May 2021 22:34:23 +0200 Subject: [PATCH 3/9] fix: accessVlan API requires internal IDs for TRK ports Before, when accessVlan was called with TRK1, it worked on port 1 instead! --- lib/cli.py | 11 ++++++++--- tests/test_cli.py | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index d766d3f..678e2dd 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -38,7 +38,7 @@ def testConnection(protocol, host): return True @staticmethod - def _parse_port_range(port_range_str): + def _parse_port_range(port_range_str, internal_ids=False): port_range = [] for port_range_part in port_range_str.replace(' ', '').split(','): _re = re.search(r""" @@ -62,7 +62,10 @@ def _parse_port_range(port_range_str): int(matches['first_if']), int(matches['last_if']) + 1) for port_item in port_range_iter: - port_range.append(f"{matches['port_prefix']}{port_item}") + if internal_ids and matches['port_prefix'] != '': + port_range.append(str(53 + port_item)) + else: + port_range.append(f"{matches['port_prefix']}{port_item}") return port_range @@ -289,12 +292,14 @@ def setAccount(self, username, old_pwd, new_pwd, confirm_new_passwd): else: print("Username/Password changed successfully") + def _get_hpe_internal_interface_ids(self, interfaces): + return ','.join(self._parse_port_range(interfaces, internal_ids=True)) def accessVlan(self, mode, interfaces, vlan_id): post_data = { 'part_tagg_sel[]': mode, # tagged, untagged, exclude 'vlan': vlan_id, - 'intfStr': interfaces, # 1-8, TRK1: 54, TRK2: 55 ... + 'intfStr': self._get_hpe_internal_interface_ids(interfaces), # 1-8, TRK1: 54, TRK2: 55 ... 'part_exclude': 'yes', 'parentQStr': '?vlan=%s' % vlan_id, # looks like this doesn't matter 'b_modal1_clicked': 'b_modal1_submit' diff --git a/tests/test_cli.py b/tests/test_cli.py index 6a6f4e4..6de494f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,6 +43,12 @@ def test__parse_port_range(self): ['1', '9', '10', '11', '17', '18', '19', '20', 'TRK2', 'TRK5', 'TRK6', 'TRK7', 'TRK8'] ) + def test__get_hpe_internal_interface_ids(self): + nose.tools.assert_equal( + self.c._get_hpe_internal_interface_ids('1, 9-11, 17-20, TRK2, TRK5-TRK8'), + '1,9,10,11,17,18,19,20,55,58,59,60,61' + ) + def test_get_interfaces_vlan_membership(self): vlan_membership = yaml.safe_load( textwrap.dedent(""" From 0cf024c56bc65d8bb33df51fe87b8620d2c2ea4e Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Mon, 1 Nov 2021 21:57:21 +0100 Subject: [PATCH 4/9] feat: If a port has no VLANs in NetBox, set to default VLAN 1 --- lib/cli.py | 22 +++++++++++++++------- library/hpe1820_port_vlans.py | 5 ++++- playbooks/netbox_vlans.yml | 20 ++++++++++++-------- 3 files changed, 31 insertions(+), 16 deletions(-) mode change 100644 => 100755 library/hpe1820_port_vlans.py diff --git a/lib/cli.py b/lib/cli.py index 678e2dd..f7c1254 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -141,10 +141,18 @@ def get_interfaces_vlan_membership(self): def get_interface_vlan_membership_change_actions(self, interface, port_vlans, desired_port_vlans): change_actions = [] vlan_vids = [int(vlan[0]) for vlan in self.getVlans() if len(vlan) == 3] - if 'untagged' in desired_port_vlans and port_vlans.get('untagged') != desired_port_vlans['untagged']: - if desired_port_vlans['untagged'] not in vlan_vids: - change_actions.append(('addVlan', desired_port_vlans['untagged'])) - change_actions.append(('accessVlan', 'untagged', interface, desired_port_vlans['untagged'])) + if 'untagged' in desired_port_vlans: + if desired_port_vlans['untagged'] is None and 'untagged' in port_vlans: + # TODO: Remove unused VLANs from switch. Needs to be done at a later point. + # The switch does not support that: + # > If you wish to exclude a port from the current VLAN + # > membership you must first make it a tagged/untagged member in + # > another VLAN. + change_actions.append(('accessVlan', 'exclude', interface, port_vlans['untagged'])) + elif port_vlans.get('untagged') != desired_port_vlans['untagged']: + if desired_port_vlans['untagged'] not in vlan_vids: + change_actions.append(('addVlan', desired_port_vlans['untagged'])) + change_actions.append(('accessVlan', 'untagged', interface, desired_port_vlans['untagged'])) if 'tagged' in desired_port_vlans: for desired_tagged_vlan in desired_port_vlans['tagged']: if desired_tagged_vlan not in port_vlans.get('tagged', []): @@ -570,7 +578,7 @@ def _ping_ajax(self, handle_val, host_name_ipaddr): probesent = int(res[10]); proberesponse = int(res[11]); probefail = int(res[12]); - + if (not isnan(handle)) and handle != 0: results = '' if respip != host_name_ipaddr and respip != '0.0.0.0': @@ -581,7 +589,7 @@ def _ping_ajax(self, handle_val, host_name_ipaddr): results = 'Request Timed Out.' if probesent == self.count: self.done = 1 - + if respip != '' and seq != self.seq: print(results) elif respip != '0.0.0.0' and respip != host_name_ipaddr and self.probessent < probesent: @@ -647,7 +655,7 @@ def _ping_ajax(self, handle_val, host_name_ipaddr): # private module function def httpRequest(session, request_method, url, post_data = None, files = None, timeout = 0): - # GET + # GET if request_method == 'GET': return session.get(url, verify = False).text diff --git a/library/hpe1820_port_vlans.py b/library/hpe1820_port_vlans.py old mode 100644 new mode 100755 index 4f6e070..e58c304 --- a/library/hpe1820_port_vlans.py +++ b/library/hpe1820_port_vlans.py @@ -47,13 +47,16 @@ ''' EXAMPLES = ''' -- name: Ensure port 2 has two VLANs assigned +- name: Ensure port 2 has two VLANs assigned and 3 none hpe1820_port_vlans: port_vlans: '2': untagged: 2900 tagged: - 295 + '3': + untagged: null + tagged: [] host: '{{ inventory_hostname }}' username: '{{ hpe1820__username | d(omit) }}' password: '{{ hpe1820__password }}' diff --git a/playbooks/netbox_vlans.yml b/playbooks/netbox_vlans.yml index 501d96c..3b01ae5 100644 --- a/playbooks/netbox_vlans.yml +++ b/playbooks/netbox_vlans.yml @@ -11,23 +11,27 @@ hpe1820__port_vlans: | {% macro print_config() %} {% for interface in interfaces|sort(attribute="id") %} - {% if interface.untagged_vlan|d() or interface.tagged_vlans|d() %} '{{ interface.name }}': - {% if interface.untagged_vlan|d() %} + {% if interface.untagged_vlan|d() or interface.tagged_vlans|d() %} + {% if interface.untagged_vlan|d() %} untagged: {{ interface.untagged_vlan.vid }} - {% endif %} - {% if interface.tagged_vlans|d() %} + {% else %} tagged: {% for vlan in (interface.tagged_vlans) if vlan %} - {{ vlan.vid }} {% endfor %} - {% else %} - tagged: [] {% endif %} + {% else %} + # Set to untagged default VLAN. + # > If you wish to exclude a port from the current VLAN + # > membership you must first make it a tagged/untagged member in + # > another VLAN. + untagged: 1 + tagged: [] {% endif %} {% endfor %} {% endmacro %} - {{ print_config() | from_yaml }} + {{ print_config() }} environment: '{{ inventory__environment | d({}) | combine(inventory__group_environment | d({})) @@ -41,7 +45,7 @@ - name: Ensure VLANs to ports hpe1820_port_vlans: - port_vlans: '{{ hpe1820__port_vlans }}' + port_vlans: '{{ hpe1820__port_vlans | from_yaml }}' host: '{{ inventory_hostname }}' username: '{{ hpe1820__username | d(omit) }}' password: '{{ hpe1820__password }}' From 073e6f7d8ad72623b6bc5cfed0388fbb542ca74d Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Mon, 1 Nov 2021 22:45:59 +0100 Subject: [PATCH 5/9] docs: Ansible module username parameter is optional, docs said required --- library/hpe1820_port_vlans.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/hpe1820_port_vlans.py b/library/hpe1820_port_vlans.py index e58c304..2c5c11f 100755 --- a/library/hpe1820_port_vlans.py +++ b/library/hpe1820_port_vlans.py @@ -36,7 +36,7 @@ username: description: - Username to login on the switch as. - required: true + default: admin password: description: - Password to use for login. @@ -93,7 +93,7 @@ def run_module(): argument_spec=dict( port_vlans=dict(required=True, type='dict'), host=dict(required=True, type='str'), - username=dict(required=False, type='str', default='admin'), + username=dict(type='str', default='admin'), password=dict(required=True, type='str', no_log=True), ), supports_check_mode=True, From 52a4e2ae36b20fc6f04e2a4abaabd09c8e44e94d Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Mon, 1 Nov 2021 22:49:42 +0100 Subject: [PATCH 6/9] feat: Add new parameter remove_unused_vlans, defaults to True --- lib/cli.py | 26 ++++++++++++++++++++++---- library/hpe1820_port_vlans.py | 11 +++++++++++ playbooks/netbox_vlans.yml | 1 + 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index f7c1254..a8515b7 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -143,7 +143,6 @@ def get_interface_vlan_membership_change_actions(self, interface, port_vlans, de vlan_vids = [int(vlan[0]) for vlan in self.getVlans() if len(vlan) == 3] if 'untagged' in desired_port_vlans: if desired_port_vlans['untagged'] is None and 'untagged' in port_vlans: - # TODO: Remove unused VLANs from switch. Needs to be done at a later point. # The switch does not support that: # > If you wish to exclude a port from the current VLAN # > membership you must first make it a tagged/untagged member in @@ -164,6 +163,26 @@ def get_interface_vlan_membership_change_actions(self, interface, port_vlans, de change_actions.append(('accessVlan', 'exclude', interface, tagged_vlan)) return change_actions + def remove_unused_vlans(self, dry_run=False): + defined_vlan_vids = set([int(vlan[0]) for vlan in self.getVlans() if len(vlan) == 3]) + used_vlan_vids = set() + for interface, port_vlans in self.get_interfaces_vlan_membership().items(): + used_vlan_vids.update(port_vlans.get('tagged', [])) + if 'untagged' in port_vlans: + used_vlan_vids.add(int(port_vlans['untagged'])) + + change_actions = [] + unused_vlan_vids = defined_vlan_vids.difference(used_vlan_vids) + if len(unused_vlan_vids) > 0: + change_actions.append(('delVlan', ','.join([str(x) for x in unused_vlan_vids]))) + + if not dry_run and len(change_actions) > 0: + for change_action in change_actions: + getattr(self, change_action[0])(*change_action[1:]) + self.saveConfig() + + return change_actions + def ensure_interfaces_vlan_membership(self, desired_port_vlans, dry_run=False): change_actions = [] interfaces_vlan_membership = self.get_interfaces_vlan_membership() @@ -179,11 +198,10 @@ def ensure_interfaces_vlan_membership(self, desired_port_vlans, dry_run=False): port_vlans, desired_port_vlans.get(interface, {}), )) - if not dry_run: + if not dry_run and len(change_actions) > 0: for change_action in change_actions: getattr(self, change_action[0])(*change_action[1:]) - if len(change_actions) > 0: - self.saveConfig() + self.saveConfig() return change_actions diff --git a/library/hpe1820_port_vlans.py b/library/hpe1820_port_vlans.py index 2c5c11f..d63cb17 100755 --- a/library/hpe1820_port_vlans.py +++ b/library/hpe1820_port_vlans.py @@ -29,6 +29,12 @@ description: - Port to VLANs mapping dict. See example for the structure. required: true + remove_unused_vlans: + description: + - Remove all VLANs that are not used by any port. Note that --diff + mode will be wrong when VLANs are added by port_vlans that were + unused. + default: true host: description: - Hostname of the switch. @@ -92,6 +98,7 @@ def run_module(): module = AnsibleModule( argument_spec=dict( port_vlans=dict(required=True, type='dict'), + remove_unused_vlans=dict(type='bool', default=True), host=dict(required=True, type='str'), username=dict(type='str', default='admin'), password=dict(required=True, type='str', no_log=True), @@ -100,6 +107,7 @@ def run_module(): ) port_vlans = module.params['port_vlans'] + remove_unused_vlans = module.params['remove_unused_vlans'] host = module.params['host'] username = module.params['username'] password = module.params['password'] @@ -117,6 +125,9 @@ def run_module(): change_actions = cli.ensure_interfaces_vlan_membership(port_vlans, dry_run=module.check_mode) + if remove_unused_vlans: + change_actions += cli.remove_unused_vlans(dry_run=module.check_mode) + # This only slows it down. Could be made conditional if anybody has a valid use case for it. # result['port_vlans'] = cli.get_interfaces_vlan_membership() diff --git a/playbooks/netbox_vlans.yml b/playbooks/netbox_vlans.yml index 3b01ae5..a6499d9 100644 --- a/playbooks/netbox_vlans.yml +++ b/playbooks/netbox_vlans.yml @@ -15,6 +15,7 @@ {% if interface.untagged_vlan|d() or interface.tagged_vlans|d() %} {% if interface.untagged_vlan|d() %} untagged: {{ interface.untagged_vlan.vid }} + tagged: [] {% else %} tagged: {% for vlan in (interface.tagged_vlans) if vlan %} From eda49686b2abf73fda504455512e42bdd8fef36b Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Mon, 1 Nov 2021 22:52:27 +0100 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20Link=20aggregation=20member=20ports?= =?UTF-8?q?=20don=E2=80=99t=20have=20VLANs,=20don=E2=80=99t=20try=20to=20d?= =?UTF-8?q?elete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/cli.py b/lib/cli.py index a8515b7..b703a17 100644 --- a/lib/cli.py +++ b/lib/cli.py @@ -141,8 +141,8 @@ def get_interfaces_vlan_membership(self): def get_interface_vlan_membership_change_actions(self, interface, port_vlans, desired_port_vlans): change_actions = [] vlan_vids = [int(vlan[0]) for vlan in self.getVlans() if len(vlan) == 3] - if 'untagged' in desired_port_vlans: - if desired_port_vlans['untagged'] is None and 'untagged' in port_vlans: + if 'untagged' in desired_port_vlans and 'untagged' in port_vlans: + if desired_port_vlans['untagged'] is None: # The switch does not support that: # > If you wish to exclude a port from the current VLAN # > membership you must first make it a tagged/untagged member in From 68e29bc1049a5bb1bae0c1e33e326210cc31e120 Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Thu, 3 Nov 2022 17:51:21 +0100 Subject: [PATCH 8/9] fix: This does not work with Mitogen, set strategy to linear ``` ModuleNotFoundError: 'lib' is present in the Mitogen importer blacklist, therefore this context will not attempt to request it from the master, as the request will always be refused. ``` --- playbooks/netbox_vlans.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/playbooks/netbox_vlans.yml b/playbooks/netbox_vlans.yml index a6499d9..b1b264c 100644 --- a/playbooks/netbox_vlans.yml +++ b/playbooks/netbox_vlans.yml @@ -7,6 +7,7 @@ hosts: [ 'platform_hpe1820' ] become: False gather_facts: False + strategy: 'linear' vars: hpe1820__port_vlans: | {% macro print_config() %} From 136315711acfd66b6e93571e1d027f5b1c1b11be Mon Sep 17 00:00:00 2001 From: Robin Schneider Date: Sat, 30 Mar 2024 16:56:49 +0100 Subject: [PATCH 9/9] fix: support newer Ansible versions like ansible core 2.14.x Ref: https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#ansiballz-framework --- library/hpe1820_port_vlans.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/hpe1820_port_vlans.py b/library/hpe1820_port_vlans.py index d63cb17..abe17a2 100755 --- a/library/hpe1820_port_vlans.py +++ b/library/hpe1820_port_vlans.py @@ -86,6 +86,8 @@ from ansible.module_utils.basic import AnsibleModule +import sys +sys.path.append('..') import lib.cli