1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2025-12-20 02:18:59 +00:00

Add availability API for addons (#6140)

* Add availability API for addons

* Add cast back and test for latest version of installed addon

* Make error responses more translation/client library friendly

* Add test cases for install/update APIs
This commit is contained in:
Mike Degatano
2025-09-04 05:14:42 -04:00
committed by GitHub
parent a3a5f6ba98
commit 7ed83a15fe
10 changed files with 360 additions and 24 deletions

View File

@@ -67,9 +67,9 @@ from ..docker.monitor import DockerContainerStateEvent
from ..docker.stats import DockerStats
from ..exceptions import (
AddonConfigurationError,
AddonNotSupportedError,
AddonsError,
AddonsJobError,
AddonsNotSupportedError,
ConfigurationFileError,
DockerError,
HomeAssistantAPIError,
@@ -1172,7 +1172,7 @@ class Addon(AddonModel):
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 @@ class Addon(AddonModel):
# 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,
)

View File

@@ -14,9 +14,9 @@ from supervisor.jobs.const import JobConcurrency
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 @@ class AddonManager(CoreSysAttributes):
"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
)

View File

@@ -89,7 +89,12 @@ from ..const import (
)
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 @@ class AddonModel(JobGroup, ABC):
"""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 @@ class AddonModel(JobGroup, ABC):
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 @@ class AddonModel(JobGroup, ABC):
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

View File

@@ -735,6 +735,10 @@ class RestAPI(CoreSysAttributes):
"/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
),

View File

@@ -326,6 +326,12 @@ class APIStore(CoreSysAttributes):
_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."""

View File

@@ -16,8 +16,11 @@ from ..const import (
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 @@ def api_process_raw(content, *, error_type=None):
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,

View File

@@ -76,6 +76,9 @@ JSON_DATA = "data"
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"

View File

@@ -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 @@ class APIError(HassioError, RuntimeError):
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

View File

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
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.addon import DockerAddon
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"]
)

View File

@@ -12,7 +12,7 @@ from supervisor.addons.addon import Addon
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")