diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 80fff328a..437411b3b 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -66,6 +66,7 @@ ATTR_USAGE = "usage" ATTR_USE_NTP = "use_ntp" ATTR_USERS = "users" ATTR_VENDOR = "vendor" +ATTR_VIRTUALIZATION = "virtualization" class BootSlot(StrEnum): diff --git a/supervisor/api/host.py b/supervisor/api/host.py index 281f1cfa3..0e8ca9204 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -49,6 +49,7 @@ from .const import ( ATTR_LLMNR_HOSTNAME, ATTR_STARTUP_TIME, ATTR_USE_NTP, + ATTR_VIRTUALIZATION, CONTENT_TYPE_TEXT, CONTENT_TYPE_X_LOG, ) @@ -73,6 +74,7 @@ class APIHost(CoreSysAttributes): ATTR_AGENT_VERSION: self.sys_dbus.agent.version, ATTR_APPARMOR_VERSION: self.sys_host.apparmor.version, ATTR_CHASSIS: self.sys_host.info.chassis, + ATTR_VIRTUALIZATION: self.sys_host.info.virtualization, ATTR_CPE: self.sys_host.info.cpe, ATTR_DEPLOYMENT: self.sys_host.info.deployment, ATTR_DISK_FREE: self.sys_host.info.free_space, diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index cd8dc97d2..78426c153 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -180,6 +180,7 @@ DBUS_ATTR_UUID = "Uuid" DBUS_ATTR_VARIANT = "Variant" DBUS_ATTR_VENDOR = "Vendor" DBUS_ATTR_VERSION = "Version" +DBUS_ATTR_VIRTUALIZATION = "Virtualization" DBUS_ATTR_WHAT = "What" DBUS_ATTR_WWN = "WWN" diff --git a/supervisor/dbus/systemd.py b/supervisor/dbus/systemd.py index e5abf6dd5..1ff2ed1bc 100644 --- a/supervisor/dbus/systemd.py +++ b/supervisor/dbus/systemd.py @@ -20,6 +20,7 @@ from .const import ( DBUS_ATTR_KERNEL_TIMESTAMP_MONOTONIC, DBUS_ATTR_LOADER_TIMESTAMP_MONOTONIC, DBUS_ATTR_USERSPACE_TIMESTAMP_MONOTONIC, + DBUS_ATTR_VIRTUALIZATION, DBUS_ERR_SYSTEMD_NO_SUCH_UNIT, DBUS_IFACE_SYSTEMD_MANAGER, DBUS_NAME_SYSTEMD, @@ -114,6 +115,12 @@ class Systemd(DBusInterfaceProxy): """Return the boot timestamp.""" return self.properties[DBUS_ATTR_FINISH_TIMESTAMP] + @property + @dbus_property + def virtualization(self) -> str: + """Return virtualization hypervisor being used.""" + return self.properties[DBUS_ATTR_VIRTUALIZATION] + @dbus_connected async def reboot(self) -> None: """Reboot host computer.""" diff --git a/supervisor/host/info.py b/supervisor/host/info.py index 0a840afdd..837be436f 100644 --- a/supervisor/host/info.py +++ b/supervisor/host/info.py @@ -129,6 +129,11 @@ class InfoCenter(CoreSysAttributes): self.coresys.config.path_supervisor ) + @property + def virtualization(self) -> str | None: + """Return virtualization hypervisor being used.""" + return self.sys_dbus.systemd.virtualization + async def get_dmesg(self) -> bytes: """Return host dmesg output.""" proc = await asyncio.create_subprocess_shell( diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 1a06914c7..6f67ad8bf 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -53,6 +53,7 @@ class UnsupportedReason(StrEnum): SYSTEMD = "systemd" SYSTEMD_JOURNAL = "systemd_journal" SYSTEMD_RESOLVED = "systemd_resolved" + VIRTUALIZATION_IMAGE = "virtualization_image" class UnhealthyReason(StrEnum): diff --git a/supervisor/resolution/evaluations/virtualization_image.py b/supervisor/resolution/evaluations/virtualization_image.py new file mode 100644 index 000000000..62b76de7b --- /dev/null +++ b/supervisor/resolution/evaluations/virtualization_image.py @@ -0,0 +1,39 @@ +"""Evaluation class for virtualization image.""" + +from ...const import CoreState +from ...coresys import CoreSys +from ..const import UnsupportedReason +from .base import EvaluateBase + + +def setup(coresys: CoreSys) -> EvaluateBase: + """Initialize evaluation-setup function.""" + return EvaluateVirtualizationImage(coresys) + + +class EvaluateVirtualizationImage(EvaluateBase): + """Evaluate correct OS image used when running under virtualization.""" + + @property + def reason(self) -> UnsupportedReason: + """Return a UnsupportedReason enum.""" + return UnsupportedReason.VIRTUALIZATION_IMAGE + + @property + def on_failure(self) -> str: + """Return a string that is printed when self.evaluate is True.""" + return "Image of Home Assistant OS in use does not support virtualization." + + @property + def states(self) -> list[CoreState]: + """Return a list of valid states when this evaluation can run.""" + return [CoreState.SETUP] + + async def evaluate(self): + """Run evaluation.""" + if not self.sys_os.available: + return False + return self.sys_host.info.virtualization and self.sys_os.board not in { + "ova", + "generic-aarch64", + } diff --git a/tests/api/test_host.py b/tests/api/test_host.py index 8df7cd91a..eca644cd6 100644 --- a/tests/api/test_host.py +++ b/tests/api/test_host.py @@ -9,6 +9,9 @@ from supervisor.coresys import CoreSys from supervisor.dbus.resolved import Resolved from supervisor.host.const import LogFormat, LogFormatter +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + DEFAULT_RANGE = "entries=:-100:" # pylint: disable=protected-access @@ -147,6 +150,26 @@ async def test_api_identifiers_info(api_client: TestClient, journald_logs: Magic } +async def test_api_virtualization_info( + api_client: TestClient, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + coresys_disk_info: CoreSys, +): + """Test getting virtualization info.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + + resp = await api_client.get("/host/info") + result = await resp.json() + assert result["data"]["virtualization"] == "" + + systemd_service.virtualization = "vmware" + await coresys_disk_info.dbus.systemd.update() + + resp = await api_client.get("/host/info") + result = await resp.json() + assert result["data"]["virtualization"] == "vmware" + + async def test_advanced_logs( api_client: TestClient, coresys: CoreSys, journald_logs: MagicMock ): diff --git a/tests/dbus_service_mocks/systemd.py b/tests/dbus_service_mocks/systemd.py index 1e2062066..7d05d3f15 100644 --- a/tests/dbus_service_mocks/systemd.py +++ b/tests/dbus_service_mocks/systemd.py @@ -28,6 +28,7 @@ class Systemd(DBusServiceMock): reboot_watchdog_usec = 600000000 kexec_watchdog_usec = 0 service_watchdogs = True + virtualization = "" response_get_unit: dict[str, list[str | DBusError]] | list[ str | DBusError ] | str | DBusError = "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" @@ -53,7 +54,7 @@ class Systemd(DBusServiceMock): @dbus_property(access=PropertyAccess.READ) def Virtualization(self) -> "s": """Get Virtualization.""" - return "" + return self.virtualization @dbus_property(access=PropertyAccess.READ) def Architecture(self) -> "s": diff --git a/tests/resolution/evaluation/test_virtualization_image.py b/tests/resolution/evaluation/test_virtualization_image.py new file mode 100644 index 000000000..a6b17a1b2 --- /dev/null +++ b/tests/resolution/evaluation/test_virtualization_image.py @@ -0,0 +1,89 @@ +"""Test evaluation base.""" + +from unittest.mock import patch + +import pytest + +from supervisor.const import CoreState +from supervisor.coresys import CoreSys +from supervisor.resolution.evaluations.virtualization_image import ( + EvaluateVirtualizationImage, +) + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + + +async def test_evaluation( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +): + """Test evaluation.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + virtualization = EvaluateVirtualizationImage(coresys) + coresys.core.state = CoreState.SETUP + + with patch( + "supervisor.os.manager.CPE.get_target_hardware", return_value=["generic-x86-64"] + ): + systemd_service.virtualization = "vmware" + await coresys.dbus.systemd.update() + + assert not coresys.os.available + await virtualization() + assert virtualization.reason not in coresys.resolution.unsupported + + await coresys.os.load() + assert coresys.os.available + await virtualization() + assert virtualization.reason in coresys.resolution.unsupported + + systemd_service.virtualization = "" + await coresys.dbus.systemd.update() + await virtualization() + assert virtualization.reason not in coresys.resolution.unsupported + + +@pytest.mark.parametrize("board", ["ova", "generic-aarch64"]) +async def test_evaluation_supported_images( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + board: str, +): + """Test supported images for virtualization do not trigger unsupported.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + virtualization = EvaluateVirtualizationImage(coresys) + coresys.core.state = CoreState.SETUP + + with patch("supervisor.os.manager.CPE.get_target_hardware", return_value=[board]): + systemd_service.virtualization = "vmware" + await coresys.dbus.systemd.update() + await coresys.os.load() + + await virtualization() + assert virtualization.reason not in coresys.resolution.unsupported + + +async def test_did_run(coresys: CoreSys): + """Test that the evaluation ran as expected.""" + virtualization = EvaluateVirtualizationImage(coresys) + should_run = virtualization.states + should_not_run = [state for state in CoreState if state not in should_run] + assert len(should_run) != 0 + assert len(should_not_run) != 0 + + with patch( + "supervisor.resolution.evaluations.virtualization_image.EvaluateVirtualizationImage.evaluate", + return_value=None, + ) as evaluate: + for state in should_run: + coresys.core.state = state + await virtualization() + evaluate.assert_called_once() + evaluate.reset_mock() + + for state in should_not_run: + coresys.core.state = state + await virtualization() + evaluate.assert_not_called() + evaluate.reset_mock()