From 60e5e76fbd247058eac16eba628acbaaadd08637 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 22 Jun 2023 17:50:03 -0400 Subject: [PATCH] Allow return of all addon info in one api --- supervisor/api/addons.py | 226 ++++++++++++++++++++------------------- supervisor/api/const.py | 10 ++ tests/api/test_addons.py | 56 +++++++++- 3 files changed, 181 insertions(+), 111 deletions(-) diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 942f188d7..535691abc 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -106,7 +106,7 @@ from ..exceptions import ( PwnedSecret, ) from ..validate import docker_ports -from .const import ATTR_SIGNED, CONTENT_TYPE_BINARY +from .const import ATTR_SIGNED, CONTENT_TYPE_BINARY, AddonView from .utils import api_process, api_process_raw, api_validate, json_loads _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -130,6 +130,109 @@ SCHEMA_OPTIONS = vol.Schema( SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()}) +async def _generate_addon_dict( + addon: Addon, *, view: AddonView = AddonView.SIMPLE, stats: bool = False +) -> dict[str, Any]: + """Generate dictionary for addon.""" + data = ( + {} + if view == AddonView.NONE + else { + ATTR_NAME: addon.name, + ATTR_SLUG: addon.slug, + ATTR_DESCRIPTON: addon.description, + ATTR_ADVANCED: addon.advanced, + ATTR_STAGE: addon.stage, + ATTR_VERSION: addon.version, + ATTR_VERSION_LATEST: addon.latest_version, + ATTR_UPDATE_AVAILABLE: addon.need_update, + ATTR_AVAILABLE: addon.available, + ATTR_DETACHED: addon.is_detached, + ATTR_HOMEASSISTANT: addon.homeassistant_version, + ATTR_STATE: addon.state, + ATTR_REPOSITORY: addon.repository, + ATTR_BUILD: addon.need_build, + ATTR_URL: addon.url, + ATTR_ICON: addon.with_icon, + ATTR_LOGO: addon.with_logo, + } + ) + + if view == AddonView.FULL: + data |= { + ATTR_HOSTNAME: addon.hostname, + ATTR_DNS: addon.dns, + ATTR_LONG_DESCRIPTION: addon.long_description, + ATTR_PROTECTED: addon.protected, + ATTR_RATING: rating_security(addon), + ATTR_BOOT: addon.boot, + ATTR_OPTIONS: addon.options, + ATTR_SCHEMA: addon.schema_ui, + ATTR_ARCH: addon.supported_arch, + ATTR_MACHINE: addon.supported_machine, + ATTR_NETWORK: addon.ports, + ATTR_NETWORK_DESCRIPTION: addon.ports_description, + ATTR_HOST_NETWORK: addon.host_network, + ATTR_HOST_PID: addon.host_pid, + ATTR_HOST_IPC: addon.host_ipc, + ATTR_HOST_UTS: addon.host_uts, + ATTR_HOST_DBUS: addon.host_dbus, + ATTR_PRIVILEGED: addon.privileged, + ATTR_FULL_ACCESS: addon.with_full_access, + ATTR_APPARMOR: addon.apparmor, + ATTR_CHANGELOG: addon.with_changelog, + ATTR_DOCUMENTATION: addon.with_documentation, + ATTR_STDIN: addon.with_stdin, + ATTR_HASSIO_API: addon.access_hassio_api, + ATTR_HASSIO_ROLE: addon.hassio_role, + ATTR_AUTH_API: addon.access_auth_api, + ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api, + ATTR_GPIO: addon.with_gpio, + ATTR_USB: addon.with_usb, + ATTR_UART: addon.with_uart, + ATTR_KERNEL_MODULES: addon.with_kernel_modules, + ATTR_DEVICETREE: addon.with_devicetree, + ATTR_UDEV: addon.with_udev, + ATTR_DOCKER_API: addon.access_docker_api, + ATTR_VIDEO: addon.with_video, + ATTR_AUDIO: addon.with_audio, + ATTR_STARTUP: addon.startup, + ATTR_SERVICES: _pretty_services(addon), + ATTR_DISCOVERY: addon.discovery, + ATTR_TRANSLATIONS: addon.translations, + ATTR_INGRESS: addon.with_ingress, + ATTR_SIGNED: addon.signed, + ATTR_WEBUI: addon.webui, + ATTR_INGRESS_ENTRY: addon.ingress_entry, + ATTR_INGRESS_URL: addon.ingress_url, + ATTR_INGRESS_PORT: addon.ingress_port, + ATTR_INGRESS_PANEL: addon.ingress_panel, + ATTR_AUDIO_INPUT: addon.audio_input, + ATTR_AUDIO_OUTPUT: addon.audio_output, + ATTR_AUTO_UPDATE: addon.auto_update, + ATTR_IP_ADDRESS: str(addon.ip_address), + ATTR_WATCHDOG: addon.watchdog, + ATTR_DEVICES: addon.static_devices + + [device.path for device in addon.devices], + } + + if stats: + stats: DockerStats = await addon.stats() + + data |= { + ATTR_CPU_PERCENT: stats.cpu_percent, + ATTR_MEMORY_USAGE: stats.memory_usage, + ATTR_MEMORY_LIMIT: stats.memory_limit, + ATTR_MEMORY_PERCENT: stats.memory_percent, + ATTR_NETWORK_RX: stats.network_rx, + ATTR_NETWORK_TX: stats.network_tx, + ATTR_BLK_READ: stats.blk_read, + ATTR_BLK_WRITE: stats.blk_write, + } + + return data + + class APIAddons(CoreSysAttributes): """Handle RESTful API for add-on functions.""" @@ -155,28 +258,14 @@ class APIAddons(CoreSysAttributes): @api_process async def list(self, request: web.Request) -> dict[str, Any]: """Return all add-ons or repositories.""" - data_addons = [ - { - ATTR_NAME: addon.name, - ATTR_SLUG: addon.slug, - ATTR_DESCRIPTON: addon.description, - ATTR_ADVANCED: addon.advanced, - ATTR_STAGE: addon.stage, - ATTR_VERSION: addon.version, - ATTR_VERSION_LATEST: addon.latest_version, - ATTR_UPDATE_AVAILABLE: addon.need_update, - ATTR_AVAILABLE: addon.available, - ATTR_DETACHED: addon.is_detached, - ATTR_HOMEASSISTANT: addon.homeassistant_version, - ATTR_STATE: addon.state, - ATTR_REPOSITORY: addon.repository, - ATTR_BUILD: addon.need_build, - ATTR_URL: addon.url, - ATTR_ICON: addon.with_icon, - ATTR_LOGO: addon.with_logo, - } - for addon in self.sys_addons.installed - ] + view = vol.Coerce(AddonView)(request.query.get("view", AddonView.SIMPLE)) + stats = vol.Boolean()(request.query.get("stats", False)) + data_addons = await asyncio.gather( + *[ + _generate_addon_dict(addon, view=view, stats=stats) + for addon in self.sys_addons.installed + ] + ) return {ATTR_ADDONS: data_addons} @@ -188,82 +277,10 @@ class APIAddons(CoreSysAttributes): async def info(self, request: web.Request) -> dict[str, Any]: """Return add-on information.""" addon: AnyAddon = self._extract_addon(request) + view = vol.Coerce(AddonView)(request.query.get("view", AddonView.FULL)) + stats = vol.Boolean()(request.query.get("stats", False)) - data = { - ATTR_NAME: addon.name, - ATTR_SLUG: addon.slug, - ATTR_HOSTNAME: addon.hostname, - ATTR_DNS: addon.dns, - ATTR_DESCRIPTON: addon.description, - ATTR_LONG_DESCRIPTION: addon.long_description, - ATTR_ADVANCED: addon.advanced, - ATTR_STAGE: addon.stage, - ATTR_REPOSITORY: addon.repository, - ATTR_VERSION_LATEST: addon.latest_version, - ATTR_PROTECTED: addon.protected, - ATTR_RATING: rating_security(addon), - ATTR_BOOT: addon.boot, - ATTR_OPTIONS: addon.options, - ATTR_SCHEMA: addon.schema_ui, - ATTR_ARCH: addon.supported_arch, - ATTR_MACHINE: addon.supported_machine, - ATTR_HOMEASSISTANT: addon.homeassistant_version, - ATTR_URL: addon.url, - ATTR_DETACHED: addon.is_detached, - ATTR_AVAILABLE: addon.available, - ATTR_BUILD: addon.need_build, - ATTR_NETWORK: addon.ports, - ATTR_NETWORK_DESCRIPTION: addon.ports_description, - ATTR_HOST_NETWORK: addon.host_network, - ATTR_HOST_PID: addon.host_pid, - ATTR_HOST_IPC: addon.host_ipc, - ATTR_HOST_UTS: addon.host_uts, - ATTR_HOST_DBUS: addon.host_dbus, - ATTR_PRIVILEGED: addon.privileged, - ATTR_FULL_ACCESS: addon.with_full_access, - ATTR_APPARMOR: addon.apparmor, - ATTR_ICON: addon.with_icon, - ATTR_LOGO: addon.with_logo, - ATTR_CHANGELOG: addon.with_changelog, - ATTR_DOCUMENTATION: addon.with_documentation, - ATTR_STDIN: addon.with_stdin, - ATTR_HASSIO_API: addon.access_hassio_api, - ATTR_HASSIO_ROLE: addon.hassio_role, - ATTR_AUTH_API: addon.access_auth_api, - ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api, - ATTR_GPIO: addon.with_gpio, - ATTR_USB: addon.with_usb, - ATTR_UART: addon.with_uart, - ATTR_KERNEL_MODULES: addon.with_kernel_modules, - ATTR_DEVICETREE: addon.with_devicetree, - ATTR_UDEV: addon.with_udev, - ATTR_DOCKER_API: addon.access_docker_api, - ATTR_VIDEO: addon.with_video, - ATTR_AUDIO: addon.with_audio, - ATTR_STARTUP: addon.startup, - ATTR_SERVICES: _pretty_services(addon), - ATTR_DISCOVERY: addon.discovery, - ATTR_TRANSLATIONS: addon.translations, - ATTR_INGRESS: addon.with_ingress, - ATTR_SIGNED: addon.signed, - ATTR_STATE: addon.state, - ATTR_WEBUI: addon.webui, - ATTR_INGRESS_ENTRY: addon.ingress_entry, - ATTR_INGRESS_URL: addon.ingress_url, - ATTR_INGRESS_PORT: addon.ingress_port, - ATTR_INGRESS_PANEL: addon.ingress_panel, - ATTR_AUDIO_INPUT: addon.audio_input, - ATTR_AUDIO_OUTPUT: addon.audio_output, - ATTR_AUTO_UPDATE: addon.auto_update, - ATTR_IP_ADDRESS: str(addon.ip_address), - ATTR_VERSION: addon.version, - ATTR_UPDATE_AVAILABLE: addon.need_update, - ATTR_WATCHDOG: addon.watchdog, - ATTR_DEVICES: addon.static_devices - + [device.path for device in addon.devices], - } - - return data + return await _generate_addon_dict(addon, view=view, stats=stats) @api_process async def options(self, request: web.Request) -> None: @@ -371,18 +388,7 @@ class APIAddons(CoreSysAttributes): """Return resource information.""" addon = self._extract_addon(request) - stats: DockerStats = await addon.stats() - - return { - ATTR_CPU_PERCENT: stats.cpu_percent, - ATTR_MEMORY_USAGE: stats.memory_usage, - ATTR_MEMORY_LIMIT: stats.memory_limit, - ATTR_MEMORY_PERCENT: stats.memory_percent, - ATTR_NETWORK_RX: stats.network_rx, - ATTR_NETWORK_TX: stats.network_tx, - ATTR_BLK_READ: stats.blk_read, - ATTR_BLK_WRITE: stats.blk_write, - } + return _generate_addon_dict(addon, view=AddonView.NONE, stats=True) @api_process def uninstall(self, request: web.Request) -> Awaitable[None]: diff --git a/supervisor/api/const.py b/supervisor/api/const.py index 8f3f342e9..3099df807 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -1,5 +1,7 @@ """Const for API.""" +from enum import StrEnum + CONTENT_TYPE_BINARY = "application/octet-stream" CONTENT_TYPE_JSON = "application/json" CONTENT_TYPE_PNG = "image/png" @@ -53,3 +55,11 @@ ATTR_UPDATE_TYPE = "update_type" ATTR_USE_NTP = "use_ntp" ATTR_USAGE = "usage" ATTR_VENDOR = "vendor" + + +class AddonView(StrEnum): + """Addon view.""" + + FULL = "full" + SIMPLE = "simple" + NONE = "none" diff --git a/tests/api/test_addons.py b/tests/api/test_addons.py index eaafb0bbd..fa8352a16 100644 --- a/tests/api/test_addons.py +++ b/tests/api/test_addons.py @@ -4,6 +4,7 @@ import asyncio from unittest.mock import MagicMock, PropertyMock, patch from aiohttp.test_utils import TestClient +import pytest from supervisor.addons.addon import Addon from supervisor.addons.build import AddonBuild @@ -15,7 +16,8 @@ from supervisor.docker.const import ContainerState from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.store.repository import Repository -from ..const import TEST_ADDON_SLUG +from tests.common import load_json_fixture +from tests.const import TEST_ADDON_SLUG def _create_test_event(name: str, state: ContainerState) -> DockerContainerStateEvent: @@ -28,6 +30,15 @@ def _create_test_event(name: str, state: ContainerState) -> DockerContainerState ) +@pytest.fixture(name="container_stats") +async def fixture_container_stats(container: MagicMock) -> MagicMock: + """Mock container stats.""" + container.status = "running" + container.stats.return_value = load_json_fixture("container_stats.json") + + yield container + + async def test_addons_info( api_client: TestClient, coresys: CoreSys, install_addon_ssh: Addon ): @@ -212,3 +223,46 @@ async def test_api_addon_rebuild_healthcheck( assert state_changes == [AddonState.STOPPED, AddonState.STARTUP] assert install_addon_ssh.state == AddonState.STARTED assert resp.status == 200 + + +async def test_api_addon_list( + api_client: TestClient, coresys: CoreSys, install_addon_ssh: Addon, container_stats +): + """Test listing addons.""" + resp = await api_client.get("/addons") + result = await resp.json() + + assert len(result["data"]["addons"]) == 1 + addon_data = result["data"]["addons"][0] + assert addon_data["name"] == "Terminal & SSH" + assert addon_data["version"] == "9.2.1" + assert "hostname" not in addon_data + assert "rating" not in addon_data + assert "cpu_percent" not in addon_data + assert "memory_percent" not in addon_data + + # Now full view + resp = await api_client.get("/addons?view=full") + result = await resp.json() + + assert len(result["data"]["addons"]) == 1 + addon_data = result["data"]["addons"][0] + assert addon_data["name"] == "Terminal & SSH" + assert addon_data["version"] == "9.2.1" + assert addon_data["hostname"] == "local-ssh" + assert addon_data["rating"] == 6 + assert "cpu_percent" not in addon_data + assert "memory_percent" not in addon_data + + # Now full with stats + resp = await api_client.get("/addons?view=full&stats=true") + result = await resp.json() + + assert len(result["data"]["addons"]) == 1 + addon_data = result["data"]["addons"][0] + assert addon_data["name"] == "Terminal & SSH" + assert addon_data["version"] == "9.2.1" + assert addon_data["hostname"] == "local-ssh" + assert addon_data["rating"] == 6 + assert addon_data["cpu_percent"] == 90.0 + assert addon_data["memory_percent"] == 1.49