Files
supervisor/tests/dbus/network/setting/test_init.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

187 lines
6.9 KiB
Python

"""Test Network Manager Connection object."""
from unittest.mock import MagicMock, PropertyMock
from awesomeversion import AwesomeVersion
from dbus_fast import Variant
from dbus_fast.aio.message_bus import MessageBus
import pytest
from supervisor.dbus.network import NetworkManager
from supervisor.dbus.network.interface import NetworkInterface
from supervisor.dbus.network.setting import NetworkSetting
from supervisor.dbus.network.setting.generate import get_connection_from_interface
from supervisor.host.configuration import Ip6Setting
from supervisor.host.const import InterfaceMethod
from supervisor.host.network import Interface
from tests.dbus_service_mocks.network_connection_settings import (
ConnectionSettings as ConnectionSettingsService,
)
from tests.dbus_service_mocks.network_device import (
ETHERNET_DEVICE_OBJECT_PATH,
WIRELESS_DEVICE_OBJECT_PATH,
)
pytestmark = pytest.mark.usefixtures("connection_settings_service")
@pytest.fixture(name="dbus_interface")
async def fixture_dbus_interface(
dbus_session_bus: MessageBus, device_object_path: str = ETHERNET_DEVICE_OBJECT_PATH
) -> NetworkInterface:
"""Get connected dbus interface."""
dbus_interface = NetworkInterface(device_object_path)
await dbus_interface.connect(dbus_session_bus)
return dbus_interface
@pytest.mark.parametrize(
"dbus_interface",
[ETHERNET_DEVICE_OBJECT_PATH, WIRELESS_DEVICE_OBJECT_PATH],
indirect=True,
)
async def test_ethernet_update(
dbus_interface: NetworkInterface,
connection_settings_service: ConnectionSettingsService,
network_manager: NetworkManager,
):
"""Test network manager update."""
connection_settings_service.Update.calls.clear()
interface = Interface.from_dbus_interface(dbus_interface)
conn = get_connection_from_interface(
interface,
network_manager,
name=dbus_interface.settings.connection.id,
uuid=dbus_interface.settings.connection.uuid,
)
assert await dbus_interface.settings.update(conn) is True
assert len(connection_settings_service.Update.calls) == 1
settings = connection_settings_service.Update.calls[0][0]
assert settings["connection"]["id"] == Variant("s", "Supervisor eth0")
assert "interface-name" not in settings["connection"]
assert settings["connection"]["uuid"] == Variant(
"s", "0c23631e-2118-355c-bbb0-8943229cb0d6"
)
assert settings["connection"]["autoconnect"] == Variant("b", True)
assert settings["match"] == {"path": Variant("as", ["platform-ff3f0000.ethernet"])}
assert "ipv4" in settings
assert settings["ipv4"]["method"] == Variant("s", "auto")
assert "gateway" not in settings["ipv4"]
# Only DNS settings need to be preserved with auto
assert settings["ipv4"]["dns"] == Variant("au", [16951488])
assert "dns-data" not in settings["ipv4"]
assert "address-data" not in settings["ipv4"]
assert "addresses" not in settings["ipv4"]
assert len(settings["ipv4"]["route-data"].value) == 1
assert settings["ipv4"]["route-data"].value[0]["dest"] == Variant(
"s", "192.168.122.0"
)
assert settings["ipv4"]["route-data"].value[0]["prefix"] == Variant("u", 24)
assert settings["ipv4"]["route-data"].value[0]["next-hop"] == Variant(
"s", "10.10.10.1"
)
assert settings["ipv4"]["routes"] == Variant("aau", [[8038592, 24, 17435146, 0]])
assert "ipv6" in settings
assert settings["ipv6"]["method"] == Variant("s", "auto")
assert "gateway" not in settings["ipv6"]
# Only DNS settings need to be preserved with auto
assert settings["ipv6"]["dns"] == Variant(
"aay", [bytearray(b" \x01H`H`\x00\x00\x00\x00\x00\x00\x00\x00\x88\x88")]
)
assert "dns-data" not in settings["ipv6"]
assert "address-data" not in settings["ipv6"]
assert "addresses" not in settings["ipv6"]
assert settings["ipv6"]["addr-gen-mode"] == Variant("i", 0)
assert "proxy" in settings
assert "vlan" not in settings
if settings["connection"]["type"] == "802-3-ethernet":
assert "802-3-ethernet" in settings
assert settings["802-3-ethernet"]["auto-negotiate"] == Variant("b", False)
assert "802-11-wireless" not in settings
assert "802-11-wireless-security" not in settings
if settings["connection"]["type"] == "802-11-wireless":
assert "802-11-wireless" in settings
assert settings["802-11-wireless"]["ssid"] == Variant("ay", b"NETT")
assert "mode" not in settings["802-11-wireless"]
assert "powersave" not in settings["802-11-wireless"]
assert "802-11-wireless-security" not in settings
# Applying the same settings again does not change the profile
assert await dbus_interface.settings.update(conn) is False
assert len(connection_settings_service.Update.calls) == 2
async def test_ipv6_disabled_is_link_local(
dbus_interface: NetworkInterface, network_manager: NetworkManager
):
"""Test disabled equals link local for ipv6."""
interface = Interface.from_dbus_interface(dbus_interface)
interface.ipv4setting.method = InterfaceMethod.DISABLED
interface.ipv6setting.method = InterfaceMethod.DISABLED
conn = get_connection_from_interface(
interface,
network_manager,
name=dbus_interface.settings.connection.id,
uuid=dbus_interface.settings.connection.uuid,
)
assert conn["ipv4"]["method"] == Variant("s", "disabled")
assert conn["ipv6"]["method"] == Variant("s", "link-local")
@pytest.mark.parametrize(
("version", "addr_gen_mode"),
[
("1.38.0", 1),
("1.40.0", 3),
],
)
async def test_ipv6_addr_gen_mode(
dbus_interface: NetworkInterface, version: str, addr_gen_mode: int
):
"""Test addr_gen_mode with various NetworkManager versions."""
interface = Interface.from_dbus_interface(dbus_interface)
interface.ipv6setting = Ip6Setting(InterfaceMethod.AUTO, [], None, None, [])
network_manager = MagicMock()
type(network_manager).version = PropertyMock(return_value=AwesomeVersion(version))
conn = get_connection_from_interface(
interface,
network_manager,
name=dbus_interface.settings.connection.id,
uuid=dbus_interface.settings.connection.uuid,
)
assert conn["ipv6"]["method"] == Variant("s", "auto")
assert conn["ipv6"]["addr-gen-mode"] == Variant("i", addr_gen_mode)
async def test_watching_updated_signal(
connection_settings_service: ConnectionSettingsService, dbus_session_bus: MessageBus
):
"""Test get settings called on update signal."""
connection_settings_service.GetSettings.calls.clear()
settings = NetworkSetting("/org/freedesktop/NetworkManager/Settings/1")
await settings.connect(dbus_session_bus)
assert connection_settings_service.GetSettings.calls == [()]
connection_settings_service.Updated()
await connection_settings_service.ping()
await connection_settings_service.ping()
assert connection_settings_service.GetSettings.calls == [(), ()]