Add API for swap configuration (#5770)

* Add API for swap configuration

Add HTTP API for swap size and swappiness to /os/config/swap. Individual
options can be set in JSON and are calling the DBus API added in OS
Agent 1.7.x, available since OS 15.0. Check for presence of OS of the
required version and return 404 if the criteria are not met.

* Fix type hints and reboot_required logic

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix formatting after adding suggestions from GH

* Address @mdegat01 review comments

- Improve swap options validation
- Add swap to the 'all' property of dbus agent
- Use APINotFound with reason instead of HTTPNotFound
- Reorder API routes

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Jan Čermák 2025-03-27 17:53:46 +01:00 committed by GitHub
parent 9222a3c9c0
commit 0a684bdb12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 381 additions and 2 deletions

View File

@ -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),

View File

@ -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],
)

View File

@ -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"

View File

@ -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."""

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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})