mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-13 20:26:29 +00:00
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:
parent
9222a3c9c0
commit
0a684bdb12
@ -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),
|
||||
|
@ -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],
|
||||
)
|
||||
|
@ -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"
|
||||
|
@ -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."""
|
||||
|
40
supervisor/dbus/agent/swap.py
Normal file
40
supervisor/dbus/agent/swap.py
Normal 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)
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
49
tests/dbus/agent/test_swap.py
Normal file
49
tests/dbus/agent/test_swap.py
Normal 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
|
42
tests/dbus_service_mocks/agent_swap.py
Normal file
42
tests/dbus_service_mocks/agent_swap.py
Normal 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})
|
Loading…
x
Reference in New Issue
Block a user