Support configuring VLANs with systemd-networkd syntax

This allows operators to configure arbitrarily named VLAN interfaces
using systemd-networkd.

Story: 2010266
Task: 46178

Change-Id: I666d7011bde0050ebc509b427c1d4f5a66b6231a
This commit is contained in:
Pierre Riteau 2022-09-09 17:57:11 +02:00
parent 4a3f88694e
commit 6d7b8812ae
9 changed files with 162 additions and 17 deletions

View File

@ -343,6 +343,9 @@ The following attributes are supported:
``interface`` ``interface``
The name of the network interface attached to the network. 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`` ``bootproto``
Boot protocol for the interface. Valid values are ``static`` and ``dhcp``. Boot protocol for the interface. Valid values are ``static`` and ``dhcp``.
The default is ``static``. When set to ``dhcp``, an external DHCP server 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 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 network to the name of the VLAN interface. The interface name must normally be
form ``<parent interface>.<VLAN ID>``. of the form ``<parent interface>.<VLAN ID>`` to ensure compatibility with all
supported host operating systems.
To configure a network called ``example`` with a VLAN interface with a parent To configure a network called ``example`` with a VLAN interface with a parent
interface of ``eth2`` for VLAN ``123``: 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 }}" 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/<group>/network-interfaces``
example_interface: "myvlan{{ example_vlan }}"
example_parent: "eth2"
Ethernet interfaces, bridges, and bond master interfaces may all be parents to Ethernet interfaces, bridges, and bond master interfaces may all be parents to
a VLAN interface. a VLAN interface.

View File

@ -123,7 +123,11 @@ class ActionModule(ActionBase):
# tagged interface may be shared between these networks. # tagged interface may be shared between these networks.
vlan = self._templar.template("{{ '%s' | net_vlan }}" % vlan = self._templar.template("{{ '%s' | net_vlan }}" %
net_name) 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, "") iface = iface.replace(".%s" % vlan, "")
return iface return iface
elif required: elif required:

View File

@ -612,7 +612,8 @@ def networkd_networks(context, names, inventory_hostname=None):
inventory_hostname) inventory_hostname)
vlan = networks.net_vlan(context, name, inventory_hostname) vlan = networks.net_vlan(context, name, inventory_hostname)
mtu = networks.net_mtu(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 = interface_to_vlans.setdefault(parent, [])
vlan_interfaces.append({"device": device, "mtu": mtu}) vlan_interfaces.append({"device": device, "mtu": mtu})

View File

@ -106,7 +106,8 @@ def get_ovs_veths(context, names, inventory_hostname):
# tagged interface may be shared between these networks. # tagged interface may be shared between these networks.
vlan = net_vlan(context, name, inventory_hostname) vlan = net_vlan(context, name, inventory_hostname)
if vlan: if vlan:
parent_or_device = get_vlan_parent(device, vlan) parent_or_device = get_vlan_parent(
context, name, device, vlan, inventory_hostname)
else: else:
parent_or_device = device parent_or_device = device
if parent_or_device in bridge_interfaces: 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. """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 device: VLAN interface name.
:param vlan: VLAN ID. :param vlan: VLAN ID.
:param inventory_hostname: Ansible inventory hostname.
:returns: parent interface name. :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 @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 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 @jinja2.pass_context
def net_prefix(context, name, inventory_hostname=None): def net_prefix(context, name, inventory_hostname=None):
cidr = net_cidr(context, name, inventory_hostname) cidr = net_cidr(context, name, inventory_hostname)
@ -545,6 +558,11 @@ def net_is_vlan(context, name, inventory_hostname=None):
@jinja2.pass_context @jinja2.pass_context
def net_is_vlan_interface(context, name, inventory_hostname=None): def net_is_vlan_interface(context, name, inventory_hostname=None):
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) device = get_and_validate_interface(context, name, inventory_hostname)
# Use a heuristic to match conventional VLAN names, ending with a # Use a heuristic to match conventional VLAN names, ending with a
# period and a numerical extension to an interface name # period and a numerical extension to an interface name
@ -600,7 +618,10 @@ def net_configdrive_network_device(context, name, inventory_hostname=None):
bootproto = net_bootproto(context, name, inventory_hostname) bootproto = net_bootproto(context, name, inventory_hostname)
mtu = net_mtu(context, name, inventory_hostname) mtu = net_mtu(context, name, inventory_hostname)
vlan = net_vlan(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]] backend = [device.split('.')[0]]
else: else:
backend = None backend = None
@ -678,6 +699,7 @@ def get_filters():
'net_fqdn': _make_attr_filter('fqdn'), 'net_fqdn': _make_attr_filter('fqdn'),
'net_ip': net_ip, 'net_ip': net_ip,
'net_interface': net_interface, 'net_interface': net_interface,
'net_parent': net_parent,
'net_no_ip': net_no_ip, 'net_no_ip': net_no_ip,
'net_cidr': net_cidr, 'net_cidr': net_cidr,
'net_mask': net_mask, 'net_mask': net_mask,

View File

@ -25,6 +25,11 @@ def _net_interface(context, name):
return context.get(name + '_interface') return context.get(name + '_interface')
@jinja2.pass_context
def _net_parent(context, name):
return context.get(name + '_parent')
@jinja2.pass_context @jinja2.pass_context
def _net_vlan(context, name): def _net_vlan(context, name):
return context.get(name + '_vlan') return context.get(name + '_vlan')
@ -42,6 +47,7 @@ class FakeTemplar(object):
self.variables = variables self.variables = variables
self.env = jinja2.Environment() self.env = jinja2.Environment()
self.env.filters['net_interface'] = _net_interface 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_vlan'] = _net_vlan
self.env.filters['net_select_bridges'] = _net_select_bridges self.env.filters['net_select_bridges'] = _net_select_bridges

View File

@ -42,6 +42,15 @@ class BaseNetworkdTest(unittest.TestCase):
# net4: bond on bond0 with members eth0 and eth1. # net4: bond on bond0 with members eth0 and eth1.
"net4_interface": "bond0", "net4_interface": "bond0",
"net4_bond_slaves": ["eth0", "eth1"], "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. # Prefix for networkd config file names.
"networkd_prefix": "50-kayobe-", "networkd_prefix": "50-kayobe-",
# Veth pair patch link prefix and suffix. # Veth pair patch link prefix and suffix.
@ -132,6 +141,52 @@ class TestNetworkdNetDevs(BaseNetworkdTest):
self.assertRaises(errors.AnsibleFilterError, self.assertRaises(errors.AnsibleFilterError,
networkd.networkd_netdevs, self.context, ["net2"]) 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): def test_bridge(self):
devs = networkd.networkd_netdevs(self.context, ["net3"]) devs = networkd.networkd_netdevs(self.context, ["net3"])
expected = { expected = {
@ -437,7 +492,8 @@ class TestNetworkdNetworks(BaseNetworkdTest):
self.assertEqual(expected, nets) self.assertEqual(expected, nets)
def test_vlan_with_parent(self): 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 = { expected = {
"50-kayobe-eth0": [ "50-kayobe-eth0": [
{ {
@ -449,6 +505,8 @@ class TestNetworkdNetworks(BaseNetworkdTest):
"Network": [ "Network": [
{"Address": "1.2.3.4/24"}, {"Address": "1.2.3.4/24"},
{"VLAN": "eth0.2"}, {"VLAN": "eth0.2"},
{"VLAN": "vlan.5"},
{"VLAN": "vlan6"},
] ]
}, },
], ],
@ -458,6 +516,20 @@ class TestNetworkdNetworks(BaseNetworkdTest):
{"Name": "eth0.2"} {"Name": "eth0.2"}
] ]
}, },
],
"50-kayobe-vlan.5": [
{
"Match": [
{"Name": "vlan.5"}
]
},
],
"50-kayobe-vlan6": [
{
"Match": [
{"Name": "vlan6"}
]
},
] ]
} }
self.assertEqual(expected, nets) self.assertEqual(expected, nets)
@ -946,11 +1018,11 @@ class TestNetworkdNetworks(BaseNetworkdTest):
# needs patching to OVS. The parent interface is a bridge, and the veth # needs patching to OVS. The parent interface is a bridge, and the veth
# pair should be plugged into it. # pair should be plugged into it.
self._update_context({ self._update_context({
"provision_wl_net_name": "net5", "provision_wl_net_name": "net7",
"net3_bridge_ports": [], "net3_bridge_ports": [],
"net5_interface": "br0.42", "net7_interface": "br0.42",
"net5_vlan": 42}) "net7_vlan": 42})
nets = networkd.networkd_networks(self.context, ["net3", "net5"]) nets = networkd.networkd_networks(self.context, ["net3", "net7"])
expected = { expected = {
"50-kayobe-br0": [ "50-kayobe-br0": [
{ {

View File

@ -19,6 +19,9 @@ controller_extra_network_interfaces:
- test_net_bond - test_net_bond
- test_net_bond_vlan - test_net_bond_vlan
- test_net_bridge_noip - test_net_bridge_noip
{% if ansible_os_family == "Debian" %}
- test_net_systemd_vlan
{% endif %}
# Custom IP routing tables. # Custom IP routing tables.
network_route_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_bridge_ports: [dummy7]
test_net_bridge_noip_no_ip: true 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. # Define a software RAID device consisting of two loopback devices.
controller_mdadm_arrays: controller_mdadm_arrays:
- name: md0 - name: md0

View File

@ -98,6 +98,14 @@ def test_network_bridge_no_ip(host):
assert not '192.168.40.1' in interface.addresses 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): def test_additional_user_account(host):
user = host.user("kayobe-test-user") user = host.user("kayobe-test-user")
assert user.name == "kayobe-test-user" assert user.name == "kayobe-test-user"

View File

@ -0,0 +1,6 @@
---
features:
- |
Adds support for configuring arbitrarily named VLAN interfaces using
``systemd-networkd``. See `story 2010266
<https://storyboard.openstack.org/#!/story/2010266>`__ for details.