Compare commits

...

4 commits

Author SHA1 Message Date
343fd0daad access: add support for the FS S5800 switch 2025-09-19 10:56:20 +02:00
9ec6241e4a access: fix parsing of existing switch VLANs
If ansible won’t handle edge cases then we must.
2025-09-18 16:47:39 +02:00
6ade4f2f8a 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.
2025-09-18 13:54:39 +02:00
bd4299732d leaf: don’t activate disabled interfaces in the EVPN family 2025-08-14 17:09:47 +02:00
8 changed files with 125 additions and 56 deletions

View file

@ -14,36 +14,30 @@ class FilterModule(object):
'iface_vlans': self.iface_vlans '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". '''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. Do not create a range from fewer than min_join consecutive numbers.
This emulates how VLAN ranges are displayed by FS switches so we can make ansible check mode work correctly.
''' '''
if sort: if sort:
nums = sorted(nums) nums = sorted(nums)
i = 0 ranges = []
lines = [] r = []
line = [] for num in nums:
nums_in_line = 0 if r and num > r[-1]+1:
while i < len(nums): ranges += [r]
j = i + 1 r = []
while j < len(nums) and nums[j]-nums[i] == j-i: r += [num]
j += 1 if r:
if j > i+1: ranges += [r]
line += [f'{nums[i]}{range_delimiter}{nums[j-1]}']
nums_in_line += 2 def format_range(r):
if len(r) < min_join:
return delimiter.join(str(n) for n in r)
else: else:
line += [f'{nums[i]}'] return f'{r[0]}{range_delimiter}{r[-1]}'
nums_in_line += 1
if max_per_line and nums_in_line >= max_per_line: return delimiter.join(format_range(r) for r in ranges)
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]
def device_address(self, device): def device_address(self, device):
'''Return loopback IP addresses for an L3 attached device''' '''Return loopback IP addresses for an L3 attached device'''

View file

@ -13,10 +13,6 @@
set_fact: set_fact:
snmp_hashes: '{{ (snmp_config.stdout | from_yaml).snmpv3.hashes }}' snmp_hashes: '{{ (snmp_config.stdout | from_yaml).snmpv3.hashes }}'
- name: Get switch facts
cisco.ios.ios_facts:
gather_subset: config
- name: Get SNMP users - name: Get SNMP users
set_fact: set_fact:
snmp_current: "{{ ansible_net_config | split('\n') | select('match', '^snmp-server user '+manager.snmp_user+' public v3') }}" snmp_current: "{{ ansible_net_config | split('\n') | select('match', '^snmp-server user '+manager.snmp_user+' public v3') }}"

View file

@ -0,0 +1 @@
fs.yml

View file

@ -11,6 +11,21 @@
set_fact: set_fact:
snmp_engine_id: '{{ (serial | sha1)[:24] }}' 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(',')
| default('0', true) | ansible.netcommon.vlan_expander | reject('eq', 0) }}" # vlan_expander barfs on empty string so add/remove a fake VLAN 0
- set_fact:
add_vlans: "{{ actual_vlans | difference(switch_vlans) }}"
del_vlans: "{{ switch_vlans | difference(actual_vlans) }}"
- name: Set configuration - name: Set configuration
ansible.netcommon.cli_config: ansible.netcommon.cli_config:
config: '{{ lookup("template", "config-"~manufacturer~"-"~device_type~".j2") }}' config: '{{ lookup("template", "config-"~manufacturer~"-"~device_type~".j2") }}'
@ -19,7 +34,7 @@
ansible_terminal_stderr_re: [] # some errors are not actually errors ansible_terminal_stderr_re: [] # some errors are not actually errors
register: result register: result
# These lines are not displayed by 'sho ru' and always reported as different, so ignore them. # These lines are not displayed by 'sho ru' and always reported as different, so ignore them.
changed_when: result.commands | reject('match', '^(no shutdown|no switchport access vlan|no switchport trunk native vlan|no voice vlan.*|switchport mode access|switchport mode hybrid|interface .*|no enable service web-server https?|no ip dhcp snooping|no ip dhcp snooping trust|no switchport port-security.*)$') changed_when: result.commands | reject('match', '^(no shutdown|no switchport access vlan|no switchport trunk native vlan|no voice vlan.*|switchport mode access|switchport mode hybrid|interface .*|service http disable|no enable service web-server https?|no ip dhcp snooping|no ip dhcp snooping trust|no switchport port-security.*)$')
notify: write config notify: write config
- name: Run model-specific tasks - name: Run model-specific tasks

View file

@ -10,7 +10,12 @@ port-channel load-balance src-dst-ip
ip ssh server 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 #} {# bond members #}
{% for iface in interfaces | selectattr('lag') %} {% for iface in interfaces | selectattr('lag') %}

View file

@ -1,40 +1,41 @@
hostname {{ inventory_hostname }} hostname {{ inventory_hostname }}
no netconf enable service http disable
service telnet disable
no enable service telnet-server vlan database
no enable service web-server http {% for vlan in add_vlans %}
no enable service web-server https vlan {{ vlan }}
{% for vlan_range in vlans | map(attribute='vid') | union([1]) | compact_numlist(max_per_line=19) %}
vlan range {{ vlan_range }}
{% endfor %} {% endfor %}
{% for vlan in del_vlans %}
no vlan {{ vlan }}
{% endfor %}
exit
{% for iface in interfaces %} {# sort to ensure LAG interfaces are added last #}
interface {{ iface.name }} {% for iface in interfaces | sort(attribute="type.value") | sort(attribute="mgmt_only") %}
{% if iface.enabled %} no{% endif %} shutdown {% if iface.mgmt_only %}
{% if iface.lag %}
port-group {{ iface.lag.name | select('in', '0123456789') | join('') }} mode active
{% elif iface.mgmt_only %}
{% for address in iface.ip_addresses %} {% for address in iface.ip_addresses %}
{% set subnet = address.address | ipaddr('subnet') %} {% set subnet = address.address | ipaddr('subnet') %}
{% set prefix = prefixes | selectattr('prefix', '==', subnet) | first %} {% set prefix = prefixes | selectattr('prefix', '==', subnet) | first %}
{% if address.family.value == 4 %} {% if address.family.value == 4 %}
ip address {{ address.address | ipaddr('address') }} {{ address.address | ipaddr('netmask') }} management ip address {{ address.address }}
{% if prefix.custom_fields.gateway %} {% if prefix.custom_fields.gateway %}
gateway {{ prefix.custom_fields.gateway.address | ipaddr('address') }} management route add gateway {{ prefix.custom_fields.gateway.address | ipaddr('address') }}
{% endif %} {% endif %}
{% else %} {% else %}
ipv6 address {{ address.address | upper }} management ipv6 address {{ address.address }}
{% if prefix.custom_fields.gateway %}
ipv6 gateway {{ prefix.custom_fields.gateway.address | ipaddr('address') | upper }}
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
mtu {{ iface.mtu | default('9216', true) }}
interface {{ iface.name }}
{% if iface.enabled %} no{% endif %} shutdown
{% if iface.lag %}
channel-group {{ iface.lag.name | select('in', '0123456789') | join('') }} mode active
{% else %}
{% if iface.mode and iface.mode.value == 'access' %} {% if iface.mode and iface.mode.value == 'access' %}
switchport mode access switchport mode access
{% if iface.untagged_vlan and iface.untagged_vlan.vid != 1 %} {% if iface.untagged_vlan and iface.untagged_vlan.vid != 1 %}
@ -46,9 +47,10 @@ interface {{ iface.name }}
switchport mode trunk switchport mode trunk
switchport trunk allowed vlan only {{ (iface.tagged_vlans or vlans) | map(attribute='vid') | compact_numlist }} switchport trunk allowed vlan only {{ (iface.tagged_vlans or vlans) | map(attribute='vid') | compact_numlist }}
{%- elif iface.mode and iface.mode.value == 'tagged-all' %} {%- elif iface.mode and iface.mode.value == 'tagged-all' %}
switchport mode uplink switchport mode trunk
switchport trunk allowed vlan only 2-4094 switchport trunk allowed vlan all
{% endif %} {% endif %}
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -1 +0,0 @@
config-fs.j2

View file

@ -0,0 +1,57 @@
hostname {{ inventory_hostname }}
no netconf enable
no enable service telnet-server
no enable service web-server http
no enable service web-server https
{% 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 %}
interface {{ iface.name }}
{% if iface.enabled %} no{% endif %} shutdown
{% if iface.lag %}
port-group {{ iface.lag.name | select('in', '0123456789') | join('') }} mode active
{% elif iface.mgmt_only %}
{% for address in iface.ip_addresses %}
{% set subnet = address.address | ipaddr('subnet') %}
{% set prefix = prefixes | selectattr('prefix', '==', subnet) | first %}
{% if address.family.value == 4 %}
ip address {{ address.address | ipaddr('address') }} {{ address.address | ipaddr('netmask') }}
{% if prefix.custom_fields.gateway %}
gateway {{ prefix.custom_fields.gateway.address | ipaddr('address') }}
{% endif %}
{% else %}
ipv6 address {{ address.address | upper }}
{% if prefix.custom_fields.gateway %}
ipv6 gateway {{ prefix.custom_fields.gateway.address | ipaddr('address') | upper }}
{% endif %}
{% endif %}
{% endfor %}
{% else %}
mtu {{ iface.mtu | default('9216', true) }}
{% if iface.mode and iface.mode.value == 'access' %}
switchport mode access
{% if iface.untagged_vlan and iface.untagged_vlan.vid != 1 %}
switchport access vlan {{ iface.untagged_vlan.vid }}
{% else %}
no switchport access vlan
{% endif %}
{%- elif iface.mode and iface.mode.value == 'tagged' %}
switchport mode trunk
switchport trunk allowed vlan only {{ (iface.tagged_vlans or vlans) | map(attribute='vid') | compact_numlist }}
{%- elif iface.mode and iface.mode.value == 'tagged-all' %}
switchport mode uplink
switchport trunk allowed vlan only 2-4094
{% endif %}
{% endif %}
{% endfor %}

View file

@ -55,8 +55,8 @@ router bgp {{ asn.asn }}
{% endfor %} {% endfor %}
address-family l2vpn evpn address-family l2vpn evpn
neighbor fabric activate neighbor fabric activate
{% for iface in ifaces_evpn|default([]) %} {% for iface in interfaces | selectattr('enabled') | selectattr('name', 'in', ifaces_evpn|default([])) %}
neighbor {{ iface }} activate neighbor {{ iface.name }} activate
{% endfor %} {% endfor %}
{% if peer is defined and interfaces | selectattr('mode') %} {% if peer is defined and interfaces | selectattr('mode') %}
advertise-all-vni advertise-all-vni