Skip to content

Commit 8a95113

Browse files
agnersmdegat01
andauthored
Improve VLAN configuration (#6094)
* Fix NetworkManager connection name for VLANs The connection name for VLANs should include the parent interface name for better identification. This was originally the intention, but the interface object's name property was used which appears empty at that point. * Disallow creating multiple connections for the same VLAN id Only allow a single connection per interface and VLAN id. The regular network commands can be used to alter the configuration. * Fix pytest * Simply connection id name generation Always rely on the Supervisor interface representation's name attribute to generate the NetworkManager connection id. Make sure that the name is correctly set when creating VLAN interfaces as well. * Special case VLAN configuration We can't use the match information when comparing Supervisor interface representation with D-Bus representations. Special case VLAN and compare using VLAN ID and parent interface. Note that this currently compares connection UUID of the parent interface. * Fix pytest * Separate VLAN creation logic from apply_changes Apply changes is really all about updating the NetworkManager settings of a particular network interface. The base in apply_changes() is NetworkInterface class, which is the NetworkManager Device abstraction. All physical interfaces have such a Device hence it is always present. The only exception is when creating a VLAN: Since it is a virtual device, there is no device when creating a VLAN. This separate the two cases. This makes it much easier to reason if a VLAN already exists or not, and to handle the case where a VLAN needs to be created. For all other network interfaces, the apply_changes() method can now rely on the presence of the NetworkInterface Device abstraction. * Add VLAN test interface and VLAN exists test Add a test which checks that an error gets raised when a VLAN for a particular interface/id combination already exists. * Address pylint * Fix test_ignore_veth_only_changes pytest * Make VLAN interface disabled to avoid test issues * Reference setting 38 in mocked connection * Make sure interface type matches Require a interface type match before doing any comparision. * Add Supervisor host network configuration tests * Fix device type checking * Fix pytest * Fix tests by taking VLAN interface into account * Fix test_load_with_network_connection_issues This seems like a hack, but it turns out that the additional active connection caused coresys.host.network.update() to be called, which implicitly "fake" activated the connection. Now it seems that our mocking causes IPv4 gateway to be set. So in a way, the test checked a particular mock behavior instead of actual intention. The crucial part of this test is that we make sure the settings remain unchanged. This is done by ensuring that the the method is still auto. * Fix test_check_network_interface_ipv4.py Now that we have the VLAN interface active too it will raise an issue as well. * Apply suggestions from code review Co-authored-by: Mike Degatano <[email protected]> * Fix ruff check issue --------- Co-authored-by: Mike Degatano <[email protected]>
1 parent 3fc1abf commit 8a95113

19 files changed

+553
-78
lines changed

supervisor/api/network.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ async def create_vlan(self, request: web.Request) -> None:
325325
)
326326

327327
vlan_interface = Interface(
328-
"",
328+
f"{interface.name}.{vlan}",
329329
"",
330330
"",
331331
True,
@@ -339,4 +339,4 @@ async def create_vlan(self, request: web.Request) -> None:
339339
None,
340340
vlan_config,
341341
)
342-
await asyncio.shield(self.sys_host.network.apply_changes(vlan_interface))
342+
await asyncio.shield(self.sys_host.network.create_vlan(vlan_interface))

supervisor/dbus/network/setting/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,8 @@ async def reload(self):
273273
if CONF_ATTR_VLAN in data:
274274
if CONF_ATTR_VLAN_ID in data[CONF_ATTR_VLAN]:
275275
self._vlan = VlanProperties(
276-
data[CONF_ATTR_VLAN][CONF_ATTR_VLAN_ID],
277-
data[CONF_ATTR_VLAN].get(CONF_ATTR_VLAN_PARENT),
276+
id=data[CONF_ATTR_VLAN][CONF_ATTR_VLAN_ID],
277+
parent=data[CONF_ATTR_VLAN].get(CONF_ATTR_VLAN_PARENT),
278278
)
279279
else:
280280
self._vlan = None

supervisor/dbus/network/setting/generate.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,6 @@ def get_connection_from_interface(
177177
# Generate/Update ID/name
178178
if not name or not name.startswith("Supervisor"):
179179
name = f"Supervisor {interface.name}"
180-
if interface.type == InterfaceType.VLAN:
181-
name = f"{name}.{cast(VlanConfig, interface.vlan).id}"
182180

183181
if interface.type == InterfaceType.ETHERNET:
184182
iftype = "802-3-ethernet"
@@ -220,7 +218,7 @@ def get_connection_from_interface(
220218
conn[CONF_ATTR_802_ETHERNET] = {
221219
CONF_ATTR_802_ETHERNET_ASSIGNED_MAC: Variant("s", "preserve")
222220
}
223-
elif interface.type == "vlan":
221+
elif interface.type == InterfaceType.VLAN:
224222
parent = cast(VlanConfig, interface.vlan).interface
225223
if (
226224
parent

supervisor/host/configuration.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ class VlanConfig:
8282
"""Represent a vlan configuration."""
8383

8484
id: int
85+
# Note: On VLAN creation, parent is the interface name, but in the NetworkManager
86+
# config the parent is set to the connection UUID in get_connection_from_interface().
87+
# On network update (which we call in apply_changes() on VLAN creation), the
88+
# parent is then set to that connection UUID in _map_nm_vlan(), hence we always
89+
# operate with a connection UUID as interface!
8590
interface: str | None
8691

8792

@@ -108,6 +113,25 @@ def equals_dbus_interface(self, inet: NetworkInterface) -> bool:
108113
if not inet.settings:
109114
return False
110115

116+
# Special handling for VLAN interfaces
117+
if self.type == InterfaceType.VLAN and inet.type == DeviceType.VLAN:
118+
if not self.vlan:
119+
raise RuntimeError("VLAN information missing")
120+
121+
if inet.settings.vlan:
122+
# For VLANs, compare by VLAN id and parent interface
123+
return (
124+
inet.settings.vlan.id == self.vlan.id
125+
and inet.settings.vlan.parent == self.vlan.interface
126+
)
127+
return False
128+
129+
if (self.type, inet.type) not in [
130+
(InterfaceType.ETHERNET, DeviceType.ETHERNET),
131+
(InterfaceType.WIRELESS, DeviceType.WIRELESS),
132+
]:
133+
return False
134+
111135
if inet.settings.match and inet.settings.match.path:
112136
return inet.settings.match.path == [self.path]
113137

supervisor/host/network.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import logging
66
from typing import Any
77

8+
from supervisor.utils.sentry import async_capture_exception
9+
810
from ..const import ATTR_HOST_INTERNET
911
from ..coresys import CoreSys, CoreSysAttributes
1012
from ..dbus.const import (
@@ -20,7 +22,6 @@
2022
WirelessMethodType,
2123
)
2224
from ..dbus.network.connection import NetworkConnection
23-
from ..dbus.network.interface import NetworkInterface
2425
from ..dbus.network.setting.generate import get_connection_from_interface
2526
from ..exceptions import (
2627
DBusError,
@@ -209,20 +210,53 @@ async def update(self, *, force_connectivity_check: bool = False):
209210

210211
await self.check_connectivity(force=force_connectivity_check)
211212

213+
async def create_vlan(self, interface: Interface) -> None:
214+
"""Create a VLAN interface."""
215+
if interface.vlan is None:
216+
raise RuntimeError("VLAN information is missing")
217+
# For VLAN interfaces, check if one already exists with same ID on same parent
218+
try:
219+
self.sys_dbus.network.get(interface.name)
220+
except NetworkInterfaceNotFound:
221+
_LOGGER.debug(
222+
"VLAN interface %s does not exist, creating it", interface.name
223+
)
224+
else:
225+
raise HostNetworkError(
226+
f"VLAN {interface.vlan.id} already exists on interface {interface.vlan.interface}",
227+
_LOGGER.error,
228+
)
229+
230+
settings = get_connection_from_interface(interface, self.sys_dbus.network)
231+
232+
try:
233+
await self.sys_dbus.network.settings.add_connection(settings)
234+
except DBusError as err:
235+
raise HostNetworkError(
236+
f"Can't create new interface: {err}", _LOGGER.error
237+
) from err
238+
239+
await self.update(force_connectivity_check=True)
240+
212241
async def apply_changes(
213242
self, interface: Interface, *, update_only: bool = False
214243
) -> None:
215244
"""Apply Interface changes to host."""
216-
inet: NetworkInterface | None = None
217-
with suppress(NetworkInterfaceNotFound):
245+
try:
218246
inet = self.sys_dbus.network.get(interface.name)
247+
except NetworkInterfaceNotFound as err:
248+
# The API layer (or anybody else) should not pass any updates for
249+
# non-existing interfaces.
250+
await async_capture_exception(err)
251+
raise HostNetworkError(
252+
"Requested Network interface update is not possible", _LOGGER.warning
253+
) from err
219254

220255
con: NetworkConnection | None = None
221256

222257
# Update exist configuration
223258
if (
224-
inet
225-
and inet.settings
259+
inet.settings
226260
and inet.settings.connection
227261
and interface.equals_dbus_interface(inet)
228262
and interface.enabled
@@ -257,7 +291,7 @@ async def apply_changes(
257291
)
258292

259293
# Create new configuration and activate interface
260-
elif inet and interface.enabled:
294+
elif interface.enabled:
261295
_LOGGER.debug("Create new configuration for %s", interface.name)
262296
settings = get_connection_from_interface(interface, self.sys_dbus.network)
263297

@@ -280,7 +314,7 @@ async def apply_changes(
280314
) from err
281315

282316
# Remove config from interface
283-
elif inet and not interface.enabled:
317+
elif not interface.enabled:
284318
if not inet.settings:
285319
_LOGGER.debug("Interface %s is already disabled.", interface.name)
286320
return
@@ -291,16 +325,6 @@ async def apply_changes(
291325
f"Can't disable interface {interface.name}: {err}", _LOGGER.error
292326
) from err
293327

294-
# Create new interface (like vlan)
295-
elif not inet:
296-
settings = get_connection_from_interface(interface, self.sys_dbus.network)
297-
298-
try:
299-
await self.sys_dbus.network.settings.add_connection(settings)
300-
except DBusError as err:
301-
raise HostNetworkError(
302-
f"Can't create new interface: {err}", _LOGGER.error
303-
) from err
304328
else:
305329
raise HostNetworkError(
306330
"Requested Network interface update is not possible", _LOGGER.warning

tests/api/test_network.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ async def test_api_network_vlan(
389389
connection = settings_service.AddConnection.calls[0][0]
390390
assert "uuid" in connection["connection"]
391391
assert connection["connection"] == {
392-
"id": Variant("s", "Supervisor .1"),
392+
"id": Variant("s", "Supervisor eth0.1"),
393393
"type": Variant("s", "vlan"),
394394
"llmnr": Variant("i", 2),
395395
"mdns": Variant("i", 2),
@@ -403,6 +403,16 @@ async def test_api_network_vlan(
403403
"parent": Variant("s", "0c23631e-2118-355c-bbb0-8943229cb0d6"),
404404
}
405405

406+
# Check if trying to recreate an existing VLAN raises an exception
407+
result = await resp.json()
408+
resp = await api_client.post(
409+
f"/network/interface/{TEST_INTERFACE_ETH_NAME}/vlan/10",
410+
json={"ipv4": {"method": "auto"}},
411+
)
412+
result = await resp.json()
413+
assert result["result"] == "error"
414+
assert len(settings_service.AddConnection.calls) == 1
415+
406416

407417
@pytest.mark.parametrize(
408418
("method", "url"),

tests/conftest.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,17 @@
6464
from .const import TEST_ADDON_SLUG
6565
from .dbus_service_mocks.base import DBusServiceMock
6666
from .dbus_service_mocks.network_connection_settings import (
67+
DEFAULT_OBJECT_PATH as DEFAULT_CONNECTION_SETTINGS_OBJECT_PATH,
6768
ConnectionSettings as ConnectionSettingsService,
6869
)
6970
from .dbus_service_mocks.network_dns_manager import DnsManager as DnsManagerService
7071
from .dbus_service_mocks.network_manager import NetworkManager as NetworkManagerService
7172

73+
from tests.dbus_service_mocks.network_active_connection import (
74+
DEFAULT_OBJECT_PATH as DEFAULT_ACTIVE_CONNECTION_OBJECT_PATH,
75+
ActiveConnection as ActiveConnectionService,
76+
)
77+
7278
# pylint: disable=redefined-outer-name, protected-access
7379

7480

@@ -191,13 +197,21 @@ async def fixture_network_manager_services(
191197
"/org/freedesktop/NetworkManager/AccessPoint/43099",
192198
"/org/freedesktop/NetworkManager/AccessPoint/43100",
193199
],
194-
"network_active_connection": None,
195-
"network_connection_settings": None,
200+
"network_active_connection": [
201+
"/org/freedesktop/NetworkManager/ActiveConnection/1",
202+
"/org/freedesktop/NetworkManager/ActiveConnection/38",
203+
],
204+
"network_connection_settings": [
205+
"/org/freedesktop/NetworkManager/Settings/1",
206+
"/org/freedesktop/NetworkManager/Settings/38",
207+
],
196208
"network_device_wireless": None,
197209
"network_device": [
198210
"/org/freedesktop/NetworkManager/Devices/1",
199211
"/org/freedesktop/NetworkManager/Devices/3",
212+
"/org/freedesktop/NetworkManager/Devices/38",
200213
],
214+
"network_device_vlan": None,
201215
"network_dns_manager": None,
202216
"network_ip4config": None,
203217
"network_ip6config": None,
@@ -235,12 +249,24 @@ async def dns_manager_service(
235249
yield network_manager_services["network_dns_manager"]
236250

237251

252+
@pytest.fixture(name="active_connection_service")
253+
async def fixture_active_connection_service(
254+
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
255+
) -> ActiveConnectionService:
256+
"""Return mock active connection service."""
257+
yield network_manager_services["network_active_connection"][
258+
DEFAULT_ACTIVE_CONNECTION_OBJECT_PATH
259+
]
260+
261+
238262
@pytest.fixture(name="connection_settings_service")
239263
async def fixture_connection_settings_service(
240264
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
241265
) -> ConnectionSettingsService:
242266
"""Return mock connection settings service."""
243-
yield network_manager_services["network_connection_settings"]
267+
yield network_manager_services["network_connection_settings"][
268+
DEFAULT_CONNECTION_SETTINGS_OBJECT_PATH
269+
]
244270

245271

246272
@pytest.fixture(name="udisks2_services")

tests/dbus/network/setting/test_generate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async def test_get_connection_no_path(network_manager: NetworkManager):
4848
async def test_generate_from_vlan(network_manager: NetworkManager):
4949
"""Test generate from a vlan interface."""
5050
vlan_interface = Interface(
51-
name="",
51+
name="eth0.1",
5252
mac="",
5353
path="",
5454
enabled=True,
@@ -64,7 +64,7 @@ async def test_generate_from_vlan(network_manager: NetworkManager):
6464
)
6565

6666
connection_payload = get_connection_from_interface(vlan_interface, network_manager)
67-
assert connection_payload["connection"]["id"].value == "Supervisor .1"
67+
assert connection_payload["connection"]["id"].value == "Supervisor eth0.1"
6868
assert connection_payload["connection"]["type"].value == "vlan"
6969
assert "uuid" in connection_payload["connection"]
7070
assert "match" not in connection_payload["connection"]

tests/dbus/network/setting/test_init.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from supervisor.host.const import InterfaceMethod
1616
from supervisor.host.network import Interface
1717

18-
from tests.dbus_service_mocks.base import DBusServiceMock
1918
from tests.dbus_service_mocks.network_connection_settings import (
2019
ConnectionSettings as ConnectionSettingsService,
2120
)
@@ -24,13 +23,7 @@
2423
WIRELESS_DEVICE_OBJECT_PATH,
2524
)
2625

27-
28-
@pytest.fixture(name="connection_settings_service", autouse=True)
29-
async def fixture_connection_settings_service(
30-
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
31-
) -> ConnectionSettingsService:
32-
"""Mock Connection Settings service."""
33-
yield network_manager_services["network_connection_settings"]
26+
pytestmark = pytest.mark.usefixtures("connection_settings_service")
3427

3528

3629
@pytest.fixture(name="dbus_interface")

tests/dbus/network/test_connection.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,11 @@
88
from supervisor.dbus.network.connection import NetworkConnection
99

1010
from tests.const import TEST_INTERFACE_ETH_NAME
11-
from tests.dbus_service_mocks.base import DBusServiceMock
1211
from tests.dbus_service_mocks.network_active_connection import (
1312
ActiveConnection as ActiveConnectionService,
1413
)
1514

16-
17-
@pytest.fixture(name="active_connection_service", autouse=True)
18-
async def fixture_active_connection_service(
19-
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
20-
) -> ActiveConnectionService:
21-
"""Mock Active Connection service."""
22-
yield network_manager_services["network_active_connection"]
15+
pytestmark = pytest.mark.usefixtures("active_connection_service")
2316

2417

2518
async def test_active_connection(

0 commit comments

Comments
 (0)