diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py index 0c06d78f4..95b6d7a65 100644 --- a/supervisor/host/manager.py +++ b/supervisor/host/manager.py @@ -95,21 +95,29 @@ class HostManager(CoreSysAttributes): return features - async def reload(self): + async def reload( + self, + *, + services: bool = True, + network: bool = True, + agent: bool = True, + audio: bool = True, + ): """Reload host functions.""" await self.info.update() - if self.sys_dbus.systemd.is_connected: + if services and self.sys_dbus.systemd.is_connected: await self.services.update() - if self.sys_dbus.network.is_connected: + if network and self.sys_dbus.network.is_connected: await self.network.update() - if self.sys_dbus.agent.is_connected: + if agent and self.sys_dbus.agent.is_connected: await self.sys_dbus.agent.update() - with suppress(PulseAudioError): - await self.sound.update() + if audio: + with suppress(PulseAudioError): + await self.sound.update() _LOGGER.info("Host information reload completed") self.supported_features.cache_clear() # pylint: disable=no-member @@ -117,7 +125,8 @@ class HostManager(CoreSysAttributes): async def load(self): """Load host information.""" with suppress(HassioError): - await self.reload() + await self.reload(network=False) + await self.network.load() # Register for events self.sys_bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events) diff --git a/supervisor/host/network.py b/supervisor/host/network.py index 2c9ad8e81..19c9de8c2 100644 --- a/supervisor/host/network.py +++ b/supervisor/host/network.py @@ -2,11 +2,15 @@ from __future__ import annotations import asyncio +from contextlib import suppress from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface import logging import attr +from supervisor.jobs.const import JobCondition +from supervisor.jobs.decorator import Job + from ..const import ATTR_HOST_INTERNET from ..coresys import CoreSys, CoreSysAttributes from ..dbus.const import ( @@ -79,7 +83,6 @@ class NetworkManager(CoreSysAttributes): async def check_connectivity(self): """Check the internet connection.""" - if not self.sys_dbus.network.connectivity_enabled: return @@ -100,6 +103,25 @@ class NetworkManager(CoreSysAttributes): self.sys_dbus.network.interfaces[inet_name] ) + @Job(conditions=JobCondition.HOST_NETWORK) + async def load(self): + """Load network information and reapply defaults over dbus.""" + await self.update() + + # Apply current settings on each interface so OS can update any out of date defaults + interfaces = [ + Interface.from_dbus_interface(self.sys_dbus.network.interfaces[i]) + for i in self.sys_dbus.network.interfaces + ] + with suppress(HostNetworkNotFound): + await asyncio.gather( + *[ + self.apply_changes(interface, update_only=True) + for interface in interfaces + if interface.enabled + ] + ) + async def update(self): """Update properties over dbus.""" _LOGGER.info("Updating local network information") @@ -114,7 +136,9 @@ class NetworkManager(CoreSysAttributes): await self.check_connectivity() - async def apply_changes(self, interface: Interface) -> None: + async def apply_changes( + self, interface: Interface, *, update_only: bool = False + ) -> None: """Apply Interface changes to host.""" inet = self.sys_dbus.network.interfaces.get(interface.name) con: NetworkConnection = None @@ -147,6 +171,13 @@ class NetworkManager(CoreSysAttributes): f"Can't update config on {interface.name}: {err}", _LOGGER.error ) from err + # Stop if only updates are allowed as other paths create/delete interfaces + elif update_only: + raise HostNetworkNotFound( + f"Requested to update interface {interface.name} which does not exist or is disabled.", + _LOGGER.warning, + ) + # Create new configuration and activate interface elif inet and interface.enabled: _LOGGER.debug("Create new configuration for %s", interface.name) diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index b275c627a..cd06f446b 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -19,6 +19,7 @@ class JobCondition(str, Enum): RUNNING = "running" HAOS = "haos" OS_AGENT = "os_agent" + HOST_NETWORK = "host_network" class JobExecutionLimit(str, Enum): diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index f30dde891..e8a64e000 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -181,6 +181,14 @@ class Job(CoreSysAttributes): f"'{self._method.__qualname__}' blocked from execution, no Home Assistant OS-Agent available" ) + if ( + JobCondition.HOST_NETWORK in self.conditions + and not self.sys_dbus.network.is_connected + ): + raise JobConditionException( + f"'{self._method.__qualname__}' blocked from execution, host Network Manager not available" + ) + async def _acquire_exection_limit(self) -> None: """Process exection limits.""" if self.limit not in ( diff --git a/tests/conftest.py b/tests/conftest.py index 41cd9aba8..d066c17c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,8 +18,13 @@ from supervisor.api import RestAPI from supervisor.bootstrap import initialize_coresys from supervisor.const import REQUEST_FROM from supervisor.coresys import CoreSys +from supervisor.dbus.agent import OSAgent from supervisor.dbus.const import DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED +from supervisor.dbus.hostname import Hostname +from supervisor.dbus.interface import DBusInterface from supervisor.dbus.network import NetworkManager +from supervisor.dbus.systemd import Systemd +from supervisor.dbus.timedate import TimeDate from supervisor.docker import DockerAPI from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository @@ -147,6 +152,37 @@ async def network_manager(dbus) -> NetworkManager: yield nm_obj +async def mock_dbus_interface(dbus: DBus, instance: DBusInterface) -> DBusInterface: + """Mock dbus for a DBusInterface instance.""" + instance.dbus = dbus + await instance.connect() + return instance + + +@pytest.fixture +async def hostname(dbus: DBus) -> Hostname: + """Mock Hostname.""" + yield await mock_dbus_interface(dbus, Hostname()) + + +@pytest.fixture +async def timedate(dbus: DBus) -> TimeDate: + """Mock Timedate.""" + yield await mock_dbus_interface(dbus, TimeDate()) + + +@pytest.fixture +async def systemd(dbus: DBus) -> Systemd: + """Mock Systemd.""" + yield await mock_dbus_interface(dbus, Systemd()) + + +@pytest.fixture +async def os_agent(dbus: DBus) -> Systemd: + """Mock OSAgent.""" + yield await mock_dbus_interface(dbus, OSAgent()) + + @pytest.fixture async def coresys(loop, docker, network_manager, aiohttp_client, run_dir) -> CoreSys: """Create a CoreSys Mock.""" diff --git a/tests/fixtures/org_freedesktop_NetworkManager-ActivateConnection.json b/tests/fixtures/org_freedesktop_NetworkManager-ActivateConnection.json index 0637a088a..d3375b291 100644 --- a/tests/fixtures/org_freedesktop_NetworkManager-ActivateConnection.json +++ b/tests/fixtures/org_freedesktop_NetworkManager-ActivateConnection.json @@ -1 +1 @@ -[] \ No newline at end of file +["/org/freedesktop/NetworkManager/ActiveConnection/1"] diff --git a/tests/fixtures/org_freedesktop_NetworkManager_Settings_1-GetSettings.json b/tests/fixtures/org_freedesktop_NetworkManager_Settings_1-GetSettings.json index 1ada5fab7..37451b81e 100644 --- a/tests/fixtures/org_freedesktop_NetworkManager_Settings_1-GetSettings.json +++ b/tests/fixtures/org_freedesktop_NetworkManager_Settings_1-GetSettings.json @@ -1 +1,38 @@ -[{"connection": {"id": "Wired connection 1", "permissions": [], "timestamp": 1598125548, "type": "802-3-ethernet", "uuid": "0c23631e-2118-355c-bbb0-8943229cb0d6"}, "ipv4": {"address-data": [{"address": "192.168.2.148", "prefix": 24}], "addresses": [[2483202240, 24, 16951488]], "dns": [16951488], "dns-search": [], "gateway": "192.168.2.1", "method": "auto", "route-data": [], "routes": []}, "ipv6": {"address-data": [], "addresses": [], "dns": [], "dns-search": [], "method": "auto", "route-data": [], "routes": []}, "proxy": {}, "802-3-ethernet": {"auto-negotiate": false, "mac-address-blacklist": [], "s390-options": {}}, "802-11-wireless": {"ssid": [78, 69, 84, 84]}}] \ No newline at end of file +[ + { + "connection": { + "id": "Wired connection 1", + "interface-name": "eth0", + "permissions": [], + "timestamp": 1598125548, + "type": "802-3-ethernet", + "uuid": "0c23631e-2118-355c-bbb0-8943229cb0d6" + }, + "ipv4": { + "address-data": [{ "address": "192.168.2.148", "prefix": 24 }], + "addresses": [[2483202240, 24, 16951488]], + "dns": [16951488], + "dns-search": [], + "gateway": "192.168.2.1", + "method": "auto", + "route-data": [], + "routes": [] + }, + "ipv6": { + "address-data": [], + "addresses": [], + "dns": [], + "dns-search": [], + "method": "auto", + "route-data": [], + "routes": [] + }, + "proxy": {}, + "802-3-ethernet": { + "auto-negotiate": false, + "mac-address-blacklist": [], + "s390-options": {} + }, + "802-11-wireless": { "ssid": [78, 69, 84, 84] } + } +] diff --git a/tests/fixtures/org_freedesktop_systemd1-ListUnits.json b/tests/fixtures/org_freedesktop_systemd1-ListUnits.json new file mode 100644 index 000000000..bc6f8092a --- /dev/null +++ b/tests/fixtures/org_freedesktop_systemd1-ListUnits.json @@ -0,0 +1,50 @@ +[ + [ + "etc-machine\\x2did.mount", + "/etc/machine-id", + "loaded", + "active", + "mounted", + "", + "/org/freedesktop/systemd1/unit/etc_2dmachine_5cx2did_2emount", + 0, + "", + "/" + ], + [ + "firewalld.service", + "firewalld.service", + "not-found", + "inactive", + "dead", + "", + "/org/freedesktop/systemd1/unit/firewalld_2eservice", + 0, + "", + "/" + ], + [ + "sys-devices-virtual-tty-ttypd.device", + "/sys/devices/virtual/tty/ttypd", + "loaded", + "active", + "plugged", + "", + "/org/freedesktop/systemd1/unit/sys_2ddevices_2dvirtual_2dtty_2dttypd_2edevice", + 0, + "", + "/" + ], + [ + "zram-swap.service", + "HassOS ZRAM swap", + "loaded", + "active", + "exited", + "", + "/org/freedesktop/systemd1/unit/zram_2dswap_2eservice", + 0, + "", + "/" + ] +] diff --git a/tests/fixtures/org_freedesktop_systemd1_Manager.json b/tests/fixtures/org_freedesktop_systemd1_Manager.json new file mode 100644 index 000000000..4313c55b5 --- /dev/null +++ b/tests/fixtures/org_freedesktop_systemd1_Manager.json @@ -0,0 +1,127 @@ +{ + "Version": "249", + "Features": "+PAM -AUDIT -SELINUX +APPARMOR -IMA -SMACK -SECCOMP +GCRYPT +GNUTLS +OPENSSL -ACL +BLKID +CURL -ELFUTILS -FIDO2 -IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 -PWQUALITY -P11KIT -QRENCODE -BZIP2 -LZ4 -XZ +ZLIB -ZSTD -XKBCOMMON -UTMP -SYSVINIT default-hierarchy=hybrid", + "Virtualization": "", + "Architecture": "arm64", + "Tainted": "cgroupsv1", + "FirmwareTimestamp": 0, + "FirmwareTimestampMonotonic": 0, + "LoaderTimestamp": 0, + "LoaderTimestampMonotonic": 0, + "KernelTimestamp": 1646197924245019, + "KernelTimestampMonotonic": 0, + "InitRDTimestamp": 0, + "InitRDTimestampMonotonic": 0, + "UserspaceTimestamp": 1646197926126937, + "UserspaceTimestampMonotonic": 1881921, + "FinishTimestamp": 1646197962613554, + "FinishTimestampMonotonic": 38368540, + "SecurityStartTimestamp": 1646197926137295, + "SecurityStartTimestampMonotonic": 1892280, + "SecurityFinishTimestamp": 1646197926139253, + "SecurityFinishTimestampMonotonic": 1894237, + "GeneratorsStartTimestamp": 1646197926235939, + "GeneratorsStartTimestampMonotonic": 1990923, + "GeneratorsFinishTimestamp": 1646197926260378, + "GeneratorsFinishTimestampMonotonic": 2015363, + "UnitsLoadStartTimestamp": 1646197926260388, + "UnitsLoadStartTimestampMonotonic": 2015371, + "UnitsLoadFinishTimestamp": 1646197926339294, + "UnitsLoadFinishTimestampMonotonic": 2094278, + "InitRDSecurityStartTimestamp": 0, + "InitRDSecurityStartTimestampMonotonic": 0, + "InitRDSecurityFinishTimestamp": 0, + "InitRDSecurityFinishTimestampMonotonic": 0, + "InitRDGeneratorsStartTimestamp": 0, + "InitRDGeneratorsStartTimestampMonotonic": 0, + "InitRDGeneratorsFinishTimestamp": 0, + "InitRDGeneratorsFinishTimestampMonotonic": 0, + "InitRDUnitsLoadStartTimestamp": 0, + "InitRDUnitsLoadStartTimestampMonotonic": 0, + "InitRDUnitsLoadFinishTimestamp": 0, + "InitRDUnitsLoadFinishTimestampMonotonic": 0, + "LogLevel": "info", + "LogTarget": "journal - or - kmsg", + "NNames": 377, + "NFailedUnits": 0, + "NJobs": 0, + "NInstalledJobs": 798, + "NFailedJobs": 0, + "Progress": 1.0, + "Environment": [ + "LANG = C.UTF - 8", + "PATH = /usr/local / sbin: /usr/local / bin: /usr/sbin: /usr/bin" + ], + "ConfirmSpawn": false, + "ShowStatus": true, + "UnitPath": [ + " / etc / systemd / system.control", + " / run / systemd / system.control", + " / run / systemd / transient", + " / run / systemd / generator.early", + " / etc / systemd / system", + " / etc / systemd / system.attached", + " / run / systemd / system", + " / run / systemd / system.attached", + " / run / systemd / generator", + " / usr / local / lib / systemd / system", + " / usr / lib / systemd / system", + " / run / systemd / generator.late" + ], + "DefaultStandardOutput": "journal", + "DefaultStandardError": "inherit", + "RuntimeWatchdogUSec": 0, + "RebootWatchdogUSec": 600000000, + "KExecWatchdogUSec": 0, + "ServiceWatchdogs": true, + "ControlGroup": "", + "SystemState": "running", + "ExitCode": [0, 0, 0, 0], + "DefaultTimerAccuracyUSec": 60000000, + "DefaultTimeoutStartUSec": 90000000, + "DefaultTimeoutStopUSec": 90000000, + "DefaultTimeoutAbortUSec": 90000000, + "DefaultRestartUSec": 100000, + "DefaultStartLimitIntervalUSec": 10000000, + "DefaultStartLimitBurst": 5, + "DefaultCPUAccounting": false, + "DefaultBlockIOAccounting": false, + "DefaultMemoryAccounting": true, + "DefaultTasksAccounting": true, + "DefaultLimitCPU": 18446744073709551615, + "DefaultLimitCPUSoft": 18446744073709551615, + "DefaultLimitFSIZE": 18446744073709551615, + "DefaultLimitFSIZESoft": 18446744073709551615, + "DefaultLimitDATA": 18446744073709551615, + "DefaultLimitDATASoft": 18446744073709551615, + "DefaultLimitSTACK": 18446744073709551615, + "DefaultLimitSTACKSoft": 8388608, + "DefaultLimitCORE": 18446744073709551615, + "DefaultLimitCORESoft": 18446744073709551615, + "DefaultLimitRSS": 18446744073709551615, + "DefaultLimitRSSSoft": 18446744073709551615, + "DefaultLimitNOFILE": 524288, + "DefaultLimitNOFILESoft": 1024, + "DefaultLimitAS": 18446744073709551615, + "DefaultLimitASSoft": 18446744073709551615, + "DefaultLimitNPROC": 14236, + "DefaultLimitNPROCSoft": 14236, + "DefaultLimitMEMLOCK": 65536, + "DefaultLimitMEMLOCKSoft": 65536, + "DefaultLimitLOCKS": 18446744073709551615, + "DefaultLimitLOCKSSoft": 18446744073709551615, + "DefaultLimitSIGPENDING": 14236, + "DefaultLimitSIGPENDINGSoft": 14236, + "DefaultLimitMSGQUEUE": 819200, + "DefaultLimitMSGQUEUESoft": 819200, + "DefaultLimitNICE": 0, + "DefaultLimitNICESoft": 0, + "DefaultLimitRTPRIO": 0, + "DefaultLimitRTPRIOSoft": 0, + "DefaultLimitRTTIME": 18446744073709551615, + "DefaultLimitRTTIMESoft": 18446744073709551615, + "DefaultTasksMax": 4270, + "TimerSlackNSec": 50000, + "DefaultOOMPolicy": "stop", + "CtrlAltDelBurstAction": "reboot - force" +} diff --git a/tests/host/test_manager.py b/tests/host/test_manager.py new file mode 100644 index 000000000..adb13bee3 --- /dev/null +++ b/tests/host/test_manager.py @@ -0,0 +1,78 @@ +"""Test host manager.""" +from unittest.mock import AsyncMock, PropertyMock, patch + +from supervisor.coresys import CoreSys +from supervisor.dbus.agent import OSAgent +from supervisor.dbus.hostname import Hostname +from supervisor.dbus.systemd import Systemd +from supervisor.dbus.timedate import TimeDate + + +async def test_reload(coresys: CoreSys): + """Test manager reload.""" + with patch.object(coresys.host.info, "update") as info_update, patch.object( + coresys.host.services, "update" + ) as services_update, patch.object( + coresys.host.network, "update" + ) as network_update, patch.object( + coresys.host.sys_dbus.agent, "update", new=AsyncMock() + ) as agent_update, patch.object( + coresys.host.sound, "update" + ) as sound_update: + + await coresys.host.reload() + + info_update.assert_called_once() + services_update.assert_called_once() + network_update.assert_called_once() + agent_update.assert_called_once() + sound_update.assert_called_once() + + info_update.reset_mock() + services_update.reset_mock() + network_update.reset_mock() + agent_update.reset_mock() + sound_update.reset_mock() + + await coresys.host.reload( + services=False, network=False, agent=False, audio=False + ) + info_update.assert_called_once() + services_update.assert_not_called() + network_update.assert_not_called() + agent_update.assert_not_called() + sound_update.assert_not_called() + + +async def test_load( + coresys: CoreSys, + hostname: Hostname, + systemd: Systemd, + timedate: TimeDate, + os_agent: OSAgent, +): + """Test manager load.""" + type(coresys.dbus).hostname = PropertyMock(return_value=hostname) + type(coresys.dbus).systemd = PropertyMock(return_value=systemd) + type(coresys.dbus).timedate = PropertyMock(return_value=timedate) + type(coresys.dbus).agent = PropertyMock(return_value=os_agent) + + with patch.object(coresys.host.sound, "update") as sound_update, patch.object( + coresys.host.apparmor, "load" + ) as apparmor_load: + # Network is updated on connect for a version check so its not None already + assert coresys.dbus.hostname.hostname is None + assert coresys.dbus.systemd.boot_timestamp is None + assert coresys.dbus.timedate.timezone is None + assert coresys.dbus.agent.diagnostics is None + + await coresys.host.load() + + assert coresys.dbus.hostname.hostname == "homeassistant-n2" + assert coresys.dbus.systemd.boot_timestamp == 1646197962613554 + assert coresys.dbus.timedate.timezone == "Etc/UTC" + assert coresys.dbus.agent.diagnostics is True + assert coresys.dbus.network.connectivity_enabled is True + + sound_update.assert_called_once() + apparmor_load.assert_called_once() diff --git a/tests/host/test_network.py b/tests/host/test_network.py new file mode 100644 index 000000000..33890db5a --- /dev/null +++ b/tests/host/test_network.py @@ -0,0 +1,31 @@ +"""Test network manager.""" +from unittest.mock import Mock, patch + +from supervisor.coresys import CoreSys + + +async def test_load(coresys: CoreSys): + """Test network manager load.""" + with patch.object( + coresys.host.sys_dbus.network, + "activate_connection", + new=Mock(wraps=coresys.host.sys_dbus.network.activate_connection), + ) as activate_connection: + await coresys.host.network.load() + + assert coresys.host.network.connectivity is True + + assert len(coresys.host.network.dns_servers) == 1 + assert str(coresys.host.network.dns_servers[0]) == "192.168.30.1" + + assert len(coresys.host.network.interfaces) == 2 + assert coresys.host.network.interfaces[0].name == "eth0" + assert coresys.host.network.interfaces[0].enabled is True + assert coresys.host.network.interfaces[1].name == "wlan0" + assert coresys.host.network.interfaces[1].enabled is False + + assert activate_connection.call_count == 1 + assert activate_connection.call_args.args == ( + "/org/freedesktop/NetworkManager/Settings/1", + "/org/freedesktop/NetworkManager/Devices/1", + )