From 6ade4f2f8a5ae2aa49c5af1006276d72b36519a6 Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Thu, 18 Sep 2025 13:45:16 +0200 Subject: [PATCH] access: fix VLAN database idempotency Do not try and match the global VLAN list as printed by the switch. Instead, only try to realize the truth: there may be some VLANs added and some removed. We keep the compact_numlist filter and use it instead of the built-in vlan_parser when listing VLANs for tagged ports. This is because some switches compact 1,2,4,5,6 as 1-2,4-6 and others as 1,2,4-6 (see next commit). All of this should reduce the number of cases where Ansible reports a change in configuration where there was in fact no change. --- filter_plugins/netbox.py | 42 +++++++++++-------------- roles/access/tasks/d-link.yml | 4 --- roles/access/tasks/main.yml | 14 +++++++++ roles/access/templates/config-d-link.j2 | 7 ++++- roles/access/templates/config-fs.j2 | 7 +++-- 5 files changed, 43 insertions(+), 31 deletions(-) diff --git a/filter_plugins/netbox.py b/filter_plugins/netbox.py index 02ad61a..0762cc1 100644 --- a/filter_plugins/netbox.py +++ b/filter_plugins/netbox.py @@ -14,36 +14,30 @@ class FilterModule(object): 'iface_vlans': self.iface_vlans } - def compact_numlist(self, nums, sort=True, delimiter=',', range_delimiter='-', max_per_line=None): + def compact_numlist(self, nums, sort=True, delimiter=',', range_delimiter='-', min_join=2): '''Transform [1,2,3,5,7,8,9] into "1-3,5,7-9". - If max_per_line is given, return a list of such strings where each string contains at most max_per_line+1 numbers. - This emulates how VLAN ranges are displayed by FS switches so we can make ansible check mode work correctly. + Do not create a range from fewer than min_join consecutive numbers. ''' if sort: nums = sorted(nums) - i = 0 - lines = [] - line = [] - nums_in_line = 0 - while i < len(nums): - j = i + 1 - while j < len(nums) and nums[j]-nums[i] == j-i: - j += 1 - if j > i+1: - line += [f'{nums[i]}{range_delimiter}{nums[j-1]}'] - nums_in_line += 2 + ranges = [] + r = [] + for num in nums: + if r and num > r[-1]+1: + ranges += [r] + r = [] + r += [num] + if r: + ranges += [r] + + def format_range(r): + if len(r) < min_join: + return delimiter.join(str(n) for n in r) else: - line += [f'{nums[i]}'] - nums_in_line += 1 - if max_per_line and nums_in_line >= max_per_line: - lines += [delimiter.join(line)] - line = [] - nums_in_line = 0 - i = j - if line: - lines += [delimiter.join(line)] - return lines if max_per_line else lines[0] + return f'{r[0]}{range_delimiter}{r[-1]}' + + return delimiter.join(format_range(r) for r in ranges) def device_address(self, device): '''Return loopback IP addresses for an L3 attached device''' diff --git a/roles/access/tasks/d-link.yml b/roles/access/tasks/d-link.yml index 413fe87..b9f525c 100644 --- a/roles/access/tasks/d-link.yml +++ b/roles/access/tasks/d-link.yml @@ -13,10 +13,6 @@ set_fact: snmp_hashes: '{{ (snmp_config.stdout | from_yaml).snmpv3.hashes }}' -- name: Get switch facts - cisco.ios.ios_facts: - gather_subset: config - - name: Get SNMP users set_fact: snmp_current: "{{ ansible_net_config | split('\n') | select('match', '^snmp-server user '+manager.snmp_user+' public v3') }}" diff --git a/roles/access/tasks/main.yml b/roles/access/tasks/main.yml index 0b759f8..eee6521 100644 --- a/roles/access/tasks/main.yml +++ b/roles/access/tasks/main.yml @@ -11,6 +11,20 @@ set_fact: snmp_engine_id: '{{ (serial | sha1)[:24] }}' +- name: Get switch facts + cisco.ios.ios_facts: + gather_subset: config + +# Determine VLANs to add and remove from switch. +- set_fact: + actual_vlans: "{{ vlans | map(attribute='vid') }}" + switch_vlans: "{{ ansible_net_config | split('\n') | select('match', '^ *vlan (range )?[0-9]') + | map('regex_search', '[0-9,-]+') | join(',') | ansible.netcommon.vlan_expander }}" + +- set_fact: + add_vlans: "{{ actual_vlans | difference(switch_vlans) }}" + del_vlans: "{{ switch_vlans | difference(actual_vlans) }}" + - name: Set configuration ansible.netcommon.cli_config: config: '{{ lookup("template", "config-"~manufacturer~"-"~device_type~".j2") }}' diff --git a/roles/access/templates/config-d-link.j2 b/roles/access/templates/config-d-link.j2 index 1a4b3b5..78680f0 100644 --- a/roles/access/templates/config-d-link.j2 +++ b/roles/access/templates/config-d-link.j2 @@ -10,7 +10,12 @@ port-channel load-balance src-dst-ip ip ssh server -vlan {{ vlans | map(attribute='vid') | compact_numlist }} +{% for vlan in add_vlans %} +vlan {{ vlan }} +{% endfor %} +{% for vlan in del_vlans %} +no vlan {{ vlan }} +{% endfor %} {# bond members #} {% for iface in interfaces | selectattr('lag') %} diff --git a/roles/access/templates/config-fs.j2 b/roles/access/templates/config-fs.j2 index cc17f14..036b709 100644 --- a/roles/access/templates/config-fs.j2 +++ b/roles/access/templates/config-fs.j2 @@ -6,8 +6,11 @@ no enable service telnet-server no enable service web-server http no enable service web-server https -{% for vlan_range in vlans | map(attribute='vid') | union([1]) | compact_numlist(max_per_line=19) %} -vlan range {{ vlan_range }} +{% for vlan in add_vlans %} + vlan {{ vlan }} +{% endfor %} +{% for vlan in del_vlans | difference([1]) %} {# VLAN 1 can not be deleted #} + no vlan {{ vlan }} {% endfor %} {% for iface in interfaces %}