From 672b220f694bc99ccc837b152555441617543f95 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Fri, 4 Nov 2022 03:22:24 -0400 Subject: [PATCH] Add boards APIs (#3984) * Add boards APIs * Move boards to agent --- supervisor/api/__init__.py | 29 ++++++ supervisor/api/const.py | 3 + supervisor/api/os.py | 69 ++++++++++++- supervisor/const.py | 1 + supervisor/dbus/agent/__init__.py | 43 +++++---- supervisor/dbus/agent/boards/__init__.py | 68 +++++++++++++ supervisor/dbus/agent/boards/const.py | 4 + supervisor/dbus/agent/boards/interface.py | 24 +++++ supervisor/dbus/agent/boards/supervised.py | 13 +++ supervisor/dbus/agent/boards/yellow.py | 49 ++++++++++ supervisor/dbus/const.py | 6 ++ supervisor/dbus/manager.py | 6 +- supervisor/exceptions.py | 7 ++ supervisor/resolution/const.py | 12 ++- .../fixups/system_execute_reboot.py | 38 ++++++++ tests/api/test_os.py | 96 ++++++++++++++++++- tests/dbus/agent/boards/__init__.py | 1 + tests/dbus/agent/boards/test_board.py | 78 +++++++++++++++ tests/dbus/agent/boards/test_yellow.py | 54 +++++++++++ tests/dbus/agent/test_apparmor.py | 2 +- tests/fixtures/io_hass_os_Boards.json | 3 + tests/fixtures/io_hass_os_Boards.xml | 35 +++++++ .../io_hass_os_Boards_Supervised.json | 1 + .../fixtures/io_hass_os_Boards_Supervised.xml | 31 ++++++ tests/fixtures/io_hass_os_Boards_Yellow.json | 5 + tests/fixtures/io_hass_os_Boards_Yellow.xml | 41 ++++++++ .../fixup/test_system_execute_reboot.py | 33 +++++++ 27 files changed, 720 insertions(+), 32 deletions(-) create mode 100644 supervisor/dbus/agent/boards/__init__.py create mode 100644 supervisor/dbus/agent/boards/const.py create mode 100644 supervisor/dbus/agent/boards/interface.py create mode 100644 supervisor/dbus/agent/boards/supervised.py create mode 100644 supervisor/dbus/agent/boards/yellow.py create mode 100644 supervisor/resolution/fixups/system_execute_reboot.py create mode 100644 tests/dbus/agent/boards/__init__.py create mode 100644 tests/dbus/agent/boards/test_board.py create mode 100644 tests/dbus/agent/boards/test_yellow.py create mode 100644 tests/fixtures/io_hass_os_Boards.json create mode 100644 tests/fixtures/io_hass_os_Boards.xml create mode 100644 tests/fixtures/io_hass_os_Boards_Supervised.json create mode 100644 tests/fixtures/io_hass_os_Boards_Supervised.xml create mode 100644 tests/fixtures/io_hass_os_Boards_Yellow.json create mode 100644 tests/fixtures/io_hass_os_Boards_Yellow.xml create mode 100644 tests/resolution/fixup/test_system_execute_reboot.py diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 8fbf57d56..fd5d7f29c 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -8,6 +8,7 @@ from aiohttp import web from ..const import AddonState from ..coresys import CoreSys, CoreSysAttributes +from ..dbus.agent.boards.const import BOARD_NAME_SUPERVISED, BOARD_NAME_YELLOW from ..exceptions import APIAddonNotInstalled from .addons import APIAddons from .audio import APIAudio @@ -179,6 +180,34 @@ class RestAPI(CoreSysAttributes): ] ) + # Boards endpoints + def get_board_routes( + board: str, + info_handler, + options_handler=None, + ) -> list[web.RouteDef]: + routes = [ + web.get(f"/os/boards/{board}", info_handler), + web.get(f"/os/boards/{board.lower()}", info_handler), + ] + if options_handler: + routes.insert(1, web.post(f"/os/boards/{board}", options_handler)) + routes.append(web.post(f"/os/boards/{board.lower()}", options_handler)) + + return routes + + self.webapp.add_routes( + [ + *get_board_routes( + BOARD_NAME_YELLOW, + api_os.boards_yellow_info, + api_os.boards_yellow_options, + ), + *get_board_routes(BOARD_NAME_SUPERVISED, api_os.boards_supervised_info), + web.get("/os/boards/{board}", api_os.boards_other_info), + ] + ) + def _register_security(self) -> None: """Register Security functions.""" api_security = APISecurity() diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 01cee8f85..c2efcfd26 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -21,14 +21,17 @@ ATTR_BROADCAST_LLMNR = "broadcast_llmnr" ATTR_BROADCAST_MDNS = "broadcast_mdns" ATTR_DATA_DISK = "data_disk" ATTR_DEVICE = "device" +ATTR_DISK_LED = "disk_led" ATTR_DT_SYNCHRONIZED = "dt_synchronized" ATTR_DT_UTC = "dt_utc" ATTR_FALLBACK = "fallback" +ATTR_HEARTBEAT_LED = "heartbeat_led" ATTR_IDENTIFIERS = "identifiers" ATTR_LLMNR = "llmnr" ATTR_LLMNR_HOSTNAME = "llmnr_hostname" ATTR_MDNS = "mdns" ATTR_PANEL_PATH = "panel_path" +ATTR_POWER_LED = "power_led" ATTR_SIGNED = "signed" ATTR_STARTUP_TIME = "startup_time" ATTR_UPDATE_TYPE = "update_type" diff --git a/supervisor/api/os.py b/supervisor/api/os.py index e5fdf8788..3ec5809db 100644 --- a/supervisor/api/os.py +++ b/supervisor/api/os.py @@ -10,14 +10,23 @@ import voluptuous as vol from ..const import ( ATTR_BOARD, ATTR_BOOT, + ATTR_CPE_BOARD, ATTR_DEVICES, ATTR_UPDATE_AVAILABLE, ATTR_VERSION, ATTR_VERSION_LATEST, ) from ..coresys import CoreSysAttributes +from ..exceptions import BoardInvalidError +from ..resolution.const import ContextType, IssueType, SuggestionType from ..validate import version_tag -from .const import ATTR_DATA_DISK, ATTR_DEVICE +from .const import ( + ATTR_DATA_DISK, + ATTR_DEVICE, + ATTR_DISK_LED, + ATTR_HEARTBEAT_LED, + ATTR_POWER_LED, +) from .utils import api_process, api_validate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -25,6 +34,15 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): version_tag}) SCHEMA_DISK = vol.Schema({vol.Required(ATTR_DEVICE): vol.All(str, vol.Coerce(Path))}) +# pylint: disable=no-value-for-parameter +SCHEMA_YELLOW_OPTIONS = vol.Schema( + { + vol.Optional(ATTR_DISK_LED): vol.Boolean(), + vol.Optional(ATTR_HEARTBEAT_LED): vol.Boolean(), + vol.Optional(ATTR_POWER_LED): vol.Boolean(), + } +) + class APIOS(CoreSysAttributes): """Handle RESTful API for OS functions.""" @@ -36,9 +54,10 @@ class APIOS(CoreSysAttributes): ATTR_VERSION: self.sys_os.version, ATTR_VERSION_LATEST: self.sys_os.latest_version, ATTR_UPDATE_AVAILABLE: self.sys_os.need_update, - ATTR_BOARD: self.sys_os.board, + ATTR_BOARD: self.sys_dbus.agent.board.board, ATTR_BOOT: self.sys_dbus.rauc.boot_slot, ATTR_DATA_DISK: self.sys_os.datadisk.disk_used, + ATTR_CPE_BOARD: self.sys_os.board, } @api_process @@ -67,3 +86,49 @@ class APIOS(CoreSysAttributes): return { ATTR_DEVICES: self.sys_os.datadisk.available_disks, } + + @api_process + async def boards_yellow_info(self, request: web.Request) -> dict[str, Any]: + """Get yellow board settings.""" + return { + ATTR_DISK_LED: self.sys_dbus.agent.board.yellow.disk_led, + ATTR_HEARTBEAT_LED: self.sys_dbus.agent.board.yellow.heartbeat_led, + ATTR_POWER_LED: self.sys_dbus.agent.board.yellow.power_led, + } + + @api_process + async def boards_yellow_options(self, request: web.Request) -> None: + """Update yellow board settings.""" + body = await api_validate(SCHEMA_YELLOW_OPTIONS, request) + + if ATTR_DISK_LED in body: + self.sys_dbus.agent.board.yellow.disk_led = body[ATTR_DISK_LED] + + if ATTR_HEARTBEAT_LED in body: + self.sys_dbus.agent.board.yellow.heartbeat_led = body[ATTR_HEARTBEAT_LED] + + if ATTR_POWER_LED in body: + self.sys_dbus.agent.board.yellow.power_led = body[ATTR_POWER_LED] + + self.sys_resolution.create_issue( + IssueType.REBOOT_REQUIRED, + ContextType.SYSTEM, + suggestions=[SuggestionType.EXECUTE_REBOOT], + ) + + @api_process + async def boards_supervised_info(self, request: web.Request) -> dict[str, Any]: + """Get supervised board settings.""" + # There are none currently, this rasises an error if a different board is in use + if self.sys_dbus.agent.board.supervised: + return {} + + @api_process + async def boards_other_info(self, request: web.Request) -> dict[str, Any]: + """Empty success return if board is in use, error otherwise.""" + if request.match_info["board"] != self.sys_dbus.agent.board.board: + raise BoardInvalidError( + f"{request.match_info['board']} board is not in use", _LOGGER.error + ) + + return {} diff --git a/supervisor/const.py b/supervisor/const.py index a515f8495..162aba177 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -128,6 +128,7 @@ ATTR_CONTAINERS = "containers" ATTR_CONTENT = "content" ATTR_CONTENT_TRUST = "content_trust" ATTR_CPE = "cpe" +ATTR_CPE_BOARD = "cpe_board" ATTR_CPU_PERCENT = "cpu_percent" ATTR_CRYPTO = "crypto" ATTR_DATA = "data" diff --git a/supervisor/dbus/agent/__init__.py b/supervisor/dbus/agent/__init__.py index 0b326d863..2fd4dbd85 100644 --- a/supervisor/dbus/agent/__init__.py +++ b/supervisor/dbus/agent/__init__.py @@ -14,9 +14,10 @@ from ..const import ( DBUS_NAME_HAOS, DBUS_OBJECT_HAOS, ) -from ..interface import DBusInterfaceProxy, dbus_property +from ..interface import DBusInterface, DBusInterfaceProxy, dbus_property from ..utils import dbus_connected from .apparmor import AppArmor +from .boards import BoardManager from .cgroup import CGroup from .datadisk import DataDisk from .system import System @@ -36,10 +37,11 @@ class OSAgent(DBusInterfaceProxy): """Initialize Properties.""" self.properties: dict[str, Any] = {} - self._cgroup: CGroup = CGroup() self._apparmor: AppArmor = AppArmor() - self._system: System = System() + self._board: BoardManager = BoardManager() + self._cgroup: CGroup = CGroup() self._datadisk: DataDisk = DataDisk() + self._system: System = System() @property def cgroup(self) -> CGroup: @@ -61,6 +63,11 @@ class OSAgent(DBusInterfaceProxy): """Return DataDisk DBUS object.""" return self._datadisk + @property + def board(self) -> BoardManager: + """Return board manager.""" + return self._board + @property @dbus_property def version(self) -> AwesomeVersion: @@ -79,15 +86,17 @@ class OSAgent(DBusInterfaceProxy): """Enable or disable OS-Agent diagnostics.""" asyncio.create_task(self.dbus.set_diagnostics(value)) + @property + def all(self) -> list[DBusInterface]: + """Return all managed dbus interfaces.""" + return [self.apparmor, self.board, self.cgroup, self.datadisk, self.system] + async def connect(self, bus: MessageBus) -> None: """Connect to system's D-Bus.""" _LOGGER.info("Load dbus interface %s", self.name) try: await super().connect(bus) - await self.cgroup.connect(bus) - await self.apparmor.connect(bus) - await self.system.connect(bus) - await self.datadisk.connect(bus) + await asyncio.gather(*[dbus.connect(bus) for dbus in self.all]) except DBusError: _LOGGER.warning("Can't connect to OS-Agent") except DBusInterfaceError: @@ -100,19 +109,21 @@ class OSAgent(DBusInterfaceProxy): """Update Properties.""" await super().update(changed) - if not changed and self.apparmor.is_connected: - await self.apparmor.update() - - if not changed and self.datadisk.is_connected: - await self.datadisk.update() + if not changed: + await asyncio.gather( + *[ + dbus.update() + for dbus in [self.apparmor, self.board, self.datadisk] + if dbus.is_connected + ] + ) def shutdown(self) -> None: """Shutdown the object and disconnect from D-Bus. This method is irreversible. """ - self.cgroup.shutdown() - self.apparmor.shutdown() - self.system.shutdown() - self.datadisk.shutdown() + for dbus in self.all: + dbus.shutdown() + super().shutdown() diff --git a/supervisor/dbus/agent/boards/__init__.py b/supervisor/dbus/agent/boards/__init__.py new file mode 100644 index 000000000..5b6a6cb77 --- /dev/null +++ b/supervisor/dbus/agent/boards/__init__.py @@ -0,0 +1,68 @@ +"""Board management for OS Agent.""" +import logging +from typing import Any + +from dbus_fast.aio.message_bus import MessageBus + +from ....exceptions import BoardInvalidError +from ...const import ( + DBUS_ATTR_BOARD, + DBUS_IFACE_HAOS_BOARDS, + DBUS_NAME_HAOS, + DBUS_OBJECT_HAOS_BOARDS, +) +from ...interface import DBusInterfaceProxy, dbus_property +from .const import BOARD_NAME_SUPERVISED, BOARD_NAME_YELLOW +from .interface import BoardProxy +from .supervised import Supervised +from .yellow import Yellow + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class BoardManager(DBusInterfaceProxy): + """Board manager object.""" + + bus_name: str = DBUS_NAME_HAOS + object_path: str = DBUS_OBJECT_HAOS_BOARDS + properties_interface: str = DBUS_IFACE_HAOS_BOARDS + sync_properties: bool = False + + def __init__(self) -> None: + """Initialize properties.""" + self._board_proxy: BoardProxy | None = None + self.properties: dict[str, Any] = {} + + @property + @dbus_property + def board(self) -> str: + """Get board name.""" + return self.properties[DBUS_ATTR_BOARD] + + @property + def supervised(self) -> Supervised: + """Get Supervised board.""" + if self.board != BOARD_NAME_SUPERVISED: + raise BoardInvalidError("Supervised board is not in use", _LOGGER.error) + + return self._board_proxy + + @property + def yellow(self) -> Yellow: + """Get Yellow board.""" + if self.board != BOARD_NAME_YELLOW: + raise BoardInvalidError("Yellow board is not in use", _LOGGER.error) + + return self._board_proxy + + async def connect(self, bus: MessageBus) -> None: + """Connect to D-Bus.""" + await super().connect(bus) + + if self.board == BOARD_NAME_YELLOW: + self._board_proxy = Yellow() + elif self.board == BOARD_NAME_SUPERVISED: + self._board_proxy = Supervised() + + if self._board_proxy: + await self._board_proxy.connect(bus) diff --git a/supervisor/dbus/agent/boards/const.py b/supervisor/dbus/agent/boards/const.py new file mode 100644 index 000000000..a968b3cc9 --- /dev/null +++ b/supervisor/dbus/agent/boards/const.py @@ -0,0 +1,4 @@ +"""Constants for boards.""" + +BOARD_NAME_SUPERVISED = "Supervised" +BOARD_NAME_YELLOW = "Yellow" diff --git a/supervisor/dbus/agent/boards/interface.py b/supervisor/dbus/agent/boards/interface.py new file mode 100644 index 000000000..46edc4b00 --- /dev/null +++ b/supervisor/dbus/agent/boards/interface.py @@ -0,0 +1,24 @@ +"""Board dbus proxy interface.""" + +from typing import Any + +from ...const import DBUS_IFACE_HAOS_BOARDS, DBUS_NAME_HAOS, DBUS_OBJECT_HAOS_BOARDS +from ...interface import DBusInterfaceProxy + + +class BoardProxy(DBusInterfaceProxy): + """DBus interface proxy for os board.""" + + bus_name: str = DBUS_NAME_HAOS + + def __init__(self, name: str) -> None: + """Initialize properties.""" + self._name: str = name + self.object_path: str = f"{DBUS_OBJECT_HAOS_BOARDS}/{name}" + self.properties_interface: str = f"{DBUS_IFACE_HAOS_BOARDS}.{name}" + self.properties: dict[str, Any] = {} + + @property + def name(self) -> str: + """Get name.""" + return self._name diff --git a/supervisor/dbus/agent/boards/supervised.py b/supervisor/dbus/agent/boards/supervised.py new file mode 100644 index 000000000..0ab4af796 --- /dev/null +++ b/supervisor/dbus/agent/boards/supervised.py @@ -0,0 +1,13 @@ +"""Supervised board management.""" + +from .const import BOARD_NAME_SUPERVISED +from .interface import BoardProxy + + +class Supervised(BoardProxy): + """Supervised board manager object.""" + + def __init__(self) -> None: + """Initialize properties.""" + super().__init__(BOARD_NAME_SUPERVISED) + self.sync_properties: bool = False diff --git a/supervisor/dbus/agent/boards/yellow.py b/supervisor/dbus/agent/boards/yellow.py new file mode 100644 index 000000000..95a97d923 --- /dev/null +++ b/supervisor/dbus/agent/boards/yellow.py @@ -0,0 +1,49 @@ +"""Yellow board management.""" + +import asyncio + +from ...const import DBUS_ATTR_DISK_LED, DBUS_ATTR_HEARTBEAT_LED, DBUS_ATTR_POWER_LED +from ...interface import dbus_property +from .const import BOARD_NAME_YELLOW +from .interface import BoardProxy + + +class Yellow(BoardProxy): + """Yellow board manager object.""" + + def __init__(self) -> None: + """Initialize properties.""" + super().__init__(BOARD_NAME_YELLOW) + + @property + @dbus_property + def heartbeat_led(self) -> bool: + """Get heartbeat LED enabled.""" + return self.properties[DBUS_ATTR_HEARTBEAT_LED] + + @heartbeat_led.setter + def heartbeat_led(self, enabled: bool) -> None: + """Enable/disable heartbeat LED.""" + asyncio.create_task(self.dbus.Boards.Yellow.set_heartbeat_led(enabled)) + + @property + @dbus_property + def power_led(self) -> bool: + """Get power LED enabled.""" + return self.properties[DBUS_ATTR_POWER_LED] + + @power_led.setter + def power_led(self, enabled: bool) -> None: + """Enable/disable power LED.""" + asyncio.create_task(self.dbus.Boards.Yellow.set_power_led(enabled)) + + @property + @dbus_property + def disk_led(self) -> bool: + """Get disk LED enabled.""" + return self.properties[DBUS_ATTR_DISK_LED] + + @disk_led.setter + def disk_led(self, enabled: bool) -> None: + """Enable/disable disk LED.""" + asyncio.create_task(self.dbus.Boards.Yellow.set_disk_led(enabled)) diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index 52af34ea5..b80ca4e7c 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -18,6 +18,7 @@ DBUS_IFACE_DEVICE_WIRELESS = "org.freedesktop.NetworkManager.Device.Wireless" DBUS_IFACE_DNS = "org.freedesktop.NetworkManager.DnsManager" 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_DATADISK = "io.hass.os.DataDisk" DBUS_IFACE_HAOS_SYSTEM = "io.hass.os.System" @@ -40,6 +41,7 @@ DBUS_OBJECT_BASE = "/" DBUS_OBJECT_DNS = "/org/freedesktop/NetworkManager/DnsManager" 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_DATADISK = "/io/hass/os/DataDisk" DBUS_OBJECT_HAOS_SYSTEM = "/io/hass/os/System" @@ -55,6 +57,7 @@ DBUS_ATTR_ACTIVE_ACCESSPOINT = "ActiveAccessPoint" DBUS_ATTR_ACTIVE_CONNECTION = "ActiveConnection" DBUS_ATTR_ACTIVE_CONNECTIONS = "ActiveConnections" DBUS_ATTR_ADDRESS_DATA = "AddressData" +DBUS_ATTR_BOARD = "Board" DBUS_ATTR_BOOT_SLOT = "BootSlot" DBUS_ATTR_CACHE_STATISTICS = "CacheStatistics" DBUS_ATTR_CHASSIS = "Chassis" @@ -72,6 +75,7 @@ DBUS_ATTR_DEVICE_INTERFACE = "Interface" DBUS_ATTR_DEVICE_TYPE = "DeviceType" DBUS_ATTR_DEVICES = "Devices" DBUS_ATTR_DIAGNOSTICS = "Diagnostics" +DBUS_ATTR_DISK_LED = "DiskLED" DBUS_ATTR_DNS = "DNS" DBUS_ATTR_DNS_EX = "DNSEx" DBUS_ATTR_DNS_OVER_TLS = "DNSOverTLS" @@ -88,6 +92,7 @@ DBUS_ATTR_FINISH_TIMESTAMP = "FinishTimestamp" DBUS_ATTR_FIRMWARE_TIMESTAMP_MONOTONIC = "FirmwareTimestampMonotonic" DBUS_ATTR_FREQUENCY = "Frequency" DBUS_ATTR_GATEWAY = "Gateway" +DBUS_ATTR_HEARTBEAT_LED = "HeartbeatLED" DBUS_ATTR_HWADDRESS = "HwAddress" DBUS_ATTR_ID = "Id" DBUS_ATTR_IP4CONFIG = "Ip4Config" @@ -108,6 +113,7 @@ DBUS_ATTR_NTPSYNCHRONIZED = "NTPSynchronized" DBUS_ATTR_OPERATING_SYSTEM_PRETTY_NAME = "OperatingSystemPrettyName" DBUS_ATTR_OPERATION = "Operation" DBUS_ATTR_PARSER_VERSION = "ParserVersion" +DBUS_ATTR_POWER_LED = "PowerLED" DBUS_ATTR_PRIMARY_CONNECTION = "PrimaryConnection" DBUS_ATTR_RESOLV_CONF_MODE = "ResolvConfMode" DBUS_ATTR_RCMANAGER = "RcManager" diff --git a/supervisor/dbus/manager.py b/supervisor/dbus/manager.py index 706262acf..0ef8fcf63 100644 --- a/supervisor/dbus/manager.py +++ b/supervisor/dbus/manager.py @@ -88,13 +88,13 @@ class DBusManager(CoreSysAttributes): """Return all managed dbus interfaces.""" return [ self.agent, - self.systemd, - self.logind, self.hostname, - self.timedate, + self.logind, self.network, self.rauc, self.resolved, + self.systemd, + self.timedate, ] async def load(self) -> None: diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index af07a6184..bf420d2e0 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -362,6 +362,13 @@ class AppArmorInvalidError(AppArmorError): """AppArmor profile validate error.""" +# util/boards + + +class BoardInvalidError(DBusObjectError): + """System does not use the board specified.""" + + # util/common diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 09ba3ad6a..9373236ac 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -32,13 +32,13 @@ class UnsupportedReason(str, Enum): """Reasons for unsupported status.""" APPARMOR = "apparmor" + CGROUP_VERSION = "cgroup_version" CONNECTIVITY_CHECK = "connectivity_check" CONTENT_TRUST = "content_trust" DBUS = "dbus" DNS_SERVER = "dns_server" DOCKER_CONFIGURATION = "docker_configuration" DOCKER_VERSION = "docker_version" - CGROUP_VERSION = "cgroup_version" JOB_CONDITIONS = "job_conditions" LXC = "lxc" NETWORK_MANAGER = "network_manager" @@ -79,6 +79,7 @@ class IssueType(str, Enum): MISSING_IMAGE = "missing_image" NO_CURRENT_BACKUP = "no_current_backup" PWNED = "pwned" + REBOOT_REQUIRED = "reboot_required" SECURITY = "security" TRUST = "trust" UPDATE_FAILED = "update_failed" @@ -90,11 +91,12 @@ class SuggestionType(str, Enum): CLEAR_FULL_BACKUP = "clear_full_backup" CREATE_FULL_BACKUP = "create_full_backup" - EXECUTE_UPDATE = "execute_update" - EXECUTE_REPAIR = "execute_repair" - EXECUTE_RESET = "execute_reset" + EXECUTE_INTEGRITY = "execute_integrity" + EXECUTE_REBOOT = "execute_reboot" EXECUTE_RELOAD = "execute_reload" EXECUTE_REMOVE = "execute_remove" + EXECUTE_REPAIR = "execute_repair" + EXECUTE_RESET = "execute_reset" EXECUTE_STOP = "execute_stop" - EXECUTE_INTEGRITY = "execute_integrity" + EXECUTE_UPDATE = "execute_update" REGISTRY_LOGIN = "registry_login" diff --git a/supervisor/resolution/fixups/system_execute_reboot.py b/supervisor/resolution/fixups/system_execute_reboot.py new file mode 100644 index 000000000..98de80433 --- /dev/null +++ b/supervisor/resolution/fixups/system_execute_reboot.py @@ -0,0 +1,38 @@ +"""Reboot host fixup.""" +import asyncio +import logging + +from ...coresys import CoreSys +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> FixupBase: + """Check setup function.""" + return FixupSystemExecuteReboot(coresys) + + +class FixupSystemExecuteReboot(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: str | None = None) -> None: + """Initialize the fixup class.""" + _LOGGER.info("Rebooting the host") + await asyncio.shield(self.sys_host.control.reboot()) + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.EXECUTE_REBOOT + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.SYSTEM + + @property + def issues(self) -> list[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.REBOOT_REQUIRED] diff --git a/tests/api/test_os.py b/tests/api/test_os.py index 198f955db..8dcc50da2 100644 --- a/tests/api/test_os.py +++ b/tests/api/test_os.py @@ -1,16 +1,23 @@ """Test OS API.""" -from pathlib import Path +import asyncio +from pathlib import Path +from unittest.mock import PropertyMock, patch + +from aiohttp.test_utils import TestClient import pytest from supervisor.coresys import CoreSys +from supervisor.dbus.agent.boards import BoardManager from supervisor.hardware.data import Device +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion # pylint: disable=protected-access @pytest.mark.asyncio -async def test_api_os_info(api_client): +async def test_api_os_info(api_client: TestClient): """Test docker info api.""" resp = await api_client.get("/os/info") result = await resp.json() @@ -22,12 +29,13 @@ async def test_api_os_info(api_client): "board", "boot", "data_disk", + "cpe_board", ): assert attr in result["data"] @pytest.mark.asyncio -async def test_api_os_info_with_agent(api_client, coresys: CoreSys): +async def test_api_os_info_with_agent(api_client: TestClient, coresys: CoreSys): """Test docker info api.""" await coresys.dbus.agent.connect(coresys.dbus.bus) await coresys.dbus.agent.update() @@ -39,7 +47,7 @@ async def test_api_os_info_with_agent(api_client, coresys: CoreSys): @pytest.mark.asyncio -async def test_api_os_datadisk_move(api_client, coresys: CoreSys): +async def test_api_os_datadisk_move(api_client: TestClient, coresys: CoreSys): """Test datadisk move without exists disk.""" await coresys.dbus.agent.connect(coresys.dbus.bus) await coresys.dbus.agent.update() @@ -52,7 +60,7 @@ async def test_api_os_datadisk_move(api_client, coresys: CoreSys): @pytest.mark.asyncio -async def test_api_os_datadisk_list(api_client, coresys: CoreSys): +async def test_api_os_datadisk_list(api_client: TestClient, coresys: CoreSys): """Test datadisk list function.""" await coresys.dbus.agent.connect(coresys.dbus.bus) await coresys.dbus.agent.update() @@ -86,3 +94,81 @@ async def test_api_os_datadisk_list(api_client, coresys: CoreSys): result = await resp.json() assert result["data"]["devices"] == ["/dev/sda"] + + +@pytest.mark.parametrize("name", ["Yellow", "yellow"]) +async def test_api_board_yellow_info( + api_client: TestClient, coresys: CoreSys, name: str +): + """Test yellow board info.""" + await coresys.dbus.agent.board.connect(coresys.dbus.bus) + + resp = await api_client.get(f"/os/boards/{name}") + assert resp.status == 200 + + result = await resp.json() + assert result["data"]["disk_led"] is True + assert result["data"]["heartbeat_led"] is True + assert result["data"]["power_led"] is True + + assert (await api_client.get("/os/boards/supervised")).status == 400 + assert (await api_client.get("/os/boards/NotReal")).status == 400 + + +@pytest.mark.parametrize("name", ["Yellow", "yellow"]) +async def test_api_board_yellow_options( + api_client: TestClient, coresys: CoreSys, dbus: list[str], name: str +): + """Test yellow board options.""" + await coresys.dbus.agent.board.connect(coresys.dbus.bus) + + assert len(coresys.resolution.issues) == 0 + dbus.clear() + resp = await api_client.post( + f"/os/boards/{name}", + json={"disk_led": False, "heartbeat_led": False, "power_led": False}, + ) + assert resp.status == 200 + + await asyncio.sleep(0) + assert dbus == [ + "/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.DiskLED", + "/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.HeartbeatLED", + "/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.PowerLED", + ] + + assert ( + Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + in coresys.resolution.issues + ) + assert ( + Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM) + in coresys.resolution.suggestions + ) + + +@pytest.mark.parametrize("name", ["Supervised", "supervised"]) +async def test_api_board_supervised_info( + api_client: TestClient, coresys: CoreSys, name: str +): + """Test supervised board info.""" + with patch.object( + BoardManager, "board", new=PropertyMock(return_value="Supervised") + ): + await coresys.dbus.agent.board.connect(coresys.dbus.bus) + + assert (await api_client.get(f"/os/boards/{name}")).status == 200 + assert (await api_client.post(f"/os/boards/{name}", json={})).status == 405 + assert (await api_client.get("/os/boards/yellow")).status == 400 + assert (await api_client.get("/os/boards/NotReal")).status == 400 + + +async def test_api_board_other_info(api_client: TestClient, coresys: CoreSys): + """Test info for other board without dbus object.""" + with patch.object(BoardManager, "board", new=PropertyMock(return_value="NotReal")): + await coresys.dbus.agent.board.connect(coresys.dbus.bus) + + assert (await api_client.get("/os/boards/NotReal")).status == 200 + assert (await api_client.post("/os/boards/NotReal", json={})).status == 405 + assert (await api_client.get("/os/boards/yellow")).status == 400 + assert (await api_client.get("/os/boards/supervised")).status == 400 diff --git a/tests/dbus/agent/boards/__init__.py b/tests/dbus/agent/boards/__init__.py new file mode 100644 index 000000000..e44781490 --- /dev/null +++ b/tests/dbus/agent/boards/__init__.py @@ -0,0 +1 @@ +"""Test for Boards D-Bus interfaces.""" diff --git a/tests/dbus/agent/boards/test_board.py b/tests/dbus/agent/boards/test_board.py new file mode 100644 index 000000000..5a9b0295f --- /dev/null +++ b/tests/dbus/agent/boards/test_board.py @@ -0,0 +1,78 @@ +"""Test Boards manager.""" + +from unittest.mock import patch + +from dbus_fast.aio.message_bus import MessageBus +from dbus_fast.aio.proxy_object import ProxyInterface +import pytest + +from supervisor.dbus.agent.boards import BoardManager +from supervisor.exceptions import BoardInvalidError +from supervisor.utils.dbus import DBUS_INTERFACE_PROPERTIES, DBus + + +@pytest.fixture(name="dbus_mock_board") +async def fixture_dbus_mock_board(request: pytest.FixtureRequest, dbus: list[str]): + """Mock Boards dbus object to particular board name for tests.""" + call_dbus = DBus.call_dbus + + async def mock_call_dbus_specify_board( + proxy_interface: ProxyInterface, + method: str, + *args, + unpack_variants: bool = True, + ): + if ( + proxy_interface.introspection.name == DBUS_INTERFACE_PROPERTIES + and method == "call_get_all" + and proxy_interface.path == "/io/hass/os/Boards" + ): + return {"Board": request.param} + + return call_dbus( + proxy_interface, method, *args, unpack_variants=unpack_variants + ) + + with patch( + "supervisor.utils.dbus.DBus.call_dbus", new=mock_call_dbus_specify_board + ): + yield dbus + + +async def test_dbus_board(dbus: list[str], dbus_bus: MessageBus): + """Test DBus Board load.""" + board = BoardManager() + await board.connect(dbus_bus) + + assert board.board == "Yellow" + assert board.yellow.power_led is True + + with pytest.raises(BoardInvalidError): + assert not board.supervised + + +@pytest.mark.parametrize("dbus_mock_board", ["Supervised"], indirect=True) +async def test_dbus_board_supervised(dbus_mock_board: list[str], dbus_bus: MessageBus): + """Test DBus Board load with supervised board.""" + board = BoardManager() + await board.connect(dbus_bus) + + assert board.board == "Supervised" + assert board.supervised + + with pytest.raises(BoardInvalidError): + assert not board.yellow + + +@pytest.mark.parametrize("dbus_mock_board", ["NotReal"], indirect=True) +async def test_dbus_board_other(dbus_mock_board: list[str], dbus_bus: MessageBus): + """Test DBus Board load with board that has no dbus object.""" + board = BoardManager() + await board.connect(dbus_bus) + + assert board.board == "NotReal" + + with pytest.raises(BoardInvalidError): + assert not board.yellow + with pytest.raises(BoardInvalidError): + assert not board.supervised diff --git a/tests/dbus/agent/boards/test_yellow.py b/tests/dbus/agent/boards/test_yellow.py new file mode 100644 index 000000000..b74e27a29 --- /dev/null +++ b/tests/dbus/agent/boards/test_yellow.py @@ -0,0 +1,54 @@ +"""Test Yellow board.""" + +import asyncio + +from dbus_fast.aio.message_bus import MessageBus + +from supervisor.dbus.agent.boards.yellow import Yellow + + +async def test_dbus_yellow(dbus: list[str], dbus_bus: MessageBus): + """Test Yellow board load.""" + yellow = Yellow() + await yellow.connect(dbus_bus) + + assert yellow.name == "Yellow" + assert yellow.disk_led is True + assert yellow.heartbeat_led is True + assert yellow.power_led is True + + +async def test_dbus_yellow_set_disk_led(dbus: list[str], dbus_bus: MessageBus): + """Test setting disk led for Yellow board.""" + yellow = Yellow() + await yellow.connect(dbus_bus) + + dbus.clear() + yellow.disk_led = False + await asyncio.sleep(0) + + assert dbus == ["/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.DiskLED"] + + +async def test_dbus_yellow_set_heartbeat_led(dbus: list[str], dbus_bus: MessageBus): + """Test setting heartbeat led for Yellow board.""" + yellow = Yellow() + await yellow.connect(dbus_bus) + + dbus.clear() + yellow.heartbeat_led = False + await asyncio.sleep(0) + + assert dbus == ["/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.HeartbeatLED"] + + +async def test_dbus_yellow_set_power_led(dbus: list[str], dbus_bus: MessageBus): + """Test setting power led for Yellow board.""" + yellow = Yellow() + await yellow.connect(dbus_bus) + + dbus.clear() + yellow.power_led = False + await asyncio.sleep(0) + + assert dbus == ["/io/hass/os/Boards/Yellow-io.hass.os.Boards.Yellow.PowerLED"] diff --git a/tests/dbus/agent/test_apparmor.py b/tests/dbus/agent/test_apparmor.py index fb2f52bd1..fcc58357d 100644 --- a/tests/dbus/agent/test_apparmor.py +++ b/tests/dbus/agent/test_apparmor.py @@ -23,7 +23,7 @@ async def test_dbus_osagent_apparmor(coresys: CoreSys): await asyncio.sleep(0) assert coresys.dbus.agent.apparmor.version == "1.0.0" - fire_property_change_signal(coresys.dbus.agent, {}, ["ParserVersion"]) + fire_property_change_signal(coresys.dbus.agent.apparmor, {}, ["ParserVersion"]) await asyncio.sleep(0) assert coresys.dbus.agent.apparmor.version == "2.13.2" diff --git a/tests/fixtures/io_hass_os_Boards.json b/tests/fixtures/io_hass_os_Boards.json new file mode 100644 index 000000000..830a06edd --- /dev/null +++ b/tests/fixtures/io_hass_os_Boards.json @@ -0,0 +1,3 @@ +{ + "Board": "Yellow" +} diff --git a/tests/fixtures/io_hass_os_Boards.xml b/tests/fixtures/io_hass_os_Boards.xml new file mode 100644 index 000000000..4b21e9e7a --- /dev/null +++ b/tests/fixtures/io_hass_os_Boards.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/io_hass_os_Boards_Supervised.json b/tests/fixtures/io_hass_os_Boards_Supervised.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/fixtures/io_hass_os_Boards_Supervised.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/io_hass_os_Boards_Supervised.xml b/tests/fixtures/io_hass_os_Boards_Supervised.xml new file mode 100644 index 000000000..4b6e43840 --- /dev/null +++ b/tests/fixtures/io_hass_os_Boards_Supervised.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/io_hass_os_Boards_Yellow.json b/tests/fixtures/io_hass_os_Boards_Yellow.json new file mode 100644 index 000000000..4a864cec9 --- /dev/null +++ b/tests/fixtures/io_hass_os_Boards_Yellow.json @@ -0,0 +1,5 @@ +{ + "HeartbeatLED": true, + "PowerLED": true, + "DiskLED": true +} diff --git a/tests/fixtures/io_hass_os_Boards_Yellow.xml b/tests/fixtures/io_hass_os_Boards_Yellow.xml new file mode 100644 index 000000000..9ef9b36dd --- /dev/null +++ b/tests/fixtures/io_hass_os_Boards_Yellow.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/resolution/fixup/test_system_execute_reboot.py b/tests/resolution/fixup/test_system_execute_reboot.py new file mode 100644 index 000000000..cb6eff606 --- /dev/null +++ b/tests/resolution/fixup/test_system_execute_reboot.py @@ -0,0 +1,33 @@ +"""Test fixup system reboot.""" + +from unittest.mock import PropertyMock, patch + +from supervisor.coresys import CoreSys +from supervisor.host.const import HostFeature +from supervisor.host.manager import HostManager +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion +from supervisor.resolution.fixups.system_execute_reboot import FixupSystemExecuteReboot + + +async def test_fixup(coresys: CoreSys, dbus: list[str]): + """Test fixup.""" + await coresys.dbus.logind.connect(coresys.dbus.bus) + dbus.clear() + + system_execute_reboot = FixupSystemExecuteReboot(coresys) + assert system_execute_reboot.auto is False + + coresys.resolution.suggestions = Suggestion( + SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM + ) + coresys.resolution.issues = Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM) + + with patch.object( + HostManager, "features", new=PropertyMock(return_value=[HostFeature.REBOOT]) + ): + await system_execute_reboot() + + assert dbus == ["/org/freedesktop/login1-org.freedesktop.login1.Manager.Reboot"] + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0