diff --git a/doc/source/configuration/reference/network.rst b/doc/source/configuration/reference/network.rst index 01be73b35..1cab3b104 100644 --- a/doc/source/configuration/reference/network.rst +++ b/doc/source/configuration/reference/network.rst @@ -343,6 +343,9 @@ The following attributes are supported: ``interface`` The name of the network interface attached to the network. +``parent`` + The name of the parent interface, when configuring a VLAN interface using + ``systemd-networkd`` syntax. ``bootproto`` Boot protocol for the interface. Valid values are ``static`` and ``dhcp``. The default is ``static``. When set to ``dhcp``, an external DHCP server @@ -473,8 +476,9 @@ Configuring VLAN Interfaces --------------------------- A VLAN interface may be configured by setting the ``interface`` attribute of a -network to the name of the VLAN interface. The interface name must be of the -form ``.``. +network to the name of the VLAN interface. The interface name must normally be +of the form ``.`` to ensure compatibility with all +supported host operating systems. To configure a network called ``example`` with a VLAN interface with a parent interface of ``eth2`` for VLAN ``123``: @@ -491,6 +495,16 @@ To keep the configuration DRY, reference the network's ``vlan`` attribute: example_interface: "eth2.{{ example_vlan }}" +Alternatively, when using Ubuntu as a host operating system, VLAN interfaces +can be named arbitrarily using syntax supported by ``systemd-networkd``. In +this case, a ``parent`` attribute must specify the underlying interface: + +.. code-block:: yaml + :caption: ``inventory/group_vars//network-interfaces`` + + example_interface: "myvlan{{ example_vlan }}" + example_parent: "eth2" + Ethernet interfaces, bridges, and bond master interfaces may all be parents to a VLAN interface. diff --git a/kayobe/plugins/action/kolla_ansible_host_vars.py b/kayobe/plugins/action/kolla_ansible_host_vars.py index 4696063d8..d03aac6aa 100644 --- a/kayobe/plugins/action/kolla_ansible_host_vars.py +++ b/kayobe/plugins/action/kolla_ansible_host_vars.py @@ -123,7 +123,11 @@ class ActionModule(ActionBase): # tagged interface may be shared between these networks. vlan = self._templar.template("{{ '%s' | net_vlan }}" % net_name) - if vlan and iface.endswith(".%s" % vlan): + parent = self._templar.template("{{ '%s' | net_parent }}" % + net_name) + if vlan and parent: + iface = parent + elif vlan and iface.endswith(".%s" % vlan): iface = iface.replace(".%s" % vlan, "") return iface elif required: diff --git a/kayobe/plugins/filter/networkd.py b/kayobe/plugins/filter/networkd.py index f07c2eb4b..2d2313581 100644 --- a/kayobe/plugins/filter/networkd.py +++ b/kayobe/plugins/filter/networkd.py @@ -612,7 +612,8 @@ def networkd_networks(context, names, inventory_hostname=None): inventory_hostname) vlan = networks.net_vlan(context, name, inventory_hostname) mtu = networks.net_mtu(context, name, inventory_hostname) - parent = networks.get_vlan_parent(device, vlan) + parent = networks.get_vlan_parent( + context, name, device, vlan, inventory_hostname) vlan_interfaces = interface_to_vlans.setdefault(parent, []) vlan_interfaces.append({"device": device, "mtu": mtu}) diff --git a/kayobe/plugins/filter/networks.py b/kayobe/plugins/filter/networks.py index 29c6018e4..ed9d23b96 100644 --- a/kayobe/plugins/filter/networks.py +++ b/kayobe/plugins/filter/networks.py @@ -106,7 +106,8 @@ def get_ovs_veths(context, names, inventory_hostname): # tagged interface may be shared between these networks. vlan = net_vlan(context, name, inventory_hostname) if vlan: - parent_or_device = get_vlan_parent(device, vlan) + parent_or_device = get_vlan_parent( + context, name, device, vlan, inventory_hostname) else: parent_or_device = device if parent_or_device in bridge_interfaces: @@ -131,14 +132,21 @@ def get_ovs_veths(context, names, inventory_hostname): ] -def get_vlan_parent(device, vlan): +def get_vlan_parent(context, name, device, vlan, inventory_hostname): """Return the parent interface of a VLAN subinterface. + :param context: a Jinja2 Context object. + :param name: name of the network. :param device: VLAN interface name. :param vlan: VLAN ID. + :param inventory_hostname: Ansible inventory hostname. :returns: parent interface name. + :raises: ansible.errors.AnsibleFilterError """ - return re.sub(r'\.{}$'.format(vlan), '', device) + parent = net_parent(context, name, inventory_hostname) + if not parent: + parent = re.sub(r'\.{}$'.format(vlan), '', device) + return parent @jinja2.pass_context @@ -190,6 +198,11 @@ def net_mask(context, name, inventory_hostname=None): return str(netaddr.IPNetwork(cidr).netmask) if cidr is not None else None +@jinja2.pass_context +def net_parent(context, name, inventory_hostname=None): + return net_attr(context, name, 'parent', inventory_hostname) + + @jinja2.pass_context def net_prefix(context, name, inventory_hostname=None): cidr = net_cidr(context, name, inventory_hostname) @@ -545,10 +558,15 @@ def net_is_vlan(context, name, inventory_hostname=None): @jinja2.pass_context def net_is_vlan_interface(context, name, inventory_hostname=None): - device = get_and_validate_interface(context, name, inventory_hostname) - # Use a heuristic to match conventional VLAN names, ending with a - # period and a numerical extension to an interface name - return re.match(r"^[a-zA-Z0-9_\-]+\.[1-9][\d]{0,3}$", device) + parent = net_parent(context, name, inventory_hostname) + vlan = net_vlan(context, name, inventory_hostname) + if parent and vlan: + return True + else: + device = get_and_validate_interface(context, name, inventory_hostname) + # Use a heuristic to match conventional VLAN names, ending with a + # period and a numerical extension to an interface name + return re.match(r"^[a-zA-Z0-9_\-]+\.[1-9][\d]{0,3}$", device) @jinja2.pass_context @@ -600,7 +618,10 @@ def net_configdrive_network_device(context, name, inventory_hostname=None): bootproto = net_bootproto(context, name, inventory_hostname) mtu = net_mtu(context, name, inventory_hostname) vlan = net_vlan(context, name, inventory_hostname) - if vlan and '.' in device: + parent = net_parent(context, name, inventory_hostname) + if vlan and parent: + backend = parent + elif vlan and '.' in device: backend = [device.split('.')[0]] else: backend = None @@ -678,6 +699,7 @@ def get_filters(): 'net_fqdn': _make_attr_filter('fqdn'), 'net_ip': net_ip, 'net_interface': net_interface, + 'net_parent': net_parent, 'net_no_ip': net_no_ip, 'net_cidr': net_cidr, 'net_mask': net_mask, diff --git a/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py b/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py index 7be386d5a..ec0a60d0f 100644 --- a/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py +++ b/kayobe/tests/unit/plugins/action/test_kolla_ansible_host_vars.py @@ -25,6 +25,11 @@ def _net_interface(context, name): return context.get(name + '_interface') +@jinja2.pass_context +def _net_parent(context, name): + return context.get(name + '_parent') + + @jinja2.pass_context def _net_vlan(context, name): return context.get(name + '_vlan') @@ -42,6 +47,7 @@ class FakeTemplar(object): self.variables = variables self.env = jinja2.Environment() self.env.filters['net_interface'] = _net_interface + self.env.filters['net_parent'] = _net_parent self.env.filters['net_vlan'] = _net_vlan self.env.filters['net_select_bridges'] = _net_select_bridges diff --git a/kayobe/tests/unit/plugins/filter/test_networkd.py b/kayobe/tests/unit/plugins/filter/test_networkd.py index ae1936056..45e2e81a2 100644 --- a/kayobe/tests/unit/plugins/filter/test_networkd.py +++ b/kayobe/tests/unit/plugins/filter/test_networkd.py @@ -42,6 +42,15 @@ class BaseNetworkdTest(unittest.TestCase): # net4: bond on bond0 with members eth0 and eth1. "net4_interface": "bond0", "net4_bond_slaves": ["eth0", "eth1"], + # net5: VLAN on vlan.5 with VLAN 5 on interface eth0. + "net5_interface": "vlan.5", + "net5_parent": "eth0", + "net5_vlan": 5, + # net6: VLAN on vlan6 with VLAN 6 on interface eth0. + "net6_interface": "vlan6", + "net6_parent": "eth0", + "net6_vlan": 6, + # NOTE(priteau): net7 is used in test_veth_on_vlan # Prefix for networkd config file names. "networkd_prefix": "50-kayobe-", # Veth pair patch link prefix and suffix. @@ -132,6 +141,52 @@ class TestNetworkdNetDevs(BaseNetworkdTest): self.assertRaises(errors.AnsibleFilterError, networkd.networkd_netdevs, self.context, ["net2"]) + def test_vlan_with_parent(self): + devs = networkd.networkd_netdevs(self.context, + ["net1", "net2", "net5", "net6"]) + expected = { + "50-kayobe-eth0.2": [ + { + "NetDev": [ + {"Name": "eth0.2"}, + {"Kind": "vlan"}, + ] + }, + { + "VLAN": [ + {"Id": 2}, + ] + }, + ], + "50-kayobe-vlan.5": [ + { + "NetDev": [ + {"Name": "vlan.5"}, + {"Kind": "vlan"}, + ] + }, + { + "VLAN": [ + {"Id": 5}, + ] + }, + ], + "50-kayobe-vlan6": [ + { + "NetDev": [ + {"Name": "vlan6"}, + {"Kind": "vlan"}, + ] + }, + { + "VLAN": [ + {"Id": 6}, + ] + }, + ] + } + self.assertEqual(expected, devs) + def test_bridge(self): devs = networkd.networkd_netdevs(self.context, ["net3"]) expected = { @@ -437,7 +492,8 @@ class TestNetworkdNetworks(BaseNetworkdTest): self.assertEqual(expected, nets) def test_vlan_with_parent(self): - nets = networkd.networkd_networks(self.context, ["net1", "net2"]) + nets = networkd.networkd_networks(self.context, + ["net1", "net2", "net5", "net6"]) expected = { "50-kayobe-eth0": [ { @@ -449,6 +505,8 @@ class TestNetworkdNetworks(BaseNetworkdTest): "Network": [ {"Address": "1.2.3.4/24"}, {"VLAN": "eth0.2"}, + {"VLAN": "vlan.5"}, + {"VLAN": "vlan6"}, ] }, ], @@ -458,6 +516,20 @@ class TestNetworkdNetworks(BaseNetworkdTest): {"Name": "eth0.2"} ] }, + ], + "50-kayobe-vlan.5": [ + { + "Match": [ + {"Name": "vlan.5"} + ] + }, + ], + "50-kayobe-vlan6": [ + { + "Match": [ + {"Name": "vlan6"} + ] + }, ] } self.assertEqual(expected, nets) @@ -946,11 +1018,11 @@ class TestNetworkdNetworks(BaseNetworkdTest): # 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", + "provision_wl_net_name": "net7", "net3_bridge_ports": [], - "net5_interface": "br0.42", - "net5_vlan": 42}) - nets = networkd.networkd_networks(self.context, ["net3", "net5"]) + "net7_interface": "br0.42", + "net7_vlan": 42}) + nets = networkd.networkd_networks(self.context, ["net3", "net7"]) expected = { "50-kayobe-br0": [ { diff --git a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 index dbc9ce064..a10f0e31b 100644 --- a/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 +++ b/playbooks/kayobe-overcloud-host-configure-base/overrides.yml.j2 @@ -19,6 +19,9 @@ controller_extra_network_interfaces: - test_net_bond - test_net_bond_vlan - test_net_bridge_noip +{% if ansible_os_family == "Debian" %} + - test_net_systemd_vlan +{% endif %} # Custom IP routing tables. network_route_tables: @@ -79,6 +82,15 @@ test_net_bridge_noip_interface: br1 test_net_bridge_noip_bridge_ports: [dummy7] test_net_bridge_noip_no_ip: true +{% if ansible_os_family == "Debian" %} +# vlan45: VLAN interface of bond0 using systemd-networkd style +test_net_systemd_vlan_cidr: 192.168.41.0/24 +test_net_systemd_vlan_interface: "vlan{% raw %}{{ test_net_systemd_vlan_vlan }}{% endraw %}" +test_net_systemd_vlan_parent: "{% raw %}{{ test_net_bond_interface }}{% endraw %}" +test_net_systemd_vlan_vlan: 45 +test_net_systemd_vlan_zone: public +{% endif %} + # Define a software RAID device consisting of two loopback devices. controller_mdadm_arrays: - name: md0 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 d2ff5c5aa..1ac794e1c 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 @@ -98,6 +98,14 @@ def test_network_bridge_no_ip(host): assert not '192.168.40.1' in interface.addresses +@pytest.mark.skipif(not _is_apt(), + reason="systemd-networkd VLANs only supported on Ubuntu") +def test_network_systemd_vlan(host): + interface = host.interface('vlan45') + assert interface.exists + assert '192.168.41.1' in interface.addresses + + def test_additional_user_account(host): user = host.user("kayobe-test-user") assert user.name == "kayobe-test-user" diff --git a/releasenotes/notes/systemd-networkd-vlans-5022f0d1b8214329.yaml b/releasenotes/notes/systemd-networkd-vlans-5022f0d1b8214329.yaml new file mode 100644 index 000000000..7b53b5514 --- /dev/null +++ b/releasenotes/notes/systemd-networkd-vlans-5022f0d1b8214329.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds support for configuring arbitrarily named VLAN interfaces using + ``systemd-networkd``. See `story 2010266 + `__ for details.