Skip to content

Commit e445b87

Browse files
authored
Fix rename of matched interfaces at runtime (LP: #1904662) (#174)
Renaming of network interfaces was not working when running netplan apply and interfaces would only be renamed after a reboot via udev. There is some udev-rename logic inside cli/commands/apply.py, which doesn't seem to be working, though. This seems to be related to systemd-udevd's NamePolicy=keep default, which is set explicitly as of systemd v240 [0][1] and prevents renaming of interfaces via udev, if the name was set once (i.e. during boot). Even before v240 this has be the (implicit) default, so I'm not sure if this rename logic has ever worked... I reworked/refactored the renaming of interfaces in apply.py to make use of iproute2 ip link set eth_OLD set name eth_NEW command instead and also added an integration test. In order to rename interfaces via iproute2, they need to be DOWN, therefore the new code is bringing all to-be-renamed interfaces - which are not defined to be critical - down during apply and renames them accordingly. Also, there was another issue with the detection of physical interfaces, where the system's current network interface names (from netifaces.interfaces()) have been compared to config_manager.py's physical_interfaces list, which are not (always) actual interface names (but netplan IDs), especially for netplan definitions with a match condition. So I added another util to lookup/match the actual interface name for a given match: condition. [0] https://www.freedesktop.org/software/systemd/man/systemd.net-naming-scheme.html [1] https://github.com/systemd/systemd/blob/e62a7fea757f259eb330da5b6d3ab4ede46400a2/NEWS#L25 Commits: * WIP: rename down interfaces on apply * cli: improve rename of interfaces at runtime, during 'netplan apply' * cli: fix linter * WIP: refactor interface rename code udevadm test-builtin net_setup_link seems to be only running once (at boot time) and _not_ rename interfaces during 'netplan apply' * cli: keep udev link changes like WakeOnLan= * cli:utils: some cleanup * cli: big cleanup + unit-tests * tests: set-name on apply integration test * cli: update comment about NamePolicy=keep rename
1 parent fad12c1 commit e445b87

File tree

5 files changed

+120
-58
lines changed

5 files changed

+120
-58
lines changed

doc/netplan.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,9 @@ Virtual devices
275275

276276
``critical`` (bool)
277277

278-
: (networkd backend only) Designate the connection as "critical to the
279-
system", meaning that special care will be taken by systemd-networkd to
280-
not release the assigned IP when the daemon is restarted.
278+
: Designate the connection as "critical to the system", meaning that special
279+
care will be taken by to not release the assigned IP when the daemon is
280+
restarted. (not recognized by NetworkManager)
281281

282282
``dhcp-identifier`` (scalar)
283283

netplan/cli/commands/apply.py

Lines changed: 40 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ def command_apply(self, run_generate=True, sync=False, exit_on_error=True): # p
187187
changes = NetplanApply.process_link_changes(devices, config_manager)
188188

189189
# if the interface is up, we can still apply some .link file changes
190+
# but we cannot apply the interface rename via udev, as it won't touch
191+
# the interface name, if it was already renamed once (e.g. during boot),
192+
# because of the NamePolicy=keep default:
193+
# https://www.freedesktop.org/software/systemd/man/systemd.net-naming-scheme.html
190194
devices = netifaces.interfaces()
191195
for device in devices:
192196
logging.debug('netplan triggering .link rules for %s', device)
@@ -199,9 +203,15 @@ def command_apply(self, run_generate=True, sync=False, exit_on_error=True): # p
199203
except subprocess.CalledProcessError:
200204
logging.debug('Ignoring device without syspath: %s', device)
201205

202-
# apply renames to "down" devices
206+
# apply some more changes manually
203207
for iface, settings in changes.items():
208+
# rename non-critical network interfaces
204209
if settings.get('name'):
210+
# bring down the interface, using its current (matched) interface name
211+
subprocess.check_call(['ip', 'link', 'set', 'dev', iface, 'down'],
212+
stdout=subprocess.DEVNULL,
213+
stderr=subprocess.DEVNULL)
214+
# rename the interface to the name given via 'set-name'
205215
subprocess.check_call(['ip', 'link', 'set',
206216
'dev', iface,
207217
'name', settings.get('name')],
@@ -234,7 +244,7 @@ def is_composite_member(composites, phy): # pragma: nocover (covered in autopkg
234244
interface? (bond, bridge)
235245
"""
236246
for composite in composites:
237-
for id, settings in composite.items():
247+
for _, settings in composite.items():
238248
members = settings.get('interfaces', [])
239249
for iface in members:
240250
if iface == phy:
@@ -245,68 +255,49 @@ def is_composite_member(composites, phy): # pragma: nocover (covered in autopkg
245255
@staticmethod
246256
def process_link_changes(interfaces, config_manager): # pragma: nocover (covered in autopkgtest)
247257
"""
248-
Go through the pending changes and pick what needs special
249-
handling. Only applies to "down" interfaces which can be safely
250-
updated.
258+
Go through the pending changes and pick what needs special handling.
259+
Only applies to non-critical interfaces which can be safely updated.
251260
"""
252261

253262
changes = {}
254263
phys = dict(config_manager.physical_interfaces)
255264
composite_interfaces = [config_manager.bridges, config_manager.bonds]
256265

257-
# TODO (cyphermox): factor out some of this matching code (and make it
258-
# pretty) in its own module.
259-
matches = {'by-driver': {},
260-
'by-mac': {},
261-
}
266+
# Find physical interfaces which need a rename
267+
# But do not rename virtual interfaces
262268
for phy, settings in phys.items():
263-
if not settings:
264-
continue
265-
if phy == 'renderer':
266-
continue
269+
if not settings or not isinstance(settings, dict):
270+
continue # Skip special values, like "renderer: ..."
267271
newname = settings.get('set-name')
268272
if not newname:
269-
continue
273+
continue # Skip if no new name needs to be set
270274
match = settings.get('match')
271275
if not match:
272-
continue
273-
driver = match.get('driver')
274-
mac = match.get('macaddress')
275-
if driver:
276-
matches['by-driver'][driver] = newname
277-
if mac:
278-
matches['by-mac'][mac] = newname
279-
280-
# /sys/class/net/ens3/device -> ../../../virtio0
281-
# /sys/class/net/ens3/device/driver -> ../../../../bus/virtio/drivers/virtio_net
282-
for interface in interfaces:
283-
if interface not in phys:
284-
# do not rename virtual devices
285-
logging.debug('Skipping non-physical interface: %s', interface)
286-
continue
287-
if NetplanApply.is_composite_member(composite_interfaces, interface):
288-
logging.debug('Skipping composite member %s', interface)
276+
continue # Skip if no match for current name is given
277+
if NetplanApply.is_composite_member(composite_interfaces, phy):
278+
logging.debug('Skipping composite member {}'.format(phy))
289279
# do not rename members of virtual devices. MAC addresses
290280
# may be the same for all interface members.
291281
continue
292-
293-
driver_name = utils.get_interface_driver_name(interface, only_down=True)
294-
if not driver_name:
295-
# don't allow up interfaces to match by mac
282+
# Find current name of the interface, according to match conditions and globs (name, mac, driver)
283+
current_iface_name = utils.find_matching_iface(interfaces, match)
284+
if not current_iface_name:
285+
logging.warning('Cannot find unique matching interface for {}: {}'.format(phy, match))
286+
continue
287+
if current_iface_name == newname:
288+
# Skip interface if it already has the correct name
289+
logging.debug('Skipping correctly named interface: {}'.format(newname))
296290
continue
297-
macaddress = utils.get_interface_macaddress(interface)
298-
if driver_name in matches['by-driver']:
299-
new_name = matches['by-driver'][driver_name]
300-
logging.debug(new_name)
301-
logging.debug(interface)
302-
if new_name != interface:
303-
changes.update({interface: {'name': new_name}})
304-
if macaddress in matches['by-mac']:
305-
new_name = matches['by-mac'][macaddress]
306-
if new_name != interface:
307-
changes.update({interface: {'name': new_name}})
308-
309-
logging.debug(changes)
291+
if settings.get('critical', False):
292+
# Skip interfaces defined as critical, as we should not take them down in order to rename
293+
logging.warning('Cannot rename {} ({} -> {}) at runtime (needs reboot), due to being critical'
294+
.format(phy, current_iface_name, newname))
295+
continue
296+
297+
# record the interface rename change
298+
changes[current_iface_name] = {'name': newname}
299+
300+
logging.debug('Link changes: {}'.format(changes))
310301
return changes
311302

312303
@staticmethod

netplan/cli/utils.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Copyright (C) 2018-2020 Canonical, Ltd.
44
# Author: Mathieu Trudel-Lapierre <[email protected]>
55
# Author: Łukasz 'sil2100' Zemczak <[email protected]>
6+
# Author: Lukas 'slyon' Märdian <[email protected]>
67
#
78
# This program is free software; you can redistribute it and/or modify
89
# it under the terms of the GNU General Public License as published by
@@ -166,24 +167,46 @@ def get_interface_driver_name(interface, only_down=False): # pragma: nocover (c
166167

167168
def get_interface_macaddress(interface): # pragma: nocover (covered in autopkgtest)
168169
link = netifaces.ifaddresses(interface)[netifaces.AF_LINK][0]
169-
170170
return link.get('addr')
171171

172172

173-
def is_interface_matching_name(interface, match_driver):
174-
return fnmatch.fnmatchcase(interface, match_driver)
173+
def is_interface_matching_name(interface, match_name):
174+
# globs are supported
175+
return fnmatch.fnmatchcase(interface, match_name)
175176

176177

177178
def is_interface_matching_driver_name(interface, match_driver):
178179
driver_name = get_interface_driver_name(interface)
179-
180-
return match_driver == driver_name
180+
# globs are supported
181+
return fnmatch.fnmatchcase(driver_name, match_driver)
181182

182183

183184
def is_interface_matching_macaddress(interface, match_mac):
184185
macaddress = get_interface_macaddress(interface)
186+
# exact, case insensitive match. globs are not supported
187+
return match_mac.lower() == macaddress.lower()
188+
189+
190+
def find_matching_iface(interfaces, match):
191+
assert isinstance(match, dict)
185192

186-
return match_mac == macaddress
193+
# Filter for match.name glob, fallback to '*'
194+
name_glob = match.get('name') if match.get('name', False) else '*'
195+
matches = fnmatch.filter(interfaces, name_glob)
196+
197+
# Filter for match.macaddress (exact match)
198+
if len(matches) > 1 and match.get('macaddress'):
199+
matches = list(filter(lambda iface: is_interface_matching_macaddress(iface, match.get('macaddress')), matches))
200+
201+
# Filter for match.driver glob
202+
if len(matches) > 1 and match.get('driver'):
203+
matches = list(filter(lambda iface: is_interface_matching_driver_name(iface, match.get('driver')), matches))
204+
205+
# Return current name of unique matched interface, if available
206+
if len(matches) != 1:
207+
logging.info(matches)
208+
return None
209+
return matches[0]
187210

188211

189212
class NetplanCommand(argparse.Namespace):

tests/integration/ethernets.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,29 @@ def test_link_local_all(self):
240240
# Verify IPv4 and IPv6 link local addresses are there
241241
self.assert_iface(self.dev_e_client, ['inet6 fe80:', 'inet 169.254.'])
242242

243+
def test_rename_interfaces(self):
244+
self.setup_eth(None)
245+
with open(self.config, 'w') as f:
246+
f.write('''network:
247+
renderer: %(r)s
248+
ethernets:
249+
idx:
250+
match:
251+
name: %(ec)s
252+
set-name: iface1
253+
addresses: [10.10.10.11/24]
254+
idy:
255+
match:
256+
macaddress: %(e2c_mac)s
257+
set-name: iface2
258+
addresses: [10.10.10.22/24]
259+
''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c_mac': self.dev_e2_client_mac})
260+
self.generate_and_settle()
261+
self.assert_iface('iface1', ['inet 10.10.10.11'])
262+
self.assert_iface_up('iface1', ['inet 10.10.10.11'])
263+
self.assert_iface('iface2', ['inet 10.10.10.22'])
264+
self.assert_iface_up('iface2', ['inet 10.10.10.22'])
265+
243266

244267
@unittest.skipIf("networkd" not in test_backends,
245268
"skipping as networkd backend tests are disabled")

tests/test_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import tempfile
2121
import glob
2222

23+
from unittest.mock import patch
24+
2325
import netplan.cli.utils as utils
2426

2527

@@ -71,3 +73,26 @@ def test_nm_interfaces_globbing2(self):
7173
self.assertTrue('ens3' in ifaces)
7274
self.assertTrue('ens4' in ifaces)
7375
self.assertTrue(len(ifaces) == 4)
76+
77+
def test_find_matching_iface_too_many(self):
78+
# too many matches
79+
iface = utils.find_matching_iface(DEVICES, {'name': 'e*'})
80+
self.assertEqual(iface, None)
81+
82+
@patch('netplan.cli.utils.get_interface_macaddress')
83+
def test_find_matching_iface(self, gim):
84+
# we mock-out get_interface_macaddress to return useful values for the test
85+
gim.side_effect = lambda x: '00:01:02:03:04:05' if x == 'eth1' else '00:00:00:00:00:00'
86+
87+
match = {'name': 'e*', 'macaddress': '00:01:02:03:04:05'}
88+
iface = utils.find_matching_iface(DEVICES, match)
89+
self.assertEqual(iface, 'eth1')
90+
91+
@patch('netplan.cli.utils.get_interface_driver_name')
92+
def test_find_matching_iface_name_and_driver(self, gidn):
93+
# we mock-out get_interface_driver_name to return useful values for the test
94+
gidn.side_effect = lambda x: 'foo' if x == 'ens4' else 'bar'
95+
96+
match = {'name': 'ens?', 'driver': 'f*'}
97+
iface = utils.find_matching_iface(DEVICES, match)
98+
self.assertEqual(iface, 'ens4')

0 commit comments

Comments
 (0)