Files
supervisor/tests/dbus/network/test_interface.py
T
858630fb75 Avoid unnecessary full network connection re-activation (#7043)
* Skip network re-activation on startup when settings are unchanged

Since #3528 Supervisor re-applies its network defaults on every startup: it
rewrites the NetworkManager connection profile of each enabled interface and
re-activates the connection so the settings take effect. The re-activation
runs unconditionally, so every Supervisor start causes a full connection
cycle (routes torn down, DHCP re-negotiated, Wi-Fi reassociation) even when
the profile did not change, which is the common case.

Make NetworkSetting.update() report whether the profile actually changed by
comparing NetworkManager's normalized view of the settings before and after
the update call. Comparing Supervisor's generated payload against the
current profile would not work here since NetworkManager omits properties at
their default value from GetSettings. On the startup path, skip
re-activation when the profile is unchanged and the connection is currently
activated. Out-of-date profiles (e.g. after a change of Supervisor's
defaults) are still rewritten and re-activated, and user-initiated updates
via the API re-activate unconditionally as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reapply changed network settings in place on startup

When the startup profile update does change the NetworkManager connection
profile (e.g. after a change of Supervisor's defaults), the settings were
applied through a full re-activation cycle, briefly disrupting connectivity.

Use NetworkManager's Device.Reapply() instead, which applies the updated
profile to the active connection without disconnecting. Not all settings can
be reapplied (e.g. wireless security changes), in which case NetworkManager
raises an error and Supervisor falls back to the full re-activation as
before. User-initiated updates via the API are unaffected and still
re-activate the connection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Log network connection activation and reapply at info level

Activating or reapplying a connection affects host connectivity, but was
only visible in debug logs. Log at info level when Supervisor activates a
connection, reapplies changed settings in place, or creates a new
connection, so the Supervisor log shows when and why the host network
configuration was touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reapply changed settings in place for user-initiated updates too

Extend the in-place reapply from the startup path to user-initiated network
settings updates via the API. Changing e.g. IP or DNS configuration no
longer drops connectivity, which also matters when the update is made
remotely over the interface being reconfigured.

Payloads containing a Wi-Fi PSK always re-activate the connection: secrets
are excluded from GetSettings and ignored by NetworkManager's Reapply (the
diff runs with NM_SETTING_COMPARE_FLAG_IGNORE_SECRETS), so an updated PSK
would otherwise be written to the profile but never applied or validated.
Unchanged settings also still re-activate on the user path, both to keep
resubmitting settings working as a way to force a reconnect and to cover
secret-only changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Clarify settings variable naming in NetworkSetting.update()

The dict fetched from GetSettings was named new_settings and mutated by the
merges into the payload sent to Update, so the name was only accurate for
half of the function. Keep the fetched state pristine as current_settings,
merge into a copy named new_settings, and compare current_settings against
the normalized result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract in-place settings application into a helper method

Review feedback on #7043 noted the conditional block in apply_changes() had
grown confusing with the added nesting. Move the decision whether updated
settings are effective without a full re-activation cycle into a dedicated
_apply_settings_in_place() method using early returns, so apply_changes()
reads linearly: update settings, then activate unless they were applied in
place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:21:08 +02:00

220 lines
7.7 KiB
Python

"""Test NetwrokInterface."""
from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface
from dbus_fast.aio.message_bus import MessageBus
import pytest
from supervisor.dbus.const import DeviceType, InterfaceMethod
from supervisor.dbus.network import NetworkManager
from supervisor.dbus.network.interface import NetworkInterface
from tests.common import mock_dbus_services
from tests.const import TEST_INTERFACE_ETH_NAME, TEST_INTERFACE_WLAN_NAME
from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.network_device import Device as DeviceService
@pytest.fixture(name="device_eth0_service")
async def fixture_device_eth0_service(
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
) -> DeviceService:
"""Mock Device eth0 service."""
return network_manager_services["network_device"][
"/org/freedesktop/NetworkManager/Devices/1"
]
@pytest.fixture(name="device_wlan0_service")
async def fixture_device_wlan0_service(
network_manager_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
) -> DeviceService:
"""Mock Device wlan0 service."""
return network_manager_services["network_device"][
"/org/freedesktop/NetworkManager/Devices/3"
]
@pytest.fixture(name="device_unmanaged_service")
async def fixture_device_unmanaged_service(
dbus_session_bus: MessageBus,
) -> DeviceService:
"""Mock Device unmanaged service."""
return (
await mock_dbus_services(
{"network_device": "/org/freedesktop/NetworkManager/Devices/35"},
dbus_session_bus,
)
)["network_device"]
async def test_network_interface_ethernet(
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
):
"""Test network interface."""
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
assert interface.sync_properties is False
assert interface.interface_name is None
assert interface.type is None
await interface.connect(dbus_session_bus)
assert interface.sync_properties is True
assert interface.interface_name == TEST_INTERFACE_ETH_NAME
assert interface.type == DeviceType.ETHERNET
assert interface.managed is True
assert interface.wireless is None
assert interface.connection.state == 2
assert interface.connection.uuid == "0c23631e-2118-355c-bbb0-8943229cb0d6"
assert interface.connection.ipv4.address == [IPv4Interface("192.168.2.148/24")]
assert interface.connection.ipv6.address == [
IPv6Interface("2a03:169:3df5:0:6be9:2588:b26a:a679/64"),
IPv6Interface("2a03:169:3df5::2f1/128"),
]
assert interface.connection.ipv4.gateway == IPv4Address("192.168.2.1")
assert interface.connection.ipv6.gateway == IPv6Address("fe80::da58:d7ff:fe00:9c69")
assert interface.connection.ipv4.nameservers == [IPv4Address("192.168.2.2")]
assert interface.connection.ipv6.nameservers == [
IPv6Address("2001:1620:2777:1::10"),
IPv6Address("2001:1620:2777:2::20"),
]
assert interface.settings.ipv4.method == InterfaceMethod.AUTO
assert interface.settings.ipv6.method == InterfaceMethod.AUTO
assert interface.settings.connection.id == "Wired connection 1"
device_eth0_service.emit_properties_changed({"Managed": False})
await device_eth0_service.ping()
assert interface.managed is False
device_eth0_service.emit_properties_changed({}, ["Managed"])
await device_eth0_service.ping()
await device_eth0_service.ping()
assert interface.managed is True
async def test_reapply(
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
):
"""Test reapply on network interface."""
device_eth0_service.Reapply.calls.clear()
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
await interface.connect(dbus_session_bus)
await interface.reapply()
assert device_eth0_service.Reapply.calls == [
("/org/freedesktop/NetworkManager/Devices/1", {}, 0, 0)
]
async def test_network_interface_wlan(
device_wlan0_service: DeviceService, dbus_session_bus: MessageBus
):
"""Test wlan network interface."""
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/3")
assert interface.wireless is None
await interface.connect(dbus_session_bus)
assert interface.sync_properties is True
assert interface.interface_name == TEST_INTERFACE_WLAN_NAME
assert interface.type == DeviceType.WIRELESS
assert interface.wireless is not None
assert interface.wireless.bitrate == 0
async def test_old_connection_disconnect(
network_manager: NetworkManager, device_eth0_service: DeviceService
):
"""Test old connection disconnects on connection change."""
interface = network_manager.get(TEST_INTERFACE_ETH_NAME)
connection = interface.connection
assert connection.is_connected is True
device_eth0_service.emit_properties_changed({"ActiveConnection": "/"})
await device_eth0_service.ping()
assert interface.connection is None
assert connection.is_connected is False
async def test_old_wireless_disconnect(
network_manager: NetworkManager, device_wlan0_service: DeviceService
):
"""Test old wireless disconnects on type change."""
interface = network_manager.get(TEST_INTERFACE_WLAN_NAME)
wireless = interface.wireless
assert wireless.is_connected is True
device_wlan0_service.emit_properties_changed({"DeviceType": DeviceType.ETHERNET})
await device_wlan0_service.ping()
assert interface.wireless is None
assert wireless.is_connected is False
async def test_unmanaged_interface(
device_unmanaged_service: DeviceService, dbus_session_bus: MessageBus
):
"""Test unmanaged interfaces don't sync properties."""
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/35")
await interface.connect(dbus_session_bus)
assert interface.managed is False
assert interface.connection is None
assert interface.driver == "veth"
assert interface.sync_properties is False
device_unmanaged_service.emit_properties_changed({"Driver": "test"})
await device_unmanaged_service.ping()
assert interface.driver == "veth"
async def test_interface_becomes_unmanaged(
network_manager: NetworkManager,
device_eth0_service: DeviceService,
device_wlan0_service: DeviceService,
):
"""Test managed objects disconnect when interface becomes unmanaged."""
eth0 = network_manager.get(TEST_INTERFACE_ETH_NAME)
connection = eth0.connection
wlan0 = network_manager.get(TEST_INTERFACE_WLAN_NAME)
wireless = wlan0.wireless
assert connection.is_connected is True
assert wireless.is_connected is True
device_eth0_service.emit_properties_changed({"Managed": False})
await device_eth0_service.ping()
device_wlan0_service.emit_properties_changed({"Managed": False})
await device_wlan0_service.ping()
assert wlan0.wireless is None
assert wireless.is_connected is False
assert eth0.connection is None
assert connection.is_connected is False
async def test_unknown_device_type(
device_eth0_service: DeviceService, dbus_session_bus: MessageBus
):
"""Test unknown device types are handled gracefully."""
interface = NetworkInterface("/org/freedesktop/NetworkManager/Devices/1")
await interface.connect(dbus_session_bus)
# Emit an unknown device type (e.g., 1000 which doesn't exist in the enum)
device_eth0_service.emit_properties_changed({"DeviceType": 1000})
await device_eth0_service.ping()
# Should preserve the actual value as a pseudo-member instead of crashing
assert isinstance(interface.type, DeviceType)
assert interface.type == 1000
# Wireless should be None since it's not a wireless device
assert interface.wireless is None