diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 64e2494a7b7..b9d6f25b983 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -67,9 +67,9 @@ from ..docker.stats import DockerStats from ..exceptions import ( AddonConfigurationError, + AddonNotSupportedError, AddonsError, AddonsJobError, - AddonsNotSupportedError, ConfigurationFileError, DockerError, HomeAssistantAPIError, @@ -1172,7 +1172,7 @@ async def stats(self) -> DockerStats: async def write_stdin(self, data) -> None: """Write data to add-on stdin.""" if not self.with_stdin: - raise AddonsNotSupportedError( + raise AddonNotSupportedError( f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error ) @@ -1419,7 +1419,7 @@ def _extract_tarfile() -> tuple[TemporaryDirectory, dict[str, Any]]: # If available if not self._available(data[ATTR_SYSTEM]): - raise AddonsNotSupportedError( + raise AddonNotSupportedError( f"Add-on {self.slug} is not available for this platform", _LOGGER.error, ) diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py index 1dea162d4db..6c985bd5bad 100644 --- a/supervisor/addons/manager.py +++ b/supervisor/addons/manager.py @@ -14,9 +14,9 @@ from ..const import AddonBoot, AddonStartup, AddonState from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ( + AddonNotSupportedError, AddonsError, AddonsJobError, - AddonsNotSupportedError, CoreDNSError, DockerError, HassioError, @@ -307,7 +307,7 @@ async def rebuild(self, slug: str, *, force: bool = False) -> asyncio.Task | Non "Version changed, use Update instead Rebuild", _LOGGER.error ) if not force and not addon.need_build: - raise AddonsNotSupportedError( + raise AddonNotSupportedError( "Can't rebuild a image based add-on", _LOGGER.error ) diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 1d9811d4cd9..11b51e7a39d 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -89,7 +89,12 @@ ) from ..coresys import CoreSys from ..docker.const import Capabilities -from ..exceptions import AddonsNotSupportedError +from ..exceptions import ( + AddonNotSupportedArchitectureError, + AddonNotSupportedError, + AddonNotSupportedHomeAssistantVersionError, + AddonNotSupportedMachineTypeError, +) from ..jobs.const import JOB_GROUP_ADDON from ..jobs.job_group import JobGroup from ..utils import version_is_new_enough @@ -680,9 +685,8 @@ def _validate_availability( """Validate if addon is available for current system.""" # Architecture if not self.sys_arch.is_supported(config[ATTR_ARCH]): - raise AddonsNotSupportedError( - f"Add-on {self.slug} not supported on this platform, supported architectures: {', '.join(config[ATTR_ARCH])}", - logger, + raise AddonNotSupportedArchitectureError( + logger, slug=self.slug, architectures=config[ATTR_ARCH] ) # Machine / Hardware @@ -690,9 +694,8 @@ def _validate_availability( if machine and ( f"!{self.sys_machine}" in machine or self.sys_machine not in machine ): - raise AddonsNotSupportedError( - f"Add-on {self.slug} not supported on this machine, supported machine types: {', '.join(machine)}", - logger, + raise AddonNotSupportedMachineTypeError( + logger, slug=self.slug, machine_types=machine ) # Home Assistant @@ -701,16 +704,15 @@ def _validate_availability( if version and not version_is_new_enough( self.sys_homeassistant.version, version ): - raise AddonsNotSupportedError( - f"Add-on {self.slug} not supported on this system, requires Home Assistant version {version} or greater", - logger, + raise AddonNotSupportedHomeAssistantVersionError( + logger, slug=self.slug, version=str(version) ) def _available(self, config) -> bool: """Return True if this add-on is available on this platform.""" try: self._validate_availability(config) - except AddonsNotSupportedError: + except AddonNotSupportedError: return False return True diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index c9c9dc40be1..23c9c934ea3 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -735,6 +735,10 @@ def _register_store(self) -> None: "/store/addons/{addon}/documentation", api_store.addons_addon_documentation, ), + web.get( + "/store/addons/{addon}/availability", + api_store.addons_addon_availability, + ), web.post( "/store/addons/{addon}/install", api_store.addons_addon_install ), diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 85c9e30a24e..22ac2f5490d 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -326,6 +326,12 @@ async def addons_addon_documentation(self, request: web.Request) -> str: _read_static_text_file, addon.path_documentation ) + @api_process + async def addons_addon_availability(self, request: web.Request) -> None: + """Check add-on availability for current system.""" + addon = cast(AddonStore, self._extract_addon(request)) + addon.validate_availability() + @api_process async def repositories_list(self, request: web.Request) -> list[dict[str, Any]]: """Return all repositories.""" diff --git a/supervisor/api/utils.py b/supervisor/api/utils.py index f5c245897dd..781bb698ab9 100644 --- a/supervisor/api/utils.py +++ b/supervisor/api/utils.py @@ -16,8 +16,11 @@ HEADER_TOKEN, HEADER_TOKEN_OLD, JSON_DATA, + JSON_ERROR_KEY, + JSON_EXTRA_FIELDS, JSON_JOB_ID, JSON_MESSAGE, + JSON_MESSAGE_TEMPLATE, JSON_RESULT, REQUEST_FROM, RESULT_ERROR, @@ -136,10 +139,11 @@ async def wrap_api( def api_return_error( - error: Exception | None = None, + error: HassioError | None = None, message: str | None = None, error_type: str | None = None, status: int = 400, + *, job_id: str | None = None, ) -> web.Response: """Return an API error message.""" @@ -158,12 +162,18 @@ def api_return_error( body=message.encode(), content_type=error_type, status=status ) case _: - result = { + result: dict[str, Any] = { JSON_RESULT: RESULT_ERROR, JSON_MESSAGE: message, } if job_id: result[JSON_JOB_ID] = job_id + if error and error.error_key: + result[JSON_ERROR_KEY] = error.error_key + if error and error.message_template: + result[JSON_MESSAGE_TEMPLATE] = error.message_template + if error and error.extra_fields: + result[JSON_EXTRA_FIELDS] = error.extra_fields return web.json_response( result, diff --git a/supervisor/const.py b/supervisor/const.py index bd4c9058861..c23a72d9ae1 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -76,6 +76,9 @@ JSON_MESSAGE = "message" JSON_RESULT = "result" JSON_JOB_ID = "job_id" +JSON_ERROR_KEY = "error_key" +JSON_MESSAGE_TEMPLATE = "message_template" +JSON_EXTRA_FIELDS = "extra_fields" RESULT_ERROR = "error" RESULT_OK = "ok" diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index a527ee24965..5f9625ab37e 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -1,17 +1,32 @@ """Core Exceptions.""" from collections.abc import Callable +from typing import Any class HassioError(Exception): """Root exception.""" + error_key: str | None = None + message_template: str | None = None + def __init__( self, message: str | None = None, logger: Callable[..., None] | None = None, + *, + extra_fields: dict[str, Any] | None = None, ) -> None: """Raise & log.""" + self.extra_fields = extra_fields or {} + + if not message and self.message_template: + message = ( + self.message_template.format(**self.extra_fields) + if self.extra_fields + else self.message_template + ) + if logger is not None and message is not None: logger(message) @@ -235,8 +250,71 @@ class AddonConfigurationError(AddonsError): """Error with add-on configuration.""" -class AddonsNotSupportedError(HassioNotSupportedError): - """Addons don't support a function.""" +class AddonNotSupportedError(HassioNotSupportedError): + """Addon doesn't support a function.""" + + +class AddonNotSupportedArchitectureError(AddonNotSupportedError): + """Addon does not support system due to architecture.""" + + error_key = "addon_not_supported_architecture_error" + message_template = "Add-on {slug} not supported on this platform, supported architectures: {architectures}" + + def __init__( + self, + logger: Callable[..., None] | None = None, + *, + slug: str, + architectures: list[str], + ) -> None: + """Initialize exception.""" + super().__init__( + None, + logger, + extra_fields={"slug": slug, "architectures": ", ".join(architectures)}, + ) + + +class AddonNotSupportedMachineTypeError(AddonNotSupportedError): + """Addon does not support system due to machine type.""" + + error_key = "addon_not_supported_machine_type_error" + message_template = "Add-on {slug} not supported on this machine, supported machine types: {machine_types}" + + def __init__( + self, + logger: Callable[..., None] | None = None, + *, + slug: str, + machine_types: list[str], + ) -> None: + """Initialize exception.""" + super().__init__( + None, + logger, + extra_fields={"slug": slug, "machine_types": ", ".join(machine_types)}, + ) + + +class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError): + """Addon does not support system due to Home Assistant version.""" + + error_key = "addon_not_supported_home_assistant_version_error" + message_template = "Add-on {slug} not supported on this system, requires Home Assistant version {version} or greater" + + def __init__( + self, + logger: Callable[..., None] | None = None, + *, + slug: str, + version: str, + ) -> None: + """Initialize exception.""" + super().__init__( + None, + logger, + extra_fields={"slug": slug, "version": version}, + ) class AddonsJobError(AddonsError, JobException): @@ -319,10 +397,17 @@ def __init__( self, message: str | None = None, logger: Callable[..., None] | None = None, + *, job_id: str | None = None, + error: HassioError | None = None, ) -> None: """Raise & log, optionally with job.""" - super().__init__(message, logger) + # Allow these to be set from another error here since APIErrors essentially wrap others to add a status + self.error_key = error.error_key if error else None + self.message_template = error.message_template if error else None + super().__init__( + message, logger, extra_fields=error.extra_fields if error else None + ) self.job_id = job_id diff --git a/tests/api/test_store.py b/tests/api/test_store.py index 29a670ab0c2..f35be6d50cc 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -6,6 +6,7 @@ from aiohttp import ClientResponse from aiohttp.test_utils import TestClient +from awesomeversion import AwesomeVersion import pytest from supervisor.addons.addon import Addon @@ -18,6 +19,7 @@ from supervisor.docker.const import ContainerState from supervisor.docker.interface import DockerInterface from supervisor.docker.monitor import DockerContainerStateEvent +from supervisor.homeassistant.module import HomeAssistant from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository @@ -306,6 +308,7 @@ async def get_message(resp: ClientResponse, json_expected: bool) -> str: ("post", "/store/addons/bad/install/1", True), ("post", "/store/addons/bad/update", True), ("post", "/store/addons/bad/update/1", True), + ("get", "/store/addons/bad/availability", True), # Legacy paths ("get", "/addons/bad/icon", False), ("get", "/addons/bad/logo", False), @@ -492,3 +495,226 @@ async def test_background_addon_update_fails_fast( assert resp.status == 400 body = await resp.json() assert body["message"] == "No update available for add-on local_ssh" + + +async def test_api_store_addons_addon_availability_success( + api_client: TestClient, store_addon: AddonStore +): + """Test /store/addons/{addon}/availability REST API - success case.""" + resp = await api_client.get(f"/store/addons/{store_addon.slug}/availability") + assert resp.status == 200 + + +@pytest.mark.parametrize( + ("supported_architectures", "api_action", "api_method", "installed"), + [ + (["i386"], "availability", "get", False), + (["i386", "aarch64"], "availability", "get", False), + (["i386"], "install", "post", False), + (["i386", "aarch64"], "install", "post", False), + (["i386"], "update", "post", True), + (["i386", "aarch64"], "update", "post", True), + ], +) +async def test_api_store_addons_addon_availability_arch_not_supported( + api_client: TestClient, + coresys: CoreSys, + supported_architectures: list[str], + api_action: str, + api_method: str, + installed: bool, +): + """Test availability errors for /store/addons/{addon}/* REST APIs - architecture not supported.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + # Create an addon with unsupported architecture + addon_obj = AddonStore(coresys, "test_arch_addon") + coresys.addons.store[addon_obj.slug] = addon_obj + + # Set addon config with unsupported architecture + addon_config = { + "advanced": False, + "arch": supported_architectures, + "slug": "test_arch_addon", + "description": "Test arch add-on", + "name": "Test Arch Add-on", + "repository": "test", + "stage": "stable", + "version": "1.0.0", + } + coresys.store.data.addons[addon_obj.slug] = addon_config + if installed: + coresys.addons.local[addon_obj.slug] = Addon(coresys, addon_obj.slug) + coresys.addons.data.user[addon_obj.slug] = {"version": AwesomeVersion("0.0.1")} + + # Mock the system architecture to be different + with patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])): + resp = await api_client.request( + api_method, f"/store/addons/{addon_obj.slug}/{api_action}" + ) + assert resp.status == 400 + result = await resp.json() + assert result["error_key"] == "addon_not_supported_architecture_error" + assert ( + result["message_template"] + == "Add-on {slug} not supported on this platform, supported architectures: {architectures}" + ) + assert result["extra_fields"] == { + "slug": "test_arch_addon", + "architectures": ", ".join(supported_architectures), + } + assert result["message"] == result["message_template"].format( + **result["extra_fields"] + ) + + +@pytest.mark.parametrize( + ("supported_machines", "api_action", "api_method", "installed"), + [ + (["odroid-n2"], "availability", "get", False), + (["!qemux86-64"], "availability", "get", False), + (["a", "b"], "availability", "get", False), + (["odroid-n2"], "install", "post", False), + (["!qemux86-64"], "install", "post", False), + (["a", "b"], "install", "post", False), + (["odroid-n2"], "update", "post", True), + (["!qemux86-64"], "update", "post", True), + (["a", "b"], "update", "post", True), + ], +) +async def test_api_store_addons_addon_availability_machine_not_supported( + api_client: TestClient, + coresys: CoreSys, + supported_machines: list[str], + api_action: str, + api_method: str, + installed: bool, +): + """Test availability errors for /store/addons/{addon}/* REST APIs - machine not supported.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + # Create an addon with unsupported machine type + addon_obj = AddonStore(coresys, "test_machine_addon") + coresys.addons.store[addon_obj.slug] = addon_obj + + # Set addon config with unsupported machine + addon_config = { + "advanced": False, + "arch": ["amd64"], + "machine": supported_machines, + "slug": "test_machine_addon", + "description": "Test machine add-on", + "name": "Test Machine Add-on", + "repository": "test", + "stage": "stable", + "version": "1.0.0", + } + coresys.store.data.addons[addon_obj.slug] = addon_config + if installed: + coresys.addons.local[addon_obj.slug] = Addon(coresys, addon_obj.slug) + coresys.addons.data.user[addon_obj.slug] = {"version": AwesomeVersion("0.0.1")} + + # Mock the system machine to be different + with patch.object(CoreSys, "machine", new=PropertyMock(return_value="qemux86-64")): + resp = await api_client.request( + api_method, f"/store/addons/{addon_obj.slug}/{api_action}" + ) + assert resp.status == 400 + result = await resp.json() + assert result["error_key"] == "addon_not_supported_machine_type_error" + assert ( + result["message_template"] + == "Add-on {slug} not supported on this machine, supported machine types: {machine_types}" + ) + assert result["extra_fields"] == { + "slug": "test_machine_addon", + "machine_types": ", ".join(supported_machines), + } + assert result["message"] == result["message_template"].format( + **result["extra_fields"] + ) + + +@pytest.mark.parametrize( + ("api_action", "api_method", "installed"), + [ + ("availability", "get", False), + ("install", "post", False), + ("update", "post", True), + ], +) +async def test_api_store_addons_addon_availability_homeassistant_version_too_old( + api_client: TestClient, + coresys: CoreSys, + api_action: str, + api_method: str, + installed: bool, +): + """Test availability errors for /store/addons/{addon}/* REST APIs - Home Assistant version too old.""" + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + # Create an addon that requires newer Home Assistant version + addon_obj = AddonStore(coresys, "test_version_addon") + coresys.addons.store[addon_obj.slug] = addon_obj + + # Set addon config with minimum Home Assistant version requirement + addon_config = { + "advanced": False, + "arch": ["amd64"], + "homeassistant": "2023.1.1", # Requires newer version than current + "slug": "test_version_addon", + "description": "Test version add-on", + "name": "Test Version Add-on", + "repository": "test", + "stage": "stable", + "version": "1.0.0", + } + coresys.store.data.addons[addon_obj.slug] = addon_config + if installed: + coresys.addons.local[addon_obj.slug] = Addon(coresys, addon_obj.slug) + coresys.addons.data.user[addon_obj.slug] = {"version": AwesomeVersion("0.0.1")} + + # Mock the Home Assistant version to be older + with patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), + ): + resp = await api_client.request( + api_method, f"/store/addons/{addon_obj.slug}/{api_action}" + ) + assert resp.status == 400 + result = await resp.json() + assert result["error_key"] == "addon_not_supported_home_assistant_version_error" + assert ( + result["message_template"] + == "Add-on {slug} not supported on this system, requires Home Assistant version {version} or greater" + ) + assert result["extra_fields"] == { + "slug": "test_version_addon", + "version": "2023.1.1", + } + assert result["message"] == result["message_template"].format( + **result["extra_fields"] + ) + + +async def test_api_store_addons_addon_availability_installed_addon( + api_client: TestClient, install_addon_ssh: Addon +): + """Test /store/addons/{addon}/availability REST API - installed addon checks against latest version.""" + resp = await api_client.get("/store/addons/local_ssh/availability") + assert resp.status == 200 + + install_addon_ssh.data_store["version"] = AwesomeVersion("10.0.0") + install_addon_ssh.data_store["homeassistant"] = AwesomeVersion("2023.1.1") + + # Mock the Home Assistant version to be older + with patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), + ): + resp = await api_client.get("/store/addons/local_ssh/availability") + assert resp.status == 400 + result = await resp.json() + assert ( + "requires Home Assistant version 2023.1.1 or greater" in result["message"] + ) diff --git a/tests/store/test_store_manager.py b/tests/store/test_store_manager.py index 2e5862f9ed3..22fc09314a4 100644 --- a/tests/store/test_store_manager.py +++ b/tests/store/test_store_manager.py @@ -12,7 +12,7 @@ from supervisor.arch import CpuArch from supervisor.backups.manager import BackupManager from supervisor.coresys import CoreSys -from supervisor.exceptions import AddonsNotSupportedError, StoreJobError +from supervisor.exceptions import AddonNotSupportedError, StoreJobError from supervisor.homeassistant.module import HomeAssistant from supervisor.store import StoreManager from supervisor.store.addon import AddonStore @@ -172,7 +172,7 @@ async def test_update_unavailable_addon( ), patch("shutil.disk_usage", return_value=(42, 42, (1024.0**3))), ): - with pytest.raises(AddonsNotSupportedError): + with pytest.raises(AddonNotSupportedError): await coresys.addons.update("local_ssh", backup=True) backup.assert_not_called() @@ -227,7 +227,7 @@ async def test_install_unavailable_addon( new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), ), patch("shutil.disk_usage", return_value=(42, 42, (1024.0**3))), - pytest.raises(AddonsNotSupportedError), + pytest.raises(AddonNotSupportedError), ): await coresys.addons.install("local_ssh")