diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 357acf647..e0baa026b 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -158,10 +158,7 @@ class AddonManager(CoreSysAttributes): if not store: raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error) - if not store.available: - raise AddonsNotSupportedError( - f"Add-on {slug} not supported on this platform", _LOGGER.error - ) + store.validate_availability() self.data.install(store) addon = Addon(self.coresys, slug) @@ -263,10 +260,7 @@ class AddonManager(CoreSysAttributes): raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning) # Check if available, Maybe something have changed - if not store.available: - raise AddonsNotSupportedError( - f"Add-on {slug} not supported on that platform", _LOGGER.error - ) + store.validate_availability() if backup: await self.sys_backups.do_backup_partial( diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index ca3d4f12d..1be4c415e 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -1,6 +1,8 @@ """Init file for Supervisor add-ons.""" from abc import ABC, abstractmethod from collections.abc import Awaitable +from contextlib import suppress +import logging from pathlib import Path from typing import Any @@ -78,10 +80,13 @@ from ..const import ( ) from ..coresys import CoreSys, CoreSysAttributes from ..docker.const import Capabilities +from ..exceptions import AddonsNotSupportedError from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode from .options import AddonOptions, UiOptions from .validate import RE_SERVICE, RE_VOLUME +_LOGGER: logging.Logger = logging.getLogger(__name__) + Data = dict[str, Any] @@ -595,31 +600,52 @@ class AddonModel(CoreSysAttributes, ABC): """Return Signer email address for CAS.""" return self.data.get(ATTR_CODENOTARY) + def validate_availability(self) -> None: + """Validate if addon is available for current system.""" + return self._validate_availability(self.data) + def __eq__(self, other): """Compaired add-on objects.""" if not isinstance(other, AddonModel): return False return self.slug == other.slug - def _available(self, config) -> bool: - """Return True if this add-on is available on this platform.""" + def _validate_availability(self, config) -> None: + """Validate if addon is available for current system.""" # Architecture if not self.sys_arch.is_supported(config[ATTR_ARCH]): - return False + raise AddonsNotSupportedError( + f"Add-on {self.slug} not supported on this platform, supported architectures: {', '.join(config[ATTR_ARCH])}", + _LOGGER.error, + ) # Machine / Hardware machine = config.get(ATTR_MACHINE) - if machine and f"!{self.sys_machine}" in machine: - return False - elif machine and self.sys_machine not in machine: - return False + 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.error, + ) # Home Assistant version: AwesomeVersion | None = config.get(ATTR_HOMEASSISTANT) + with suppress(AwesomeVersionException, TypeError): + if 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.error, + ) + + def _available(self, config) -> bool: + """Return True if this add-on is available on this platform.""" try: - return self.sys_homeassistant.version >= version - except (AwesomeVersionException, TypeError): - return True + self._validate_availability(config) + except AddonsNotSupportedError: + return False + + return True def _image(self, config) -> str: """Generate image name from data.""" diff --git a/tests/common.py b/tests/common.py index bde4d27ec..43ae3759e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -8,6 +8,7 @@ from dbus_fast.introspection import Method, Property, Signal from supervisor.dbus.interface import DBusInterface, DBusInterfaceProxy from supervisor.utils.dbus import DBUS_INTERFACE_PROPERTIES +from supervisor.utils.yaml import read_yaml_file def get_dbus_name(intr_list: list[Method | Property | Signal], snake_case: str) -> str: @@ -71,6 +72,12 @@ def load_json_fixture(filename: str) -> Any: return json.loads(path.read_text(encoding="utf-8")) +def load_yaml_fixture(filename: str) -> Any: + """Load a YAML fixture.""" + path = Path(Path(__file__).parent.joinpath("fixtures"), filename) + return read_yaml_file(path) + + def load_fixture(filename: str) -> str: """Load a fixture.""" path = Path(Path(__file__).parent.joinpath("fixtures"), filename) diff --git a/tests/store/test_store_manager.py b/tests/store/test_store_manager.py index d8e5ffe78..d7def10dc 100644 --- a/tests/store/test_store_manager.py +++ b/tests/store/test_store_manager.py @@ -1,15 +1,24 @@ """Test store manager.""" +from typing import Any from unittest.mock import PropertyMock, patch +from awesomeversion import AwesomeVersion import pytest +from supervisor.addons.addon import Addon +from supervisor.arch import CpuArch +from supervisor.backups.manager import BackupManager from supervisor.bootstrap import migrate_system_env from supervisor.const import ATTR_ADDONS_CUSTOM_LIST from supervisor.coresys import CoreSys -from supervisor.exceptions import StoreJobError +from supervisor.exceptions import AddonsNotSupportedError, StoreJobError +from supervisor.homeassistant.module import HomeAssistant from supervisor.store import StoreManager +from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository +from tests.common import load_yaml_fixture + async def test_default_load(coresys: CoreSys): """Test default load from config.""" @@ -111,3 +120,114 @@ async def test_reload_fails_if_out_of_date(coresys: CoreSys): type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True) ), pytest.raises(StoreJobError): await coresys.store.reload() + + +@pytest.mark.parametrize( + "config,log", + [ + ( + {"arch": ["i386"]}, + "Add-on local_ssh not supported on this platform, supported architectures: i386", + ), + ( + {"machine": ["odroid-n2"]}, + "Add-on local_ssh not supported on this machine, supported machine types: odroid-n2", + ), + ( + {"machine": ["!qemux86-64"]}, + "Add-on local_ssh not supported on this machine, supported machine types: !qemux86-64", + ), + ( + {"homeassistant": AwesomeVersion("2023.1.1")}, + "Add-on local_ssh not supported on this system, requires Home Assistant version 2023.1.1 or greater", + ), + ], +) +async def test_update_unavailable_addon( + coresys: CoreSys, + install_addon_ssh: Addon, + caplog: pytest.LogCaptureFixture, + config: dict[str, Any], + log: str, +): + """Test updating addon when new version not available for system.""" + addon_config = dict( + load_yaml_fixture("addons/local/ssh/config.yaml"), + version=AwesomeVersion("10.0.0"), + **config, + ) + + with patch.object(BackupManager, "do_backup_partial") as backup, patch.object( + AddonStore, "data", new=PropertyMock(return_value=addon_config) + ), patch.object( + CpuArch, "supported", new=PropertyMock(return_value=["amd64"]) + ), patch.object( + CoreSys, "machine", new=PropertyMock(return_value="qemux86-64") + ), patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), + ), patch( + "shutil.disk_usage", return_value=(42, 42, (1024.0**3)) + ): + with pytest.raises(AddonsNotSupportedError): + await coresys.addons.update("local_ssh", backup=True) + + backup.assert_not_called() + + assert log in caplog.text + + +@pytest.mark.parametrize( + "config,log", + [ + ( + {"arch": ["i386"]}, + "Add-on local_ssh not supported on this platform, supported architectures: i386", + ), + ( + {"machine": ["odroid-n2"]}, + "Add-on local_ssh not supported on this machine, supported machine types: odroid-n2", + ), + ( + {"machine": ["!qemux86-64"]}, + "Add-on local_ssh not supported on this machine, supported machine types: !qemux86-64", + ), + ( + {"homeassistant": AwesomeVersion("2023.1.1")}, + "Add-on local_ssh not supported on this system, requires Home Assistant version 2023.1.1 or greater", + ), + ], +) +async def test_install_unavailable_addon( + coresys: CoreSys, + repository: Repository, + caplog: pytest.LogCaptureFixture, + config: dict[str, Any], + log: str, +): + """Test updating addon when new version not available for system.""" + addon_config = dict( + load_yaml_fixture("addons/local/ssh/config.yaml"), + version=AwesomeVersion("10.0.0"), + **config, + ) + + with patch.object( + AddonStore, "data", new=PropertyMock(return_value=addon_config) + ), patch.object( + CpuArch, "supported", new=PropertyMock(return_value=["amd64"]) + ), patch.object( + CoreSys, "machine", new=PropertyMock(return_value="qemux86-64") + ), patch.object( + HomeAssistant, + "version", + new=PropertyMock(return_value=AwesomeVersion("2022.1.1")), + ), patch( + "shutil.disk_usage", return_value=(42, 42, (1024.0**3)) + ), pytest.raises( + AddonsNotSupportedError + ): + await coresys.addons.install("local_ssh") + + assert log in caplog.text