From 3bbf736d8d4672460ab42576d1679b2465b06810 Mon Sep 17 00:00:00 2001 From: Mark Goddard Date: Fri, 12 Feb 2021 16:42:53 +0000 Subject: [PATCH] Ubuntu: support systemd-networkd This change adds support for network configuration via systemd-networkd on Ubuntu systems. This is implemented via an Ansible Galaxy role, stackhpc.systemd_networkd which was forked from aruhier.systemd_networkd. Several improvements were made in https://github.com/stackhpc/ansible-role-systemd-networkd/pull/1, including: * Add support for removing unexpected config files * Use become where necessary * Refactor config generation into a single task to improve performance The systemd_networkd role does not add much abstraction on top of the systemd-networkd configuration file format, which provides a lot of flexibility at the expense of additional code in Kayobe. This code is implemented as filter plugins, similarly to the existing MichaelRigart.interfaces role. This patch includes support for: * Ethernet interfaces * bridges * bonds * VLANs * virtual Ethernet pairs (to connect Linux bridges and OVS bridges) * static IP addresses * static routes * MTU Some network attributes are currently not supported for systemd-networkd: * rules * route options * ethtool_opts * zone * allowed addresses Story: 2004960 Task: 41881 Change-Id: I248b5bb9ce5a80a07a2a311cb3aca6daca920720 --- ansible/filter_plugins/networkd.py | 22 + ansible/group_vars/all/network | 6 + .../roles/network-debian/handlers/main.yml | 16 + ansible/roles/network-debian/tasks/main.yml | 62 +- .../configuration/reference/network.rst | 6 + doc/source/contributor/automated.rst | 2 +- kayobe/plugins/filter/networkd.py | 571 +++++++++++++ .../unit/plugins/filter/test_networkd.py | 748 ++++++++++++++++++ .../overrides.yml.j2 | 5 - .../tests/test_overcloud_host_configure.py | 15 +- requirements.yml | 2 + .../kayobe-network-bootstrap/tasks/Debian.yml | 38 - .../kayobe-network-bootstrap/tasks/RedHat.yml | 14 - roles/kayobe-network-bootstrap/tasks/main.yml | 17 +- 14 files changed, 1412 insertions(+), 112 deletions(-) create mode 100644 ansible/filter_plugins/networkd.py create mode 100644 ansible/roles/network-debian/handlers/main.yml create mode 100644 kayobe/plugins/filter/networkd.py create mode 100644 kayobe/tests/unit/plugins/filter/test_networkd.py delete mode 100644 roles/kayobe-network-bootstrap/tasks/Debian.yml delete mode 100644 roles/kayobe-network-bootstrap/tasks/RedHat.yml diff --git a/ansible/filter_plugins/networkd.py b/ansible/filter_plugins/networkd.py new file mode 100644 index 000000000..6321e33d6 --- /dev/null +++ b/ansible/filter_plugins/networkd.py @@ -0,0 +1,22 @@ +# Copyright (c) 2021 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from kayobe.plugins.filter import networkd + + +class FilterModule(object): + """Systemd-networkd filters.""" + + def filters(self): + return networkd.get_filters() diff --git a/ansible/group_vars/all/network b/ansible/group_vars/all/network index 998a2d823..dbb263712 100644 --- a/ansible/group_vars/all/network +++ b/ansible/group_vars/all/network @@ -86,3 +86,9 @@ network_patch_suffix_ovs: '-ovs' # List of IP routing tables. Each item should be a dict containing 'id' and # 'name' items. These tables will be added to /etc/iproute2/rt_tables. network_route_tables: [] + +############################################################################### +# Systemd-networkd configuration. + +# Prefix for systemd-networkd configuration file names. +networkd_prefix: "50-kayobe-" diff --git a/ansible/roles/network-debian/handlers/main.yml b/ansible/roles/network-debian/handlers/main.yml new file mode 100644 index 000000000..04bf83cc6 --- /dev/null +++ b/ansible/roles/network-debian/handlers/main.yml @@ -0,0 +1,16 @@ +--- +- name: Find netplan systemd-networkd configuration + become: true + find: + path: /run/systemd/network + register: netplan_systemd_networkd_config + listen: Remove netplan systemd-networkd configuration + +- name: Remove netplan systemd-networkd configuration + become: true + file: + path: "{{ item.path }}" + state: absent + loop: "{{ netplan_systemd_networkd_config.files }}" + loop_control: + label: "{{ item.path }}" diff --git a/ansible/roles/network-debian/tasks/main.yml b/ansible/roles/network-debian/tasks/main.yml index 06f3c8394..76c74635b 100644 --- a/ansible/roles/network-debian/tasks/main.yml +++ b/ansible/roles/network-debian/tasks/main.yml @@ -1,51 +1,29 @@ --- -- name: Ensure NetworkManager is disabled - service: - name: NetworkManager - state: stopped - enabled: no - become: True - register: nm_result - failed_when: - - nm_result is failed - # Ugh, Ansible's service module doesn't handle uninstalled services. - - "'Could not find the requested service' not in nm_result.msg" - - import_role: name: ahuffman.resolv when: resolv_is_managed | bool become: True -- name: Configure network interfaces (RedHat) - import_role: - name: MichaelRigart.interfaces - vars: - interfaces_route_tables: "{{ network_route_tables }}" - interfaces_ether_interfaces: > - {{ network_interfaces | - net_select_ethers | - map('net_interface_obj') | - list }} - interfaces_bridge_interfaces: > - {{ network_interfaces | - net_select_bridges | - map('net_bridge_obj') | - list }} - interfaces_bond_interfaces: > - {{ network_interfaces | - net_select_bonds | - map('net_bond_obj') | - list }} +- name: Remove netplan.io packages + become: true + package: + name: + - libnetplan0 + - netplan.io + state: absent + notify: + - Remove netplan systemd-networkd configuration -# Ensure that interface bouncing is finished before veth pairs are added, -# since they are only ephemerally configured on Debian. -- name: Flush handlers - meta: flush_handlers - -# Configure virtual ethernet patch links to connect the workload provision -# and external network bridges to the Neutron OVS bridge. -- name: Ensure OVS patch links exist +- name: Configure systemd-networkd import_role: - name: veth + name: stackhpc.systemd_networkd vars: - veth_interfaces: "{{ network_interfaces | net_ovs_veths }}" + systemd_networkd_link: "{{ network_interfaces | networkd_links }}" + systemd_networkd_netdev: "{{ network_interfaces | networkd_netdevs }}" + systemd_networkd_network: "{{ network_interfaces | networkd_networks }}" + systemd_networkd_apply_config: true + systemd_networkd_enable_resolved: false + systemd_networkd_symlink_resolv_conf: false + systemd_networkd_cleanup: true + systemd_networkd_cleanup_patterns: + - "{{ networkd_prefix }}*" diff --git a/doc/source/configuration/reference/network.rst b/doc/source/configuration/reference/network.rst index 34532570b..e4e685931 100644 --- a/doc/source/configuration/reference/network.rst +++ b/doc/source/configuration/reference/network.rst @@ -58,6 +58,8 @@ supported: Fully Qualified Domain Name (FQDN) used by API services on this network. ``routes`` + .. note:: ``options`` is not currently supported on Ubuntu. + List of static IP routes. Each item should be a dict containing the item ``cidr``, and optionally ``gateway``, ``table`` and ``options``. ``cidr`` is the CIDR representation of the route's destination. ``gateway`` @@ -334,11 +336,15 @@ The following attributes are supported: ``bond_lacp_rate`` For bond interfaces, the lacp_rate to use for the bond. ``ethtool_opts`` + .. note:: ``ethtool_opts`` is not currently supported on Ubuntu. + Physical network interface options to apply with ``ethtool``. When used on bond and bridge interfaces, settings apply to underlying interfaces. This should be a string of arguments passed to the ``ethtool`` utility, for example ``"-G ${DEVICE} rx 8192 tx 8192"``. ``zone`` + .. note:: ``zone`` is not currently supported on Ubuntu. + The name of ``firewalld`` zone to be attached to network interface. IP Addresses diff --git a/doc/source/contributor/automated.rst b/doc/source/contributor/automated.rst index 7cbca9135..e3cd2153e 100644 --- a/doc/source/contributor/automated.rst +++ b/doc/source/contributor/automated.rst @@ -240,7 +240,7 @@ Alternatively, this can be added using the following commands:: sudo ip l add breth1 type bridge sudo ip l set breth1 up - sudo ip a add 192.168.33.5/24 dev breth1 + sudo ip a add 192.168.33.5/24 brd 192.168.33.255 dev breth1 sudo ip l add eth1 type dummy sudo ip l set eth1 up sudo ip l set eth1 master breth1 diff --git a/kayobe/plugins/filter/networkd.py b/kayobe/plugins/filter/networkd.py new file mode 100644 index 000000000..5f0934743 --- /dev/null +++ b/kayobe/plugins/filter/networkd.py @@ -0,0 +1,571 @@ +# Copyright (c) 2021 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +This module provides Ansible filters that generate configuration for +systemd-networkd NetDevs, links and networks. The results are compatible with +the stackhpc.ansible_role_systemd_networkd role. + +Systemd-networkd uses INI-style configuration files, with the provision for +multiple sections with the same name, and multiple options with the same name +in a given section. This results in a slightly unwieldy data format used by the +role. The top level is a list of dicts with section names as keys. The values +are lists of dicts mapping option names to values. + +Example schema (YAML): +- section1: + - option1: value1 + - option2: value2 +- section2 + - option3: value3 +""" + +import ipaddress + +from ansible import errors +import jinja2 + +from kayobe.plugins.filter import networks +from kayobe.plugins.filter import utils + + +def _filter_options(config): + """Filter out None values from a networkd config. + + :param config: List of sections to filter. + :returns: a filtered list of sections without empty options. + """ + # Example schema (YAML): + # - section1: + # - option1: value1 + # - option2: + # - section2 + # - option3: + # We can filter this down to the following: + # - section1: + # - option1: value1 + new_config = [] + for section_dict in config: + new_section_dict = {} + for section_name, section in section_dict.items(): + new_section = [] + for option_dict in section: + new_option_dict = {} + for option_name, option in option_dict.items(): + if option is not None: + new_option_dict[option_name] = option + if new_option_dict: + new_section.append(new_option_dict) + if new_section: + new_section_dict[section_name] = new_section + if new_section_dict: + new_config.append(new_section_dict) + return new_config + + +def _ms_to_s(n): + """Convert from milliseconds to seconds.""" + if n is not None: + n = float(n) / 1000 + return n + + +def _vlan_netdev(context, name, inventory_hostname): + """Return a networkd NetDev configuration for a VLAN interface. + + :param context: a Jinja2 Context object. + :param name: name of the network. + :param inventory_hostname: Ansible inventory hostname. + """ + device = networks.net_interface(context, name, inventory_hostname) + mtu = networks.net_mtu(context, name, inventory_hostname) + vlan = networks.net_vlan(context, name, inventory_hostname) + config = [ + { + 'NetDev': [ + {'Name': device}, + {'Kind': 'vlan'}, + {'MTUBytes': mtu}, + ], + }, + { + 'VLAN': [ + {'Id': vlan}, + ] + } + ] + return _filter_options(config) + + +def _bridge_netdev(context, name, inventory_hostname): + """Return a networkd NetDev configuration for a bridge. + + :param context: a Jinja2 Context object. + :param name: name of the network. + :param inventory_hostname: Ansible inventory hostname. + """ + device = networks.net_interface(context, name, inventory_hostname) + mtu = networks.net_mtu(context, name, inventory_hostname) + config = [ + { + 'NetDev': [ + {'Name': device}, + {'Kind': 'bridge'}, + {'MTUBytes': mtu}, + ] + } + ] + return _filter_options(config) + + +def _bond_netdev(context, name, inventory_hostname): + """Return a networkd NetDev configuration for a bond. + + :param context: a Jinja2 Context object. + :param name: name of the network. + :param inventory_hostname: Ansible inventory hostname. + """ + device = networks.net_interface(context, name, inventory_hostname) + mtu = networks.net_mtu(context, name, inventory_hostname) + mode = networks.net_bond_mode(context, name, inventory_hostname) + miimon = networks.net_bond_miimon(context, name, inventory_hostname) + updelay = networks.net_bond_updelay(context, name, inventory_hostname) + downdelay = networks.net_bond_downdelay(context, name, inventory_hostname) + xmit_hash_policy = networks.net_bond_xmit_hash_policy(context, name, + inventory_hostname) + lacp_rate = networks.net_bond_lacp_rate(context, name, inventory_hostname) + config = [ + { + 'NetDev': [ + {'Name': device}, + {'Kind': 'bond'}, + {'MTUBytes': mtu}, + ] + }, + { + 'Bond': [ + {'Mode': mode}, + {'TransmitHashPolicy': xmit_hash_policy}, + {'LACPTransmitRate': lacp_rate}, + {'MIIMonitorSec': _ms_to_s(miimon)}, + {'UpDelaySec': _ms_to_s(updelay)}, + {'DownDelaySec': _ms_to_s(downdelay)}, + ] + } + ] + return _filter_options(config) + + +def _veth_netdev(context, veth, inventory_hostname): + """Return a networkd NetDev configuration for a veth pair. + + :param context: a Jinja2 Context object. + :param veth: a dict describing the virtual Ethernet pair. + :param inventory_hostname: Ansible inventory hostname. + """ + interface = veth['name'] + peer = veth['peer'] + mtu = veth['mtu'] + config = [ + { + 'NetDev': [ + {'Name': interface}, + {'Kind': 'veth'}, + {'MTUBytes': mtu}, + ], + }, + { + 'Peer': [ + {'Name': peer}, + ] + } + ] + return _filter_options(config) + + +def _network(context, name, inventory_hostname, bridge, bond, vlan_interfaces): + """Return a networkd network for an interface. + + :param context: a Jinja2 Context object. + :param name: name of the network. + :param inventory_hostname: Ansible inventory hostname. + :param bridge: Name of a bridge into which the interface is plugged, or + None. + :param bond: Name of a bond of which the interface is a member, or None. + :param vlan_interfaces: List of VLAN subinterfaces of the interface. + """ + # FIXME(mgoddard): Currently does not support: rules, ethtool_opts, zone, + # allowed_addresses. + device = networks.net_interface(context, name, inventory_hostname) + ip = networks.net_ip(context, name, inventory_hostname) + cidr = networks.net_cidr(context, name, inventory_hostname) + gateway = networks.net_gateway(context, name, inventory_hostname) + if ip is None: + gateway = None + else: + if not cidr: + raise errors.AnsibleFilterError( + "No CIDR attribute configured for '%s' network but it has an " + "IP address" % + (name)) + ip = "%s/%s" % (ip, ipaddress.ip_network(cidr).prefixlen) + + mtu = networks.net_mtu(context, name, inventory_hostname) + routes = networks.net_routes(context, name, inventory_hostname) + bootproto = networks.net_bootproto(context, name, inventory_hostname) + defroute = networks.net_defroute(context, name, inventory_hostname) + if defroute is not None: + defroute = utils.call_bool_filter(context, defroute) + config = [ + { + 'Match': [ + {'Name': device}, + ] + }, + { + 'Network': [ + {'Address': ip}, + {'Broadcast': 'true' if ip else None}, + {'Gateway': gateway}, + {'DHCP': ('yes' if bootproto and bootproto.lower() == 'dhcp' + else None)}, + {'UseGateway': ('false' + if defroute is not None and not defroute + else None)}, + {'Bridge': bridge}, + {'Bond': bond}, + ] + [ + {'VLAN': vlan_interface} + for vlan_interface in vlan_interfaces + ] + }, + { + 'Link': [ + {'MTUBytes': mtu}, + ] + }, + ] + if routes: + config += [ + { + 'Route': [ + # FIXME(mgoddard): No support for 'options'. + {'Destination': route['cidr']}, + {'Gateway': route.get('gateway')}, + ] + } + for route in routes or [] + ] + return _filter_options(config) + + +def _bridge_port_network(context, name, port, inventory_hostname, + vlan_interfaces): + """Return a networkd network configuration for a bridge port. + + :param context: a Jinja2 Context object. + :param name: name of the network. + :param port: name of the bridge port interface. + :param inventory_hostname: Ansible inventory hostname. + :param vlan_interfaces: List of VLAN subinterfaces of the interface. + """ + bridge = networks.get_and_validate_interface(context, name, + inventory_hostname) + mtu = networks.net_mtu(context, name, inventory_hostname) + config = [ + { + 'Match': [ + {'Name': port}, + ] + }, + { + 'Network': [ + {'Bridge': bridge}, + ] + [ + {'VLAN': vlan_interface} + for vlan_interface in vlan_interfaces + ] + }, + { + 'Link': [ + {'MTUBytes': mtu}, + ] + } + ] + return _filter_options(config) + + +def _bond_member_network(context, name, member, inventory_hostname, + vlan_interfaces): + """Return a networkd network configuration for a bond member. + + :param context: a Jinja2 Context object. + :param name: name of the network. + :param member: name of the bond member interface. + :param inventory_hostname: Ansible inventory hostname. + :param vlan_interfaces: List of VLAN subinterfaces of the interface. + """ + bond = networks.get_and_validate_interface(context, name, + inventory_hostname) + mtu = networks.net_mtu(context, name, inventory_hostname) + config = [ + { + 'Match': [ + {'Name': member}, + ] + }, + { + 'Network': [ + {'Bond': bond}, + ] + [ + {'VLAN': vlan_interface} + for vlan_interface in vlan_interfaces + ] + }, + { + 'Link': [ + {'MTUBytes': mtu}, + ] + } + ] + return _filter_options(config) + + +def _veth_network(context, veth, inventory_hostname): + """Return a networkd network configuration for a veth link. + + :param context: a Jinja2 Context object. + :param veth: a dict describing the virtual Ethernet pair. + :param inventory_hostname: Ansible inventory hostname. + """ + interface = veth['name'] + bridge = veth['bridge'] + config = [ + { + 'Match': [ + {'Name': interface}, + ] + }, + { + 'Network': [ + {'Bridge': bridge}, + ] + } + ] + return _filter_options(config) + + +def _veth_peer_network(context, veth, inventory_hostname): + """Return a networkd network configuration for a veth peer. + + :param context: a Jinja2 Context object. + :param veth: a dict describing the virtual Ethernet pair. + :param inventory_hostname: Ansible inventory hostname. + """ + interface = veth['peer'] + config = [ + { + 'Match': [ + {'Name': interface}, + ] + }, + { + 'Network': [ + # NOTE(mgoddard): bring the interface up, even without an IP. + {'ConfigureWithoutCarrier': 'true'}, + ] + } + ] + return _filter_options(config) + + +@jinja2.contextfilter +def networkd_netdevs(context, names, inventory_hostname=None): + """Return a dict representation of networkd NetDev configuration. + + The format is compatible with the systemd_networkd_netdev variable in the + stackhpc.ansible_role_systemd_networkd role. + + :param context: a Jinja2 Context object. + :param names: List of names of networks. + :param inventory_hostname: Ansible inventory hostname. + :returns: a dict representation of networkd NetDev configuration. + """ + # Prefix for configuration file names. + prefix = utils.get_hostvar(context, "networkd_prefix", inventory_hostname) + + result = {} + + # VLANs. + for name in networks.net_select_vlans(context, names, inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + netdev = _vlan_netdev(context, name, inventory_hostname) + result["%s%s" % (prefix, device)] = netdev + + # Bridges. + for name in networks.net_select_bridges(context, names, + inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + netdev = _bridge_netdev(context, name, inventory_hostname) + result["%s%s" % (prefix, device)] = netdev + + # Bonds. + for name in networks.net_select_bonds(context, names, inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + netdev = _bond_netdev(context, name, inventory_hostname) + result["%s%s" % (prefix, device)] = netdev + + # Virtual Ethernet pairs. + veths = networks.get_ovs_veths(context, names, inventory_hostname) + for veth in veths: + netdev = _veth_netdev(context, veth, inventory_hostname) + device = veth['name'] + result["%s%s" % (prefix, device)] = netdev + + return result + + +@jinja2.contextfilter +def networkd_links(context, names, inventory_hostname=None): + """Return a dict representation of networkd link configuration. + + The format is compatible with the systemd_networkd_link variable in the + stackhpc.ansible_role_systemd_networkd role. + + :param context: a Jinja2 Context object. + :param names: List of names of networks. + :param inventory_hostname: Ansible inventory hostname. + :returns: a dict representation of networkd link configuration. + """ + # NOTE(mgoddard): We do not currently support link configuration. + return {} + + +@jinja2.contextfilter +def networkd_networks(context, names, inventory_hostname=None): + """Return a dict representation of networkd network configuration. + + The format is compatible with the systemd_networkd_network variable in the + stackhpc.ansible_role_systemd_networkd role. + + :param context: a Jinja2 Context object. + :param names: List of names of networks. + :param inventory_hostname: Ansible inventory hostname. + :returns: a dict representation of networkd network configuration. + """ + # TODO(mgoddard): some attributes are currently not supported for + # systemd-networkd: rules, route options, ethtool_opts, zone, + # allowed addresses + + # Build up some useful mappings. + bridge_port_to_bridge = {} + bond_member_to_bond = {} + interface_to_vlans = {} + + # List of all interfaces. + interfaces = [ + networks.net_interface(context, name, inventory_hostname) + for name in names + ] + + # Map bridge ports to bridges. + for name in networks.net_select_bridges(context, names, + inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + for port in networks.net_bridge_ports(context, name, + inventory_hostname): + bridge_port_to_bridge[port] = device + + # Map bond members to bonds. + for name in networks.net_select_bonds(context, names, inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + for member in networks.net_bond_slaves(context, name, + inventory_hostname): + bond_member_to_bond[member] = device + + # Map interfaces to lists of VLAN subinterfaces. + for name in networks.net_select_vlans(context, names, inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + vlan = networks.net_vlan(context, name, inventory_hostname) + parent = networks.get_vlan_parent(device, vlan) + vlan_interfaces = interface_to_vlans.setdefault(parent, []) + vlan_interfaces.append(device) + + # Prefix for configuration file names. + prefix = utils.get_hostvar(context, "networkd_prefix", inventory_hostname) + + result = {} + + # Configured networks. + for name in names: + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + bridge = bridge_port_to_bridge.get(device) + bond = bond_member_to_bond.get(device) + vlan_interfaces = interface_to_vlans.get(device, []) + net = _network(context, name, inventory_hostname, bridge, bond, + vlan_interfaces) + result["%s%s" % (prefix, device)] = net + + # Bridge ports that are not in configured networks. + for name in networks.net_select_bridges(context, names, + inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + bridge_ports = networks.net_bridge_ports(context, name, + inventory_hostname) + for port in set(bridge_ports) - set(interfaces): + vlan_interfaces = interface_to_vlans.get(port, []) + netdev = _bridge_port_network(context, name, port, + inventory_hostname, vlan_interfaces) + result["%s%s" % (prefix, port)] = netdev + + # Bond members that are not in configured networks. + for name in networks.net_select_bonds(context, names, inventory_hostname): + device = networks.get_and_validate_interface(context, name, + inventory_hostname) + bond_members = networks.net_bond_slaves(context, name, + inventory_hostname) + for member in set(bond_members) - set(interfaces): + vlan_interfaces = interface_to_vlans.get(member, []) + netdev = _bond_member_network(context, name, member, + inventory_hostname, vlan_interfaces) + result["%s%s" % (prefix, member)] = netdev + + # Virtual Ethernet pairs for Open vSwitch. + veths = networks.get_ovs_veths(context, names, inventory_hostname) + for veth in veths: + net = _veth_network(context, veth, inventory_hostname) + device = veth['name'] + result["%s%s" % (prefix, device)] = net + + net = _veth_peer_network(context, veth, inventory_hostname) + device = veth['peer'] + result["%s%s" % (prefix, device)] = net + + return result + + +def get_filters(): + return { + 'networkd_netdevs': networkd_netdevs, + 'networkd_links': networkd_links, + 'networkd_networks': networkd_networks, + } diff --git a/kayobe/tests/unit/plugins/filter/test_networkd.py b/kayobe/tests/unit/plugins/filter/test_networkd.py new file mode 100644 index 000000000..8264301a6 --- /dev/null +++ b/kayobe/tests/unit/plugins/filter/test_networkd.py @@ -0,0 +1,748 @@ +# Copyright (c) 2021 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import unittest + +from ansible import errors +from ansible.plugins.filter.core import to_bool +import jinja2 + +from kayobe.plugins.filter import networkd + + +class BaseNetworkdTest(unittest.TestCase): + + maxDiff = 2000 + + variables = { + # Inventory hostname, used to index IP list. + "inventory_hostname": "test-host", + # net1: Ethernet on eth0 with IP 1.2.3.4/24. + "net1_interface": "eth0", + "net1_cidr": "1.2.3.0/24", + "net1_ips": {"test-host": "1.2.3.4"}, + # net2: VLAN on eth0.2 with VLAN 2 on interface eth0. + "net2_interface": "eth0.2", + "net2_vlan": 2, + # net3: bridge on br0 with ports eth0 and eth1. + "net3_interface": "br0", + "net3_bridge_ports": ["eth0", "eth1"], + # net4: bond on bond0 with members eth0 and eth1. + "net4_interface": "bond0", + "net4_bond_slaves": ["eth0", "eth1"], + # Prefix for networkd config file names. + "networkd_prefix": "50-kayobe-", + # Veth pair patch link prefix and suffix. + "network_patch_prefix": "p-", + "network_patch_suffix_ovs": "-ovs", + "network_patch_suffix_phy": "-phy", + } + + def setUp(self): + # Bandit complains about Jinja2 autoescaping without nosec. + self.env = jinja2.Environment() # nosec + self.env.filters['bool'] = to_bool + self.context = self._make_context(self.variables) + + def _make_context(self, parent): + return self.env.context_class( + self.env, parent=parent, name='dummy', blocks={}) + + def _update_context(self, variables): + updated_vars = copy.deepcopy(self.variables) + updated_vars.update(variables) + self.context = self._make_context(updated_vars) + + +class TestNetworkdNetDevs(BaseNetworkdTest): + + def test_empty(self): + devs = networkd.networkd_netdevs(self.context, []) + self.assertEqual({}, devs) + + def test_vlan(self): + devs = networkd.networkd_netdevs(self.context, ["net2"]) + expected = { + "50-kayobe-eth0.2": [ + { + "NetDev": [ + {"Name": "eth0.2"}, + {"Kind": "vlan"}, + ] + }, + { + "VLAN": [ + {"Id": 2}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_vlan_all_options(self): + self._update_context({"net2_mtu": 1400}) + devs = networkd.networkd_netdevs(self.context, ["net2"]) + expected = { + "50-kayobe-eth0.2": [ + { + "NetDev": [ + {"Name": "eth0.2"}, + {"Kind": "vlan"}, + {"MTUBytes": 1400}, + ] + }, + { + "VLAN": [ + {"Id": 2}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_vlan_no_interface(self): + self._update_context({"net2_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_netdevs, self.context, ["net2"]) + + def test_bridge(self): + devs = networkd.networkd_netdevs(self.context, ["net3"]) + expected = { + "50-kayobe-br0": [ + { + "NetDev": [ + {"Name": "br0"}, + {"Kind": "bridge"}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_bridge_all_options(self): + self._update_context({"net3_mtu": 1400}) + devs = networkd.networkd_netdevs(self.context, ["net3"]) + expected = { + "50-kayobe-br0": [ + { + "NetDev": [ + {"Name": "br0"}, + {"Kind": "bridge"}, + {"MTUBytes": 1400}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_bridge_no_interface(self): + self._update_context({"net3_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_netdevs, self.context, ["net3"]) + + def test_bond(self): + devs = networkd.networkd_netdevs(self.context, ["net4"]) + expected = { + "50-kayobe-bond0": [ + { + "NetDev": [ + {"Name": "bond0"}, + {"Kind": "bond"}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_bond_all_options(self): + self._update_context({ + "net4_mtu": 1400, + "net4_bond_mode": "802.3ad", + "net4_bond_miimon": 100, + "net4_bond_updelay": 200, + "net4_bond_downdelay": 300, + "net4_bond_xmit_hash_policy": "layer3+4", + "net4_bond_lacp_rate": 60, + }) + devs = networkd.networkd_netdevs(self.context, ["net4"]) + expected = { + "50-kayobe-bond0": [ + { + "NetDev": [ + {"Name": "bond0"}, + {"Kind": "bond"}, + {"MTUBytes": 1400}, + ] + }, + { + "Bond": [ + {"Mode": "802.3ad"}, + {"TransmitHashPolicy": "layer3+4"}, + {"LACPTransmitRate": 60}, + {"MIIMonitorSec": 0.1}, + {"UpDelaySec": 0.2}, + {"DownDelaySec": 0.3}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_bond_no_interface(self): + self._update_context({"net4_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_netdevs, self.context, ["net4"]) + + def test_veth(self): + self._update_context({"external_net_names": ["net3"]}) + devs = networkd.networkd_netdevs(self.context, ["net3"]) + expected = { + "50-kayobe-br0": [ + { + "NetDev": [ + {"Name": "br0"}, + {"Kind": "bridge"}, + ] + }, + ], + "50-kayobe-p-br0-phy": [ + { + "NetDev": [ + {"Name": "p-br0-phy"}, + {"Kind": "veth"}, + ] + }, + { + "Peer": [ + {"Name": "p-br0-ovs"}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_veth_with_mtu(self): + self._update_context({"external_net_names": ["net3"], + "net3_mtu": 1400}) + devs = networkd.networkd_netdevs(self.context, ["net3"]) + expected = { + "50-kayobe-br0": [ + { + "NetDev": [ + {"Name": "br0"}, + {"Kind": "bridge"}, + {"MTUBytes": 1400}, + ] + }, + ], + "50-kayobe-p-br0-phy": [ + { + "NetDev": [ + {"Name": "p-br0-phy"}, + {"Kind": "veth"}, + {"MTUBytes": 1400}, + ] + }, + { + "Peer": [ + {"Name": "p-br0-ovs"}, + ] + }, + ] + } + self.assertEqual(expected, devs) + + def test_veth_no_interface(self): + self._update_context({"external_net_names": ["net3"], + "net3_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_netdevs, self.context, ["net3"]) + + +class TestNetworkdLinks(BaseNetworkdTest): + + def test_empty(self): + links = networkd.networkd_links(self.context, ['net1']) + self.assertEqual({}, links) + + +class TestNetworkdNetworks(BaseNetworkdTest): + + def test_empty(self): + nets = networkd.networkd_networks(self.context, []) + self.assertEqual({}, nets) + + def test_eth(self): + nets = networkd.networkd_networks(self.context, ["net1"]) + expected = { + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Address": "1.2.3.4/24"}, + {"Broadcast": "true"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_eth_all_options(self): + self._update_context({ + "net1_gateway": "1.2.3.1", + "net1_mtu": 1400, + "net1_routes": [ + { + "cidr": "1.2.4.0/24", + }, + { + "cidr": "1.2.5.0/24", + "gateway": "1.2.5.1", + }, + { + "cidr": "1.2.6.0/24", + }, + ], + "net1_bootproto": "dhcp", + "net1_defroute": 'no', + }) + nets = networkd.networkd_networks(self.context, ["net1"]) + expected = { + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Address": "1.2.3.4/24"}, + {"Broadcast": "true"}, + {"Gateway": "1.2.3.1"}, + {"DHCP": "yes"}, + {'UseGateway': "false"}, + ] + }, + { + "Link": [ + {"MTUBytes": 1400}, + ] + }, + { + "Route": [ + {"Destination": "1.2.4.0/24"}, + ] + }, + { + "Route": [ + {"Destination": "1.2.5.0/24"}, + {"Gateway": "1.2.5.1"}, + ] + }, + { + "Route": [ + {"Destination": "1.2.6.0/24"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_eth_no_interface(self): + self._update_context({"net1_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_networks, self.context, ["net1"]) + + def test_vlan(self): + nets = networkd.networkd_networks(self.context, ["net2"]) + expected = { + "50-kayobe-eth0.2": [ + { + "Match": [ + {"Name": "eth0.2"} + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_vlan_with_parent(self): + nets = networkd.networkd_networks(self.context, ["net1", "net2"]) + expected = { + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Address": "1.2.3.4/24"}, + {"Broadcast": "true"}, + {"VLAN": "eth0.2"}, + ] + }, + ], + "50-kayobe-eth0.2": [ + { + "Match": [ + {"Name": "eth0.2"} + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_vlan_no_interface(self): + self._update_context({"net2_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_networks, self.context, ["net2"]) + + def test_bridge(self): + nets = networkd.networkd_networks(self.context, ["net3"]) + expected = { + "50-kayobe-br0": [ + { + "Match": [ + {"Name": "br0"} + ] + }, + ], + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Bridge": "br0"}, + ] + }, + ], + "50-kayobe-eth1": [ + { + "Match": [ + {"Name": "eth1"} + ] + }, + { + "Network": [ + {"Bridge": "br0"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_bridge_with_bridge_port_net(self): + # Test the case where a bridge port interface is a Kayobe network + # (here, eth0 is net1). + self._update_context({ + "net1_mtu": 1400, + "net1_ips": None, + }) + nets = networkd.networkd_networks(self.context, ["net1", "net3"]) + expected = { + "50-kayobe-br0": [ + { + "Match": [ + {"Name": "br0"} + ] + }, + ], + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Bridge": "br0"}, + ] + }, + { + "Link": [ + {"MTUBytes": 1400}, + ] + }, + ], + "50-kayobe-eth1": [ + { + "Match": [ + {"Name": "eth1"} + ] + }, + { + "Network": [ + {"Bridge": "br0"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_bridge_no_interface(self): + self._update_context({"net3_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_networks, self.context, ["net3"]) + + def test_bond(self): + nets = networkd.networkd_networks(self.context, ["net4"]) + expected = { + "50-kayobe-bond0": [ + { + "Match": [ + {"Name": "bond0"} + ] + }, + ], + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Bond": "bond0"}, + ] + }, + ], + "50-kayobe-eth1": [ + { + "Match": [ + {"Name": "eth1"} + ] + }, + { + "Network": [ + {"Bond": "bond0"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_bond_with_bond_member_net(self): + # Test the case where a bond member interface is a Kayobe network + # (here, eth0 is net1). + self._update_context({ + "net1_mtu": 1400, + "net1_ips": None, + }) + nets = networkd.networkd_networks(self.context, ["net1", "net4"]) + expected = { + "50-kayobe-bond0": [ + { + "Match": [ + {"Name": "bond0"} + ] + }, + ], + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Bond": "bond0"}, + ] + }, + { + "Link": [ + {"MTUBytes": 1400}, + ] + }, + ], + "50-kayobe-eth1": [ + { + "Match": [ + {"Name": "eth1"} + ] + }, + { + "Network": [ + {"Bond": "bond0"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_bond_no_interface(self): + self._update_context({"net4_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_networks, self.context, ["net4"]) + + def test_veth(self): + self._update_context({"external_net_names": ["net3"], + "net3_bridge_ports": []}) + nets = networkd.networkd_networks(self.context, ["net3"]) + expected = { + "50-kayobe-br0": [ + { + "Match": [ + {"Name": "br0"} + ] + }, + ], + "50-kayobe-p-br0-phy": [ + { + "Match": [ + {"Name": "p-br0-phy"} + ] + }, + { + "Network": [ + {"Bridge": "br0"}, + ] + }, + ], + "50-kayobe-p-br0-ovs": [ + { + "Match": [ + {"Name": "p-br0-ovs"} + ] + }, + { + "Network": [ + {"ConfigureWithoutCarrier": "true"}, + ] + }, + ], + } + self.assertEqual(expected, nets) + + def test_veth_on_vlan(self): + # Test the case where a VLAN interface is one of the networks that + # needs patching to OVS. The parent interface is a bridge, and the veth + # pair should be plugged into it. + self._update_context({ + "provision_wl_net_name": "net5", + "net3_bridge_ports": [], + "net5_interface": "br0.42", + "net5_vlan": 42}) + nets = networkd.networkd_networks(self.context, ["net3", "net5"]) + expected = { + "50-kayobe-br0": [ + { + "Match": [ + {"Name": "br0"} + ] + }, + { + "Network": [ + {"VLAN": "br0.42"} + ] + } + ], + "50-kayobe-br0.42": [ + { + "Match": [ + {"Name": "br0.42"} + ] + }, + ], + "50-kayobe-p-br0-phy": [ + { + "Match": [ + {"Name": "p-br0-phy"} + ] + }, + { + "Network": [ + {"Bridge": "br0"}, + ] + }, + ], + "50-kayobe-p-br0-ovs": [ + { + "Match": [ + {"Name": "p-br0-ovs"} + ] + }, + { + "Network": [ + {"ConfigureWithoutCarrier": "true"}, + ] + }, + ], + } + self.assertEqual(expected, nets) + + def test_veth_no_interface(self): + self._update_context({"external_net_names": ["net3"], + "net3_interface": None}) + self.assertRaises(errors.AnsibleFilterError, + networkd.networkd_networks, self.context, ["net3"]) + + def test_no_veth_without_bridge(self): + self._update_context({"external_net_names": ["net1"]}) + nets = networkd.networkd_networks(self.context, ["net1"]) + expected = { + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Address": "1.2.3.4/24"}, + {"Broadcast": "true"}, + ] + }, + ] + } + self.assertEqual(expected, nets) + + def test_no_veth_on_vlan_without_bridge(self): + # Test the case where a VLAN interface is one of the networks that + # needs patching to OVS. The parent interface is a bridge, and the veth + # pair should be plugged into it. + self._update_context({"provision_wl_net": "net2"}) + nets = networkd.networkd_networks(self.context, ["net1", "net2"]) + expected = { + "50-kayobe-eth0": [ + { + "Match": [ + {"Name": "eth0"} + ] + }, + { + "Network": [ + {"Address": "1.2.3.4/24"}, + {"Broadcast": "true"}, + {"VLAN": "eth0.2"}, + ] + }, + ], + "50-kayobe-eth0.2": [ + { + "Match": [ + {"Name": "eth0.2"} + ] + }, + ] + } + self.assertEqual(expected, nets) diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 index 6b11a421d..7ffe1ef65 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 @@ -16,11 +16,8 @@ controller_extra_network_interfaces: - test_net_eth_vlan - test_net_bridge - test_net_bridge_vlan -{# Bond configuration does not seem to work with dummy interfaces on Ubuntu #} -{% if ansible_os_family != 'Debian' %} - test_net_bond - test_net_bond_vlan -{% endif %} # dummy2: Ethernet interface. test_net_eth_cidr: 192.168.34.0/24 @@ -44,7 +41,6 @@ test_net_bridge_vlan_cidr: 192.168.37.0/24 test_net_bridge_vlan_interface: "{% raw %}{{ test_net_bridge_interface }}.{{ test_net_bridge_vlan_vlan }}{% endraw %}" test_net_bridge_vlan_vlan: 43 -{% if ansible_os_family != 'Debian' %} # bond0: bond with slaves dummy5, dummy6. test_net_bond_cidr: 192.168.38.0/24 test_net_bond_interface: bond0 @@ -54,7 +50,6 @@ test_net_bond_bond_slaves: [dummy5, dummy6] test_net_bond_vlan_cidr: 192.168.39.0/24 test_net_bond_vlan_interface: "{% raw %}{{ test_net_bond_interface }}.{{ test_net_bond_vlan_vlan }}{% endraw %}" test_net_bond_vlan_vlan: 44 -{% endif %} # Define a software RAID device consisting of two loopback devices. controller_mdadm_arrays: diff --git a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py index aa91430fd..6f44c59e8 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py +++ b/playbooks/kayobe-overcloud-host-configure-base/tests/test_overcloud_host_configure.py @@ -15,13 +15,6 @@ def _is_dnf(): return info[0] == 'CentOS Linux' and info[1].startswith('8') -def _supports_bonds(): - # Bond configuration does not currently work on Ubuntu when using dummy - # devices as slaves. - info = distro.linux_distribution() - return info[0] != 'Ubuntu' - - def test_network_ethernet(host): interface = host.interface('dummy2') assert interface.exists @@ -59,21 +52,21 @@ def test_network_bridge_vlan(host): assert host.file('/sys/class/net/br0.43/lower_br0').exists -@pytest.mark.skipif(not _supports_bonds(), reason="Bonding no worky on Ubuntu") def test_network_bond(host): interface = host.interface('bond0') assert interface.exists assert '192.168.38.1' in interface.addresses sys_slaves = host.check_output('cat /sys/class/net/bond0/bonding/slaves') - slaves = ['dummy5', 'dummy6'] - assert sys_slaves == " ".join(slaves) + # Ordering is not guaranteed, so compare sets. + sys_slaves = set(sys_slaves.split()) + slaves = set(['dummy5', 'dummy6']) + assert sys_slaves == slaves for slave in slaves: interface = host.interface(slave) assert interface.exists assert not interface.addresses -@pytest.mark.skipif(not _supports_bonds(), reason="Bonding no worky on Ubuntu") def test_network_bond_vlan(host): interface = host.interface('bond0.44') assert interface.exists diff --git a/requirements.yml b/requirements.yml index 906f7b0c8..9cec4f4e8 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,6 +1,8 @@ --- - src: ahuffman.resolv version: 1.3.1 +- src: stackhpc.systemd_networkd + version: v1.0.1 - src: jriguera.configdrive # There are no versioned releases of this role. version: 8438592c84585c86e62ae07e526d3da53629b377 diff --git a/roles/kayobe-network-bootstrap/tasks/Debian.yml b/roles/kayobe-network-bootstrap/tasks/Debian.yml deleted file mode 100644 index 4a5d2476d..000000000 --- a/roles/kayobe-network-bootstrap/tasks/Debian.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -- name: Ensure interfaces.d directory exists - file: - path: /etc/network/interfaces.d - state: directory - become: true - -- name: Ensure interfaces.d directory is sourced - lineinfile: - path: /etc/network/interfaces - line: source /etc/network/interfaces.d/* - become: true - -- name: Ensure all-in-one network dummy interface exists - become: true - copy: - content: | - auto {{ bridge_port_interface }} - iface {{ bridge_port_interface }} inet manual - dest: /etc/network/interfaces.d/ifcfg-{{ bridge_port_interface }} - -- name: Ensure all-in-one network bridge interface exists - become: true - copy: - content: | - auto {{ bridge_interface }} - iface {{ bridge_interface }} inet static - address {{ bridge_ip }} - netmask {{ (bridge_ip ~ '/' ~ bridge_prefix) | ipaddr('netmask') }} - bridge_ports {{ bridge_port_interface }} - dest: /etc/network/interfaces.d/ifcfg-{{ bridge_interface }} - -- name: Ensure all-in-one network bridge interfaces are up - become: true - command: "{{ item }}" - with_items: - - "ifup {{ bridge_interface }}" - - "ifup {{ bridge_port_interface }}" diff --git a/roles/kayobe-network-bootstrap/tasks/RedHat.yml b/roles/kayobe-network-bootstrap/tasks/RedHat.yml deleted file mode 100644 index 125a62f97..000000000 --- a/roles/kayobe-network-bootstrap/tasks/RedHat.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -- name: Ensure all-in-one network bridge interface exists (RedHat) - command: "{{ item }}" - become: true - with_items: - - "ip l set {{ bridge_interface }} up" - - "ip a add {{ bridge_ip }}/{{ bridge_prefix }} dev {{ bridge_interface }}" - # NOTE(mgoddard): CentOS 8 removes interfaces from their bridge during - # ifdown, and removes the bridge if there are no interfaces left. When - # Kayobe bounces veth links plugged into the bridge, it causes the - # bridge which has the IP we are using for SSH to be removed. Use a - # dummy interface. - - "ip l set {{ bridge_port_interface }} up" - - "ip l set {{ bridge_port_interface }} master {{ bridge_interface }}" diff --git a/roles/kayobe-network-bootstrap/tasks/main.yml b/roles/kayobe-network-bootstrap/tasks/main.yml index e7ab9c59b..e50433f4e 100644 --- a/roles/kayobe-network-bootstrap/tasks/main.yml +++ b/roles/kayobe-network-bootstrap/tasks/main.yml @@ -6,4 +6,19 @@ - "ip l add {{ bridge_interface }} type bridge" - "ip l add {{ bridge_port_interface }} type dummy" -- include_tasks: "{{ ansible_os_family }}.yml" +- name: Ensure all-in-one network bridge interface exists + vars: + bridge_cidr: "{{ bridge_ip }}/{{ bridge_prefix }}" + bridge_broadcast: "{{ bridge_cidr | ipaddr('broadcast') }}" + command: "{{ item }}" + become: true + with_items: + - "ip l set {{ bridge_interface }} up" + - "ip a add {{ bridge_cidr }} brd {{ bridge_broadcast }} dev {{ bridge_interface }}" + # NOTE(mgoddard): CentOS 8 removes interfaces from their bridge during + # ifdown, and removes the bridge if there are no interfaces left. When + # Kayobe bounces veth links plugged into the bridge, it causes the + # bridge which has the IP we are using for SSH to be removed. Use a + # dummy interface. + - "ip l set {{ bridge_port_interface }} up" + - "ip l set {{ bridge_port_interface }} master {{ bridge_interface }}"