diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 24db1d962..b04c2ac56 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -237,6 +237,8 @@ class RestAPI(CoreSysAttributes): [ web.get("/os/info", api_os.info), web.post("/os/update", api_os.update), + web.get("/os/config/swap", api_os.config_swap_info), + web.post("/os/config/swap", api_os.config_swap_options), web.post("/os/config/sync", api_os.config_sync), web.post("/os/datadisk/move", api_os.migrate_data), web.get("/os/datadisk/list", api_os.list_data), diff --git a/supervisor/api/os.py b/supervisor/api/os.py index ab6b79f52..b565d3c31 100644 --- a/supervisor/api/os.py +++ b/supervisor/api/os.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Awaitable import logging +import re from typing import Any from aiohttp import web @@ -21,12 +22,14 @@ from ..const import ( ATTR_SERIAL, ATTR_SIZE, ATTR_STATE, + ATTR_SWAP_SIZE, + ATTR_SWAPPINESS, ATTR_UPDATE_AVAILABLE, ATTR_VERSION, ATTR_VERSION_LATEST, ) from ..coresys import CoreSysAttributes -from ..exceptions import BoardInvalidError +from ..exceptions import APINotFound, BoardInvalidError from ..resolution.const import ContextType, IssueType, SuggestionType from ..validate import version_tag from .const import ( @@ -65,6 +68,15 @@ SCHEMA_GREEN_OPTIONS = vol.Schema( vol.Optional(ATTR_SYSTEM_HEALTH_LED): vol.Boolean(), } ) + +RE_SWAP_SIZE = re.compile(r"^\d+([KMG](i?B)?|B)?$", re.IGNORECASE) + +SCHEMA_SWAP_OPTIONS = vol.Schema( + { + vol.Optional(ATTR_SWAP_SIZE): vol.Match(RE_SWAP_SIZE), + vol.Optional(ATTR_SWAPPINESS): vol.All(int, vol.Range(min=0, max=200)), + } +) # pylint: enable=no-value-for-parameter @@ -212,3 +224,45 @@ class APIOS(CoreSysAttributes): ) return {} + + @api_process + async def config_swap_info(self, request: web.Request) -> dict[str, Any]: + """Get swap settings.""" + if not self.coresys.os.available or self.coresys.os.version < "15.0": + raise APINotFound( + "Home Assistant OS 15.0 or newer required for swap settings" + ) + + return { + ATTR_SWAP_SIZE: self.sys_dbus.agent.swap.swap_size, + ATTR_SWAPPINESS: self.sys_dbus.agent.swap.swappiness, + } + + @api_process + async def config_swap_options(self, request: web.Request) -> None: + """Update swap settings.""" + if not self.coresys.os.available or self.coresys.os.version < "15.0": + raise APINotFound( + "Home Assistant OS 15.0 or newer required for swap settings" + ) + + body = await api_validate(SCHEMA_SWAP_OPTIONS, request) + + reboot_required = False + + if ATTR_SWAP_SIZE in body: + old_size = self.sys_dbus.agent.swap.swap_size + await self.sys_dbus.agent.swap.set_swap_size(body[ATTR_SWAP_SIZE]) + reboot_required = reboot_required or old_size != body[ATTR_SWAP_SIZE] + + if ATTR_SWAPPINESS in body: + old_swappiness = self.sys_dbus.agent.swap.swappiness + await self.sys_dbus.agent.swap.set_swappiness(body[ATTR_SWAPPINESS]) + reboot_required = reboot_required or old_swappiness != body[ATTR_SWAPPINESS] + + if reboot_required: + self.sys_resolution.create_issue( + IssueType.REBOOT_REQUIRED, + ContextType.SYSTEM, + suggestions=[SuggestionType.EXECUTE_REBOOT], + ) diff --git a/supervisor/const.py b/supervisor/const.py index dded9c751..4add13ecb 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -314,6 +314,8 @@ ATTR_SUPERVISOR_INTERNET = "supervisor_internet" ATTR_SUPERVISOR_VERSION = "supervisor_version" ATTR_SUPPORTED = "supported" ATTR_SUPPORTED_ARCH = "supported_arch" +ATTR_SWAP_SIZE = "swap_size" +ATTR_SWAPPINESS = "swappiness" ATTR_SYSTEM = "system" ATTR_SYSTEM_MANAGED = "system_managed" ATTR_SYSTEM_MANAGED_CONFIG_ENTRY = "system_managed_config_entry" diff --git a/supervisor/dbus/agent/__init__.py b/supervisor/dbus/agent/__init__.py index 21c14afe5..54a5f406e 100644 --- a/supervisor/dbus/agent/__init__.py +++ b/supervisor/dbus/agent/__init__.py @@ -22,6 +22,7 @@ from .apparmor import AppArmor from .boards import BoardManager from .cgroup import CGroup from .datadisk import DataDisk +from .swap import Swap from .system import System _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -43,6 +44,7 @@ class OSAgent(DBusInterfaceProxy): self._board: BoardManager = BoardManager() self._cgroup: CGroup = CGroup() self._datadisk: DataDisk = DataDisk() + self._swap: Swap = Swap() self._system: System = System() @property @@ -55,6 +57,11 @@ class OSAgent(DBusInterfaceProxy): """Return AppArmor DBUS object.""" return self._apparmor + @property + def swap(self) -> Swap: + """Return Swap DBUS object.""" + return self._swap + @property def system(self) -> System: """Return System DBUS object.""" @@ -89,7 +96,14 @@ class OSAgent(DBusInterfaceProxy): @property def all(self) -> list[DBusInterface]: """Return all managed dbus interfaces.""" - return [self.apparmor, self.board, self.cgroup, self.datadisk, self.system] + return [ + self.apparmor, + self.board, + self.cgroup, + self.datadisk, + self.swap, + self.system, + ] async def connect(self, bus: MessageBus) -> None: """Connect to system's D-Bus.""" diff --git a/supervisor/dbus/agent/swap.py b/supervisor/dbus/agent/swap.py new file mode 100644 index 000000000..faacd95f2 --- /dev/null +++ b/supervisor/dbus/agent/swap.py @@ -0,0 +1,40 @@ +"""Swap object for OS Agent.""" + +from collections.abc import Awaitable + +from ..const import ( + DBUS_ATTR_SWAP_SIZE, + DBUS_ATTR_SWAPPINESS, + DBUS_IFACE_HAOS_CONFIG_SWAP, + DBUS_NAME_HAOS, + DBUS_OBJECT_HAOS_CONFIG_SWAP, +) +from ..interface import DBusInterfaceProxy, dbus_property + + +class Swap(DBusInterfaceProxy): + """Swap object for OS Agent.""" + + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS_CONFIG_SWAP + properties_interface: str = DBUS_IFACE_HAOS_CONFIG_SWAP + + @property + @dbus_property + def swap_size(self) -> str: + """Get swap size.""" + return self.properties[DBUS_ATTR_SWAP_SIZE] + + def set_swap_size(self, size: str) -> Awaitable[None]: + """Set swap size.""" + return self.dbus.Config.Swap.set_swap_size(size) + + @property + @dbus_property + def swappiness(self) -> int: + """Get swappiness.""" + return self.properties[DBUS_ATTR_SWAPPINESS] + + def set_swappiness(self, swappiness: int) -> Awaitable[None]: + """Set swappiness.""" + return self.dbus.Config.Swap.set_swappiness(swappiness) diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index 82c75d8f3..296f516b8 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -25,6 +25,7 @@ DBUS_IFACE_HAOS = "io.hass.os" DBUS_IFACE_HAOS_APPARMOR = "io.hass.os.AppArmor" DBUS_IFACE_HAOS_BOARDS = "io.hass.os.Boards" DBUS_IFACE_HAOS_CGROUP = "io.hass.os.CGroup" +DBUS_IFACE_HAOS_CONFIG_SWAP = "io.hass.os.Config.Swap" DBUS_IFACE_HAOS_DATADISK = "io.hass.os.DataDisk" DBUS_IFACE_HAOS_SYSTEM = "io.hass.os.System" DBUS_IFACE_HOSTNAME = "org.freedesktop.hostname1" @@ -53,6 +54,7 @@ DBUS_OBJECT_HAOS = "/io/hass/os" DBUS_OBJECT_HAOS_APPARMOR = "/io/hass/os/AppArmor" DBUS_OBJECT_HAOS_BOARDS = "/io/hass/os/Boards" DBUS_OBJECT_HAOS_CGROUP = "/io/hass/os/CGroup" +DBUS_OBJECT_HAOS_CONFIG_SWAP = "/io/hass/os/Config/Swap" DBUS_OBJECT_HAOS_DATADISK = "/io/hass/os/DataDisk" DBUS_OBJECT_HAOS_SYSTEM = "/io/hass/os/System" DBUS_OBJECT_HOSTNAME = "/org/freedesktop/hostname1" @@ -169,6 +171,8 @@ DBUS_ATTR_STATIC_OPERATING_SYSTEM_CPE_NAME = "OperatingSystemCPEName" DBUS_ATTR_STRENGTH = "Strength" DBUS_ATTR_SUPPORTED_FILESYSTEMS = "SupportedFilesystems" DBUS_ATTR_SYMLINKS = "Symlinks" +DBUS_ATTR_SWAP_SIZE = "SwapSize" +DBUS_ATTR_SWAPPINESS = "Swappiness" DBUS_ATTR_TABLE = "Table" DBUS_ATTR_TIME_DETECTED = "TimeDetected" DBUS_ATTR_TIMEUSEC = "TimeUSec" diff --git a/tests/api/test_os.py b/tests/api/test_os.py index cb5ba4ea3..0ec1af0d2 100644 --- a/tests/api/test_os.py +++ b/tests/api/test_os.py @@ -18,6 +18,7 @@ from tests.dbus_service_mocks.agent_boards import Boards as BoardsService from tests.dbus_service_mocks.agent_boards_green import Green as GreenService from tests.dbus_service_mocks.agent_boards_yellow import Yellow as YellowService from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService +from tests.dbus_service_mocks.agent_swap import Swap as SwapService from tests.dbus_service_mocks.agent_system import System as SystemService from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.rauc import Rauc as RaucService @@ -337,3 +338,172 @@ async def test_api_board_other_info( assert (await api_client.post("/os/boards/not-real", json={})).status == 405 assert (await api_client.get("/os/boards/yellow")).status == 400 assert (await api_client.get("/os/boards/supervised")).status == 400 + + +@pytest.mark.parametrize("os_available", ["15.0"], indirect=True) +async def test_api_config_swap_info( + api_client: TestClient, coresys: CoreSys, os_available +): + """Test swap info.""" + await coresys.dbus.agent.swap.connect(coresys.dbus.bus) + + resp = await api_client.get("/os/config/swap") + + assert resp.status == 200 + result = await resp.json() + assert result["data"]["swap_size"] == "1M" + assert result["data"]["swappiness"] == 1 + + +@pytest.mark.parametrize("os_available", ["15.0"], indirect=True) +async def test_api_config_swap_options( + api_client: TestClient, + coresys: CoreSys, + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test swap setting.""" + swap_service: SwapService = os_agent_services["agent_swap"] + await coresys.dbus.agent.swap.connect(coresys.dbus.bus) + + assert coresys.dbus.agent.swap.swap_size == "1M" + assert coresys.dbus.agent.swap.swappiness == 1 + + resp = await api_client.post( + "/os/config/swap", + json={ + "swap_size": "2M", + "swappiness": 10, + }, + ) + assert resp.status == 200 + + await swap_service.ping() + + assert coresys.dbus.agent.swap.swap_size == "2M" + assert coresys.dbus.agent.swap.swappiness == 10 + + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + in coresys.resolution.issues + ) + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + in coresys.resolution.suggestions + ) + + # test setting only the swap size + resp = await api_client.post( + "/os/config/swap", + json={ + "swap_size": "10M", + }, + ) + assert resp.status == 200 + + await swap_service.ping() + + assert coresys.dbus.agent.swap.swap_size == "10M" + assert coresys.dbus.agent.swap.swappiness == 10 + + # test setting only the swappiness + resp = await api_client.post( + "/os/config/swap", + json={ + "swappiness": 100, + }, + ) + assert resp.status == 200 + + await swap_service.ping() + + assert coresys.dbus.agent.swap.swap_size == "10M" + assert coresys.dbus.agent.swap.swappiness == 100 + + +@pytest.mark.parametrize("os_available", ["15.0"], indirect=True) +async def test_api_config_swap_options_no_reboot( + api_client: TestClient, + coresys: CoreSys, + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test no resolution is shown when setting are submitted empty or unchanged.""" + await coresys.dbus.agent.swap.connect(coresys.dbus.bus) + + # empty options + resp = await api_client.post( + "/os/config/swap", + json={}, + ) + assert resp.status == 200 + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + not in coresys.resolution.issues + ) + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + not in coresys.resolution.suggestions + ) + + # no change + resp = await api_client.post( + "/os/config/swap", + json={ + "swappiness": coresys.dbus.agent.swap.swappiness, + "swap_size": coresys.dbus.agent.swap.swap_size, + }, + ) + assert resp.status == 200 + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + not in coresys.resolution.issues + ) + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + not in coresys.resolution.suggestions + ) + + +async def test_api_config_swap_not_os( + api_client: TestClient, + coresys: CoreSys, + os_agent_services: dict[str, DBusServiceMock], +): + """Test 404 is returned for swap endpoints if not running on HAOS.""" + await coresys.dbus.agent.swap.connect(coresys.dbus.bus) + + resp = await api_client.get("/os/config/swap") + assert resp.status == 404 + + resp = await api_client.post( + "/os/config/swap", + json={ + "swap_size": "2M", + "swappiness": 10, + }, + ) + assert resp.status == 404 + + +@pytest.mark.parametrize("os_available", ["14.2"], indirect=True) +async def test_api_config_swap_old_os( + api_client: TestClient, + coresys: CoreSys, + os_agent_services: dict[str, DBusServiceMock], + os_available, +): + """Test 404 is returned for swap endpoints if OS is older than 15.0.""" + await coresys.dbus.agent.swap.connect(coresys.dbus.bus) + + resp = await api_client.get("/os/config/swap") + assert resp.status == 404 + + resp = await api_client.post( + "/os/config/swap", + json={ + "swap_size": "2M", + "swappiness": 10, + }, + ) + assert resp.status == 404 diff --git a/tests/conftest.py b/tests/conftest.py index d90739aa5..3f9a13010 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -283,6 +283,7 @@ async def fixture_os_agent_services( "agent_apparmor": None, "agent_cgroup": None, "agent_datadisk": None, + "agent_swap": None, "agent_system": None, "agent_boards": None, "agent_boards_yellow": None, diff --git a/tests/dbus/agent/test_agent.py b/tests/dbus/agent/test_agent.py index 5298ff146..15ba060f4 100644 --- a/tests/dbus/agent/test_agent.py +++ b/tests/dbus/agent/test_agent.py @@ -69,6 +69,7 @@ async def test_dbus_osagent_connect_error( "agent_apparmor": None, "agent_cgroup": None, "agent_datadisk": None, + "agent_swap": None, "agent_system": None, "agent_boards": None, "agent_boards_yellow": None, diff --git a/tests/dbus/agent/test_swap.py b/tests/dbus/agent/test_swap.py new file mode 100644 index 000000000..d4ad60661 --- /dev/null +++ b/tests/dbus/agent/test_swap.py @@ -0,0 +1,49 @@ +"""Test Swap configuration interface.""" + +from dbus_fast.aio.message_bus import MessageBus +import pytest + +from supervisor.dbus.agent import OSAgent + +from tests.dbus_service_mocks.agent_swap import Swap as SwapService +from tests.dbus_service_mocks.base import DBusServiceMock + + +@pytest.fixture(name="swap_service", autouse=True) +async def fixture_swap_service( + os_agent_services: dict[str, DBusServiceMock], +) -> SwapService: + """Mock System dbus service.""" + yield os_agent_services["agent_swap"] + + +async def test_dbus_osagent_swap_size( + swap_service: SwapService, dbus_session_bus: MessageBus +): + """Test DBus API for swap size.""" + os_agent = OSAgent() + + assert os_agent.swap.swap_size is None + await os_agent.swap.connect(dbus_session_bus) + + assert os_agent.swap.swap_size == "1M" + + swap_service.emit_properties_changed({"SwapSize": "2M"}) + await swap_service.ping() + assert os_agent.swap.swap_size == "2M" + + +async def test_dbus_osagent_swappiness( + swap_service: SwapService, dbus_session_bus: MessageBus +): + """Test DBus API for swappiness.""" + os_agent = OSAgent() + + assert os_agent.swap.swappiness is None + await os_agent.swap.connect(dbus_session_bus) + + assert os_agent.swap.swappiness == 1 + + swap_service.emit_properties_changed({"Swappiness": 10}) + await swap_service.ping() + assert os_agent.swap.swappiness == 10 diff --git a/tests/dbus_service_mocks/agent_swap.py b/tests/dbus_service_mocks/agent_swap.py new file mode 100644 index 000000000..72b3add69 --- /dev/null +++ b/tests/dbus_service_mocks/agent_swap.py @@ -0,0 +1,42 @@ +"""Mock of OS Agent Swap dbus service.""" + +from dbus_fast.service import dbus_property + +from .base import DBusServiceMock + +BUS_NAME = "io.hass.os" + + +def setup(object_path: str | None = None) -> DBusServiceMock: + """Create dbus mock object.""" + return Swap() + + +class Swap(DBusServiceMock): + """Swap mock. + + gdbus introspect --system --dest io.hass.os --object-path /io/hass/os/Config/Swap + """ + + object_path = "/io/hass/os/Config/Swap" + interface = "io.hass.os.Config.Swap" + + @dbus_property() + def SwapSize(self) -> "s": + """Get swap size.""" + return "1M" + + @SwapSize.setter + def SwapSize(self, value: "s"): + """Set swap size.""" + self.emit_properties_changed({"SwapSize": value}) + + @dbus_property() + def Swappiness(self) -> "i": + """Get swappiness.""" + return 1 + + @Swappiness.setter + def Swappiness(self, value: "i"): + """Set swappiness.""" + self.emit_properties_changed({"Swappiness": value})