diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index a65e58a1b12..9e1ab66ab82 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -3,14 +3,12 @@ from __future__ import annotations import asyncio -from contextlib import suppress from dataclasses import replace from datetime import datetime import logging import os -import re import struct -from typing import Any, NamedTuple, cast +from typing import Any, cast from aiohasupervisor import SupervisorError from aiohasupervisor.models import ( @@ -41,35 +39,23 @@ from homeassistant.components.http import ( ) from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_ID, - ATTR_NAME, EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, SERVER_PORT, Platform, ) -from homeassistant.core import ( - Event, - HassJob, - HomeAssistant, - ServiceCall, - async_get_hass_or_none, - callback, -) -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, issue_registry as ir, - selector, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task -from homeassistant.util.dt import now # config_flow, diagnostics, system_health, and entity platforms are imported to # ensure other dependencies that wait for hassio are not waiting @@ -92,19 +78,7 @@ from .auth import async_setup_auth_view from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, - ATTR_ADDON, - ATTR_ADDONS, - ATTR_APP, - ATTR_APPS, - ATTR_COMPRESSED, - ATTR_FOLDERS, - ATTR_HOMEASSISTANT, - ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, - ATTR_INPUT, - ATTR_LOCATION, - ATTR_PASSWORD, ATTR_REPOSITORIES, - ATTR_SLUG, DATA_ADDONS_LIST, DATA_COMPONENT, DATA_CONFIG_STORE, @@ -118,7 +92,6 @@ from .const import ( DATA_SUPERVISOR_INFO, DOMAIN, HASSIO_UPDATE_INTERVAL, - SupervisorEntityModel, ) from .coordinator import ( HassioDataUpdateCoordinator, @@ -136,15 +109,11 @@ from .coordinator import ( get_supervisor_stats, ) from .discovery import async_setup_discovery_view -from .handler import ( - HassIO, - HassioAPIError, - async_update_diagnostics, - get_supervisor_client, -) +from .handler import HassIO, async_update_diagnostics, get_supervisor_client from .http import HassIOView from .ingress import async_setup_ingress_view from .issues import SupervisorIssues +from .services import async_setup_services from .websocket_api import async_load_websocket_api # Expose the future safe name now so integrations can use it @@ -190,23 +159,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SERVICE_ADDON_START = "addon_start" -SERVICE_ADDON_STOP = "addon_stop" -SERVICE_ADDON_RESTART = "addon_restart" -SERVICE_ADDON_STDIN = "addon_stdin" -SERVICE_APP_START = "app_start" -SERVICE_APP_STOP = "app_stop" -SERVICE_APP_RESTART = "app_restart" -SERVICE_APP_STDIN = "app_stdin" -SERVICE_HOST_SHUTDOWN = "host_shutdown" -SERVICE_HOST_REBOOT = "host_reboot" -SERVICE_BACKUP_FULL = "backup_full" -SERVICE_BACKUP_PARTIAL = "backup_partial" -SERVICE_RESTORE_FULL = "restore_full" -SERVICE_RESTORE_PARTIAL = "restore_partial" -SERVICE_MOUNT_RELOAD = "mount_reload" - -VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) DEPRECATION_URL = ( "https://www.home-assistant.io/blog/2025/05/22/" @@ -214,148 +166,11 @@ DEPRECATION_URL = ( ) -def valid_addon(value: Any) -> str: - """Validate value is a valid addon slug.""" - value = VALID_ADDON_SLUG(value) - hass = async_get_hass_or_none() - - if hass and (addons := get_addons_info(hass)) is not None and value not in addons: - raise vol.Invalid("Not a valid app slug") - return value - - -SCHEMA_NO_DATA = vol.Schema({}) - -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) - -SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( - {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} -) - -SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon}) - -SCHEMA_APP_STDIN = SCHEMA_APP.extend( - {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} -) - -SCHEMA_BACKUP_FULL = vol.Schema( - { - vol.Optional( - ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") - ): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_COMPRESSED): cv.boolean, - vol.Optional(ATTR_LOCATION): vol.All( - cv.string, lambda v: None if v == "/backup" else v - ), - vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, - } -) - -SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( - { - vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, - vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - # Legacy "addons", "apps" is preferred - vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - } -) - -SCHEMA_RESTORE_FULL = vol.Schema( - { - vol.Required(ATTR_SLUG): cv.slug, - vol.Optional(ATTR_PASSWORD): cv.string, - } -) - -SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( - { - vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, - vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - # Legacy "addons", "apps" is preferred - vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - } -) - -SCHEMA_MOUNT_RELOAD = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector( - selector.DeviceSelectorConfig( - filter=selector.DeviceFilterSelectorConfig( - integration=DOMAIN, - model=SupervisorEntityModel.MOUNT, - ) - ) - ) - } -) - - def _is_32_bit() -> bool: size = struct.calcsize("P") return size * 8 == 32 -class APIEndpointSettings(NamedTuple): - """Settings for API endpoint.""" - - command: str - schema: vol.Schema - timeout: int | None = 60 - pass_data: bool = False - - -MAP_SERVICE_API = { - # Legacy addon services - SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), - SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), - SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), - SERVICE_ADDON_STDIN: APIEndpointSettings( - "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN - ), - # New app services - SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP), - SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP), - SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP), - SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN), - SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA), - SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA), - SERVICE_BACKUP_FULL: APIEndpointSettings( - "/backups/new/full", - SCHEMA_BACKUP_FULL, - None, - True, - ), - SERVICE_BACKUP_PARTIAL: APIEndpointSettings( - "/backups/new/partial", - SCHEMA_BACKUP_PARTIAL, - None, - True, - ), - SERVICE_RESTORE_FULL: APIEndpointSettings( - "/backups/{slug}/restore/full", - SCHEMA_RESTORE_FULL, - None, - True, - ), - SERVICE_RESTORE_PARTIAL: APIEndpointSettings( - "/backups/{slug}/restore/partial", - SCHEMA_RESTORE_PARTIAL, - None, - True, - ), -} - HARDWARE_INTEGRATIONS = { "green": "homeassistant_green", "odroid-c2": "hardkernel", @@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) - hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host) + hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host) supervisor_client = get_supervisor_client(hass) try: @@ -510,74 +325,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) issues_task = hass.async_create_task(issues.setup(), eager_start=True) - async def async_service_handler(service: ServiceCall) -> None: - """Handle service calls for Hass.io.""" - api_endpoint = MAP_SERVICE_API[service.service] - - data = service.data.copy() - addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None) - slug = data.pop(ATTR_SLUG, None) - - if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None): - data[ATTR_ADDONS] = addons - - payload = None - - # Pass data to Hass.io API - if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN): - payload = data[ATTR_INPUT] - elif api_endpoint.pass_data: - payload = data - - # Call API - # The exceptions are logged properly in hassio.send_command - with suppress(HassioAPIError): - await hassio.send_command( - api_endpoint.command.format(addon=addon, slug=slug), - payload=payload, - timeout=api_endpoint.timeout, - ) - - for service, settings in MAP_SERVICE_API.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings.schema - ) - - dev_reg = dr.async_get(hass) - - async def async_mount_reload(service: ServiceCall) -> None: - """Handle service calls for Hass.io.""" - coordinator: HassioDataUpdateCoordinator | None = None - - if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="mount_reload_unknown_device_id", - ) - - if ( - device.name is None - or device.model != SupervisorEntityModel.MOUNT - or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None - or coordinator.entry_id not in device.config_entries - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="mount_reload_invalid_device", - ) - - try: - await supervisor_client.mounts.reload_mount(device.name) - except SupervisorError as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="mount_reload_error", - translation_placeholders={"name": device.name, "error": str(error)}, - ) from error - - hass.services.async_register( - DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD - ) + # Register services + async_setup_services(hass, supervisor_client) async def update_info_data(_: datetime | None = None) -> None: """Update last available supervisor information.""" diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index f176967923f..9a4841b4bc9 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -26,7 +26,7 @@ from aiohasupervisor.models import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .handler import HassioAPIError, get_supervisor_client +from .handler import get_supervisor_client type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] type _ReturnFuncType[_T, **_P, _R] = Callable[ @@ -36,18 +36,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[ def api_error[_AddonManagerT: AddonManager, **_P, _R]( error_message: str, - *, - expected_error_type: type[HassioAPIError | SupervisorError] | None = None, ) -> Callable[ [_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R] ]: - """Handle HassioAPIError and raise a specific AddonError.""" - error_type = expected_error_type or (HassioAPIError, SupervisorError) + """Handle SupervisorError and raise a specific AddonError.""" - def handle_hassio_api_error( + def handle_supervisor_error( func: _FuncType[_AddonManagerT, _P, _R], ) -> _ReturnFuncType[_AddonManagerT, _P, _R]: - """Handle a HassioAPIError.""" + """Handle a SupervisorError.""" @wraps(func) async def wrapper( @@ -56,7 +53,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R]( """Wrap an add-on manager method.""" try: return_value = await func(self, *args, **kwargs) - except error_type as err: + except SupervisorError as err: raise AddonError( f"{error_message.format(addon_name=self.addon_name)}: {err}" ) from err @@ -65,7 +62,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R]( return wrapper - return handle_hassio_api_error + return handle_supervisor_error @dataclass @@ -128,10 +125,7 @@ class AddonManager: ) ) - @api_error( - "Failed to get the {addon_name} app discovery info", - expected_error_type=SupervisorError, - ) + @api_error("Failed to get the {addon_name} app discovery info") async def async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" discovery_info = next( @@ -148,10 +142,7 @@ class AddonManager: return discovery_info.config - @api_error( - "Failed to get the {addon_name} app info", - expected_error_type=SupervisorError, - ) + @api_error("Failed to get the {addon_name} app info") async def async_get_addon_info(self) -> AddonInfo: """Return and cache manager add-on info.""" addon_store_info = await self._supervisor_client.store.addon_info( @@ -199,19 +190,14 @@ class AddonManager: version=addon_info.version, ) - @api_error( - "Failed to set the {addon_name} app options", - expected_error_type=SupervisorError, - ) + @api_error("Failed to set the {addon_name} app options") async def async_set_addon_options(self, config: dict) -> None: """Set manager add-on options.""" await self._supervisor_client.addons.set_addon_options( self.addon_slug, AddonsOptions(config=config) ) - @api_error( - "Failed to install the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to install the {addon_name} app") async def async_install_addon(self) -> None: """Install the managed add-on.""" try: @@ -221,10 +207,7 @@ class AddonManager: f"{self.addon_name} app is not available: {err!s}" ) from None - @api_error( - "Failed to uninstall the {addon_name} app", - expected_error_type=SupervisorError, - ) + @api_error("Failed to uninstall the {addon_name} app") async def async_uninstall_addon(self) -> None: """Uninstall the managed add-on.""" await self._supervisor_client.addons.uninstall_addon(self.addon_slug) @@ -259,31 +242,22 @@ class AddonManager: self.addon_slug, StoreAddonUpdate(backup=False) ) - @api_error( - "Failed to start the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to start the {addon_name} app") async def async_start_addon(self) -> None: """Start the managed add-on.""" await self._supervisor_client.addons.start_addon(self.addon_slug) - @api_error( - "Failed to restart the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to restart the {addon_name} app") async def async_restart_addon(self) -> None: """Restart the managed add-on.""" await self._supervisor_client.addons.restart_addon(self.addon_slug) - @api_error( - "Failed to stop the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to stop the {addon_name} app") async def async_stop_addon(self) -> None: """Stop the managed add-on.""" await self._supervisor_client.addons.stop_addon(self.addon_slug) - @api_error( - "Failed to create a backup of the {addon_name} app", - expected_error_type=SupervisorError, - ) + @api_error("Failed to create a backup of the {addon_name} app") async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None: """Create a partial backup of the managed add-on.""" if addon_info: diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py new file mode 100644 index 00000000000..bd9076141d9 --- /dev/null +++ b/homeassistant/components/hassio/services.py @@ -0,0 +1,439 @@ +"""Set up Supervisor services.""" + +from collections.abc import Awaitable, Callable +import json +import re +from typing import Any + +from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor.models import ( + FullBackupOptions, + FullRestoreOptions, + PartialBackupOptions, + PartialRestoreOptions, +) +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + async_get_hass_or_none, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + selector, +) +from homeassistant.util.dt import now + +from .const import ( + ADDONS_COORDINATOR, + ATTR_ADDON, + ATTR_ADDONS, + ATTR_APP, + ATTR_APPS, + ATTR_COMPRESSED, + ATTR_FOLDERS, + ATTR_HOMEASSISTANT, + ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, + ATTR_INPUT, + ATTR_LOCATION, + ATTR_PASSWORD, + ATTR_SLUG, + DOMAIN, + SupervisorEntityModel, +) +from .coordinator import HassioDataUpdateCoordinator, get_addons_info + +SERVICE_ADDON_START = "addon_start" +SERVICE_ADDON_STOP = "addon_stop" +SERVICE_ADDON_RESTART = "addon_restart" +SERVICE_ADDON_STDIN = "addon_stdin" +SERVICE_APP_START = "app_start" +SERVICE_APP_STOP = "app_stop" +SERVICE_APP_RESTART = "app_restart" +SERVICE_APP_STDIN = "app_stdin" +SERVICE_HOST_SHUTDOWN = "host_shutdown" +SERVICE_HOST_REBOOT = "host_reboot" +SERVICE_BACKUP_FULL = "backup_full" +SERVICE_BACKUP_PARTIAL = "backup_partial" +SERVICE_RESTORE_FULL = "restore_full" +SERVICE_RESTORE_PARTIAL = "restore_partial" +SERVICE_MOUNT_RELOAD = "mount_reload" + + +VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) + + +def valid_addon(value: Any) -> str: + """Validate value is a valid addon slug.""" + value = VALID_ADDON_SLUG(value) + hass = async_get_hass_or_none() + + if hass and (addons := get_addons_info(hass)) is not None and value not in addons: + raise vol.Invalid("Not a valid app slug") + return value + + +SCHEMA_NO_DATA = vol.Schema({}) + +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) + +SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( + {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} +) + +SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon}) + +SCHEMA_APP_STDIN = SCHEMA_APP.extend( + {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} +) + +SCHEMA_BACKUP_FULL = vol.Schema( + { + vol.Optional( + ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") + ): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_COMPRESSED): cv.boolean, + vol.Optional(ATTR_LOCATION): vol.All( + cv.string, lambda v: None if v == "/backup" else v + ), + vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, + } +) + +SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( + { + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All( + cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + ), + vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + # Legacy "addons", "apps" is preferred + vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + } +) + +SCHEMA_RESTORE_FULL = vol.Schema( + { + vol.Required(ATTR_SLUG): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, + } +) + +SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( + { + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All( + cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + ), + vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + # Legacy "addons", "apps" is preferred + vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + } +) + +SCHEMA_MOUNT_RELOAD = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector( + selector.DeviceSelectorConfig( + filter=selector.DeviceFilterSelectorConfig( + integration=DOMAIN, + model=SupervisorEntityModel.MOUNT, + ) + ) + ) + } +) + + +@callback +def async_setup_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register the Supervisor services.""" + async_register_app_services(hass, supervisor_client) + async_register_host_services(hass, supervisor_client) + async_register_backup_restore_services(hass, supervisor_client) + async_register_network_storage_services(hass, supervisor_client) + + +@callback +def async_register_app_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register app services.""" + simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = { + SERVICE_APP_START: ("start", supervisor_client.addons.start_addon), + SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon), + SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon), + } + + async def async_simple_app_service_handler(service: ServiceCall) -> None: + """Handles app services which only take a slug and have no response.""" + action, api_method = simple_app_services[service.service] + app_slug = service.data[ATTR_APP] + + try: + await api_method(app_slug) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to {action} app {app_slug}: {err}" + ) from err + + for service in simple_app_services: + hass.services.async_register( + DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP + ) + + async def async_app_stdin_service_handler(service: ServiceCall) -> None: + """Handles app stdin service.""" + app_slug = service.data[ATTR_APP] + data: dict | str = service.data[ATTR_INPUT] + + # For backwards compatibility the payload here must be valid json + # This is sensible when a dictionary is provided, it must be serialized + # If user provides a string though, we wrap it in quotes before encoding + # This is purely for legacy reasons, Supervisor has no json requirement + # Supervisor just hands the raw request as binary to the container + data = json.dumps(data) + payload = data.encode(encoding="utf-8") + + try: + await supervisor_client.addons.write_addon_stdin(app_slug, payload) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to write stdin to app {app_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_APP_STDIN, + async_app_stdin_service_handler, + schema=SCHEMA_APP_STDIN, + ) + + # LEGACY - Register equivalent addon services for compatibility + simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = { + SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon), + SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon), + SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon), + } + + async def async_simple_addon_service_handler(service: ServiceCall) -> None: + """Handles addon services which only take a slug and have no response.""" + action, api_method = simple_addon_services[service.service] + addon_slug = service.data[ATTR_ADDON] + + try: + await api_method(addon_slug) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to {action} app {addon_slug}: {err}" + ) from err + + for service in simple_addon_services: + hass.services.async_register( + DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON + ) + + async def async_addon_stdin_service_handler(service: ServiceCall) -> None: + """Handles addon stdin service.""" + addon_slug = service.data[ATTR_ADDON] + data: dict | str = service.data[ATTR_INPUT] + + # See explanation for why we make strings into json in async_app_stdin_service_handler + data = json.dumps(data) + payload = data.encode(encoding="utf-8") + + try: + await supervisor_client.addons.write_addon_stdin(addon_slug, payload) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to write stdin to app {addon_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_ADDON_STDIN, + async_addon_stdin_service_handler, + schema=SCHEMA_ADDON_STDIN, + ) + + +@callback +def async_register_host_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register host services.""" + simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = { + SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot), + SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown), + } + + async def async_simple_host_service_handler(service: ServiceCall) -> None: + """Handler for host services that take no input and return no response.""" + action, api_method = simple_host_services[service.service] + try: + await api_method() + except SupervisorError as err: + raise HomeAssistantError(f"Failed to {action} the host: {err}") from err + + for service in simple_host_services: + hass.services.async_register( + DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA + ) + + +@callback +def async_register_backup_restore_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register backup and restore services.""" + + async def async_full_backup_service_handler( + service: ServiceCall, + ) -> ServiceResponse: + """Handler for create full backup service. Returns the new backup's ID.""" + options = FullBackupOptions(**service.data) + try: + backup = await supervisor_client.backups.full_backup(options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to create full backup {options.name}: {err}" + ) from err + + return {"backup": backup.slug} + + hass.services.async_register( + DOMAIN, + SERVICE_BACKUP_FULL, + async_full_backup_service_handler, + schema=SCHEMA_BACKUP_FULL, + supports_response=SupportsResponse.OPTIONAL, + ) + + async def async_partial_backup_service_handler( + service: ServiceCall, + ) -> ServiceResponse: + """Handler for create partial backup service. Returns the new backup's ID.""" + data = service.data.copy() + if ATTR_APPS in data: + data[ATTR_ADDONS] = data.pop(ATTR_APPS) + options = PartialBackupOptions(**data) + + try: + backup = await supervisor_client.backups.partial_backup(options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to create partial backup {options.name}: {err}" + ) from err + + return {"backup": backup.slug} + + hass.services.async_register( + DOMAIN, + SERVICE_BACKUP_PARTIAL, + async_partial_backup_service_handler, + schema=SCHEMA_BACKUP_PARTIAL, + supports_response=SupportsResponse.OPTIONAL, + ) + + async def async_full_restore_service_handler(service: ServiceCall) -> None: + """Handler for full restore service.""" + backup_slug = service.data[ATTR_SLUG] + options: FullRestoreOptions | None = None + if ATTR_PASSWORD in service.data: + options = FullRestoreOptions(password=service.data[ATTR_PASSWORD]) + + try: + await supervisor_client.backups.full_restore(backup_slug, options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to full restore from backup {backup_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_RESTORE_FULL, + async_full_restore_service_handler, + schema=SCHEMA_RESTORE_FULL, + ) + + async def async_partial_restore_service_handler(service: ServiceCall) -> None: + """Handler for partial restore service.""" + data = service.data.copy() + backup_slug = data.pop(ATTR_SLUG) + if ATTR_APPS in data: + data[ATTR_ADDONS] = data.pop(ATTR_APPS) + options = PartialRestoreOptions(**data) + + try: + await supervisor_client.backups.partial_restore(backup_slug, options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to partial restore from backup {backup_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_RESTORE_PARTIAL, + async_partial_restore_service_handler, + schema=SCHEMA_RESTORE_PARTIAL, + ) + + +@callback +def async_register_network_storage_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register network storage (or mount) services.""" + dev_reg = dr.async_get(hass) + + async def async_mount_reload(service: ServiceCall) -> None: + """Handle service calls for Hass.io.""" + coordinator: HassioDataUpdateCoordinator | None = None + + if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mount_reload_unknown_device_id", + ) + + if ( + device.name is None + or device.model != SupervisorEntityModel.MOUNT + or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None + or coordinator.entry_id not in device.config_entries + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mount_reload_invalid_device", + ) + + try: + await supervisor_client.mounts.reload_mount(device.name) + except SupervisorError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mount_reload_error", + translation_placeholders={"name": device.name, "error": str(error)}, + ) from error + + hass.services.async_register( + DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD + ) diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 534106c4957..21b8dbf8e12 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) -from . import HassioAPIError from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, @@ -40,6 +39,7 @@ from .const import ( WS_TYPE_SUBSCRIBE, ) from .coordinator import get_addons_list +from .handler import HassioAPIError from .update_helper import update_addon, update_core SCHEMA_WEBSOCKET_EVENT = vol.Schema( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 071d616735d..7723674d335 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,7 +5,8 @@ from datetime import timedelta import os from pathlib import PurePath from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, call, patch +from uuid import uuid4 from aiohasupervisor import SupervisorError from aiohasupervisor.models import ( @@ -13,6 +14,7 @@ from aiohasupervisor.models import ( AddonStage, AddonState, CIFSMountResponse, + FullBackupOptions, HomeAssistantOptions, InstalledAddon, InstalledAddonComplete, @@ -20,6 +22,9 @@ from aiohasupervisor.models import ( MountState, MountType, MountUsage, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, SupervisorOptions, ) from freezegun.api import FrozenDateTimeFactory @@ -54,7 +59,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.yaml import load_yaml_dict from tests.common import MockConfigEntry, async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @@ -461,7 +465,6 @@ async def test_service_register(hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2021-11-13 11:48:00") async def test_service_calls( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, supervisor_client: AsyncMock, supervisor_is_connected: AsyncMock, app_or_addon: str, @@ -472,21 +475,7 @@ async def test_service_calls( assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() - aioclient_mock.post("http://127.0.0.1/addons/test/start", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/restart", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/update", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/backups/new/full", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/backups/new/partial", json={"result": "ok"}) - aioclient_mock.post( - "http://127.0.0.1/backups/test/restore/full", json={"result": "ok"} - ) - aioclient_mock.post( - "http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"} - ) + supervisor_client.reset_mock() await hass.services.async_call( "hassio", f"{app_or_addon}_start", {app_or_addon: "test"} @@ -500,64 +489,90 @@ async def test_service_calls( await hass.services.async_call( "hassio", f"{app_or_addon}_stdin", {app_or_addon: "test", "input": "test"} ) + await hass.services.async_call( + "hassio", + f"{app_or_addon}_stdin", + {app_or_addon: "test", "input": {"hello": "world"}}, + ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27 - assert aioclient_mock.mock_calls[-1][2] == "test" + supervisor_client.addons.start_addon.assert_called_once_with("test") + supervisor_client.addons.stop_addon.assert_called_once_with("test") + supervisor_client.addons.restart_addon.assert_called_once_with("test") + assert ( + call("test", b'"test"') in supervisor_client.addons.write_addon_stdin.mock_calls + ) + assert ( + call("test", b'{"hello": "world"}') + in supervisor_client.addons.write_addon_stdin.mock_calls + ) await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 + supervisor_client.host.shutdown.assert_called_once_with() + supervisor_client.host.reboot.assert_called_once_with() - await hass.services.async_call("hassio", "backup_full", {}) - await hass.services.async_call( + supervisor_client.backups.full_backup.return_value = NewBackup( + job_id=uuid4(), slug="full" + ) + supervisor_client.backups.partial_backup.return_value = NewBackup( + job_id=uuid4(), slug="partial" + ) + + full_backup = await hass.services.async_call( + "hassio", "backup_full", {}, blocking=True, return_response=True + ) + supervisor_client.backups.full_backup.assert_called_once_with( + FullBackupOptions(name="2021-11-13 03:48:00") + ) + assert full_backup == {"backup": "full"} + + partial_backup = await hass.services.async_call( "hassio", "backup_partial", { "homeassistant": True, - "apps": ["test"], + f"{app_or_addon}s": ["test"], "folders": ["ssl"], "password": "123456", }, + blocking=True, + return_response=True, ) - await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 - # API receives "addons" even when we pass "apps" - assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 03:48:00", - "homeassistant": True, - "addons": ["test"], - "folders": ["ssl"], - "password": "123456", - } + supervisor_client.backups.partial_backup.assert_called_once_with( + PartialBackupOptions( + name="2021-11-13 03:48:00", + homeassistant=True, + addons={"test"}, + folders={"ssl"}, + password="123456", + ) + ) + assert partial_backup == {"backup": "partial"} await hass.services.async_call("hassio", "restore_full", {"slug": "test"}) - await hass.async_block_till_done() - await hass.services.async_call( "hassio", "restore_partial", { "slug": "test", "homeassistant": False, - "apps": ["test"], + f"{app_or_addon}s": ["test"], "folders": ["ssl"], "password": "123456", }, ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33 - # API receives "addons" even when we pass "apps" - assert aioclient_mock.mock_calls[-1][2] == { - "addons": ["test"], - "folders": ["ssl"], - "homeassistant": False, - "password": "123456", - } + supervisor_client.backups.full_restore.assert_called_once_with("test", None) + supervisor_client.backups.partial_restore.assert_called_once_with( + "test", + PartialRestoreOptions( + homeassistant=False, addons={"test"}, folders={"ssl"}, password="123456" + ), + ) await hass.services.async_call( "hassio", @@ -569,13 +584,13 @@ async def test_service_calls( }, ) await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 34 - assert aioclient_mock.mock_calls[-1][2] == { - "name": "backup_name", - "location": "backup_share", - "homeassistant_exclude_database": True, - } + supervisor_client.backups.full_backup.assert_called_with( + FullBackupOptions( + name="backup_name", + location="backup_share", + homeassistant_exclude_database=True, + ) + ) await hass.services.async_call( "hassio", @@ -585,12 +600,9 @@ async def test_service_calls( }, ) await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 35 - assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 03:48:00", - "location": None, - } + supervisor_client.backups.full_backup.assert_called_with( + FullBackupOptions(name="2021-11-13 03:48:00", location=None) + ) # check backup with different timezone await hass.config.async_update(time_zone="Europe/London") @@ -604,12 +616,9 @@ async def test_service_calls( }, ) await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 37 - assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 11:48:00", - "location": None, - } + supervisor_client.backups.full_backup.assert_called_with( + FullBackupOptions(name="2021-11-13 11:48:00", location=None) + ) @pytest.mark.parametrize( @@ -673,7 +682,6 @@ async def test_service_calls_apps_addons_exclusive( "app_or_addon", ["app", "addon"], ) -@pytest.mark.usefixtures("aioclient_mock") async def test_addon_service_call_with_complex_slug( hass: HomeAssistant, supervisor_is_connected: AsyncMock, @@ -714,26 +722,22 @@ async def test_addon_service_call_with_complex_slug( @pytest.mark.usefixtures("hassio_env") async def test_service_calls_core( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - supervisor_client: AsyncMock, + hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: """Call core service and check the API calls behind that.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "hassio", {}) - aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"}) - await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + supervisor_client.homeassistant.stop.assert_called_once_with() + assert len(supervisor_client.mock_calls) == 20 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert len(supervisor_client.mock_calls) == 20 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -742,7 +746,46 @@ async def test_service_calls_core( await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 21 + supervisor_client.homeassistant.restart.assert_called_once_with() + assert len(supervisor_client.mock_calls) == 21 + + +@pytest.mark.parametrize( + "app_or_addon", + ["apps", "addons"], +) +@pytest.mark.usefixtures("hassio_env", "supervisor_client") +async def test_invalid_service_calls_app_duplicates( + hass: HomeAssistant, app_or_addon: str +) -> None: + """Test invalid backup/restore service calls due to duplicates in apps list.""" + assert await async_setup_component(hass, "hassio", {}) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "backup_partial", {app_or_addon: ["test", "test"]} + ) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "restore_partial", {app_or_addon: ["test", "test"]} + ) + + +@pytest.mark.usefixtures("hassio_env", "supervisor_client") +async def test_invalid_service_calls_folder_duplicates(hass: HomeAssistant) -> None: + """Test invalid backup/restore service calls due to duplicates in folder list.""" + assert await async_setup_component(hass, "hassio", {}) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "backup_partial", {"folders": ["ssl", "ssl"]} + ) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "restore_partial", {"folders": ["ssl", "ssl"]} + ) @pytest.mark.usefixtures("addon_installed")