core/homeassistant/components/hassio/addon_manager.py

391 lines
13 KiB
Python

"""Provide add-on management."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from enum import Enum
from functools import partial, wraps
import logging
from typing import Any, Concatenate
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .handler import (
HassioAPIError,
async_create_backup,
async_get_addon_discovery_info,
async_get_addon_info,
async_get_addon_store_info,
async_install_addon,
async_restart_addon,
async_set_addon_options,
async_start_addon,
async_stop_addon,
async_uninstall_addon,
async_update_addon,
)
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
type _ReturnFuncType[_T, **_P, _R] = Callable[
Concatenate[_T, _P], Coroutine[Any, Any, _R]
]
def api_error[_AddonManagerT: AddonManager, **_P, _R](
error_message: str,
) -> Callable[
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
]:
"""Handle HassioAPIError and raise a specific AddonError."""
def handle_hassio_api_error(
func: _FuncType[_AddonManagerT, _P, _R],
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
"""Handle a HassioAPIError."""
@wraps(func)
async def wrapper(
self: _AddonManagerT, *args: _P.args, **kwargs: _P.kwargs
) -> _R:
"""Wrap an add-on manager method."""
try:
return_value = await func(self, *args, **kwargs)
except HassioAPIError as err:
raise AddonError(
f"{error_message.format(addon_name=self.addon_name)}: {err}"
) from err
return return_value
return wrapper
return handle_hassio_api_error
@dataclass
class AddonInfo:
"""Represent the current add-on info state."""
available: bool
hostname: str | None
options: dict[str, Any]
state: AddonState
update_available: bool
version: str | None
class AddonState(Enum):
"""Represent the current state of the add-on."""
NOT_INSTALLED = "not_installed"
INSTALLING = "installing"
UPDATING = "updating"
NOT_RUNNING = "not_running"
RUNNING = "running"
class AddonManager:
"""Manage the add-on.
Methods may raise AddonError.
Only one instance of this class may exist per add-on
to keep track of running add-on tasks.
"""
def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
addon_name: str,
addon_slug: str,
) -> None:
"""Set up the add-on manager."""
self.addon_name = addon_name
self.addon_slug = addon_slug
self._hass = hass
self._logger = logger
self._install_task: asyncio.Task | None = None
self._restart_task: asyncio.Task | None = None
self._start_task: asyncio.Task | None = None
self._update_task: asyncio.Task | None = None
def task_in_progress(self) -> bool:
"""Return True if any of the add-on tasks are in progress."""
return any(
task and not task.done()
for task in (
self._restart_task,
self._install_task,
self._start_task,
self._update_task,
)
)
@api_error("Failed to get the {addon_name} add-on discovery info")
async def async_get_addon_discovery_info(self) -> dict:
"""Return add-on discovery info."""
discovery_info = await async_get_addon_discovery_info(
self._hass, self.addon_slug
)
if not discovery_info:
raise AddonError(f"Failed to get {self.addon_name} add-on discovery info")
discovery_info_config: dict = discovery_info["config"]
return discovery_info_config
@api_error("Failed to get the {addon_name} add-on info")
async def async_get_addon_info(self) -> AddonInfo:
"""Return and cache manager add-on info."""
addon_store_info = await async_get_addon_store_info(self._hass, self.addon_slug)
self._logger.debug("Add-on store info: %s", addon_store_info)
if not addon_store_info["installed"]:
return AddonInfo(
available=addon_store_info["available"],
hostname=None,
options={},
state=AddonState.NOT_INSTALLED,
update_available=False,
version=None,
)
addon_info = await async_get_addon_info(self._hass, self.addon_slug)
addon_state = self.async_get_addon_state(addon_info)
return AddonInfo(
available=addon_info["available"],
hostname=addon_info["hostname"],
options=addon_info["options"],
state=addon_state,
update_available=addon_info["update_available"],
version=addon_info["version"],
)
@callback
def async_get_addon_state(self, addon_info: dict[str, Any]) -> AddonState:
"""Return the current state of the managed add-on."""
addon_state = AddonState.NOT_RUNNING
if addon_info["state"] == "started":
addon_state = AddonState.RUNNING
if self._install_task and not self._install_task.done():
addon_state = AddonState.INSTALLING
if self._update_task and not self._update_task.done():
addon_state = AddonState.UPDATING
return addon_state
@api_error("Failed to set the {addon_name} add-on options")
async def async_set_addon_options(self, config: dict) -> None:
"""Set manager add-on options."""
options = {"options": config}
await async_set_addon_options(self._hass, self.addon_slug, options)
def _check_addon_available(self, addon_info: AddonInfo) -> None:
"""Check if the managed add-on is available."""
if not addon_info.available:
raise AddonError(f"{self.addon_name} add-on is not available")
@api_error("Failed to install the {addon_name} add-on")
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
addon_info = await self.async_get_addon_info()
self._check_addon_available(addon_info)
await async_install_addon(self._hass, self.addon_slug)
@api_error("Failed to uninstall the {addon_name} add-on")
async def async_uninstall_addon(self) -> None:
"""Uninstall the managed add-on."""
await async_uninstall_addon(self._hass, self.addon_slug)
@api_error("Failed to update the {addon_name} add-on")
async def async_update_addon(self) -> None:
"""Update the managed add-on if needed."""
addon_info = await self.async_get_addon_info()
self._check_addon_available(addon_info)
if addon_info.state is AddonState.NOT_INSTALLED:
raise AddonError(f"{self.addon_name} add-on is not installed")
if not addon_info.update_available:
return
await self.async_create_backup()
await async_update_addon(self._hass, self.addon_slug)
@api_error("Failed to start the {addon_name} add-on")
async def async_start_addon(self) -> None:
"""Start the managed add-on."""
await async_start_addon(self._hass, self.addon_slug)
@api_error("Failed to restart the {addon_name} add-on")
async def async_restart_addon(self) -> None:
"""Restart the managed add-on."""
await async_restart_addon(self._hass, self.addon_slug)
@api_error("Failed to stop the {addon_name} add-on")
async def async_stop_addon(self) -> None:
"""Stop the managed add-on."""
await async_stop_addon(self._hass, self.addon_slug)
@api_error("Failed to create a backup of the {addon_name} add-on")
async def async_create_backup(self) -> None:
"""Create a partial backup of the managed add-on."""
addon_info = await self.async_get_addon_info()
name = f"addon_{self.addon_slug}_{addon_info.version}"
self._logger.debug("Creating backup: %s", name)
await async_create_backup(
self._hass,
{"name": name, "addons": [self.addon_slug]},
partial=True,
)
async def async_configure_addon(
self,
addon_config: dict[str, Any],
) -> None:
"""Configure the manager add-on, if needed."""
addon_info = await self.async_get_addon_info()
if addon_info.state is AddonState.NOT_INSTALLED:
raise AddonError(f"{self.addon_name} add-on is not installed")
if addon_config != addon_info.options:
await self.async_set_addon_options(addon_config)
@callback
def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that installs the managed add-on.
Only schedule a new install task if the there's no running task.
"""
if not self._install_task or self._install_task.done():
self._logger.info(
"%s add-on is not installed. Installing add-on", self.addon_name
)
self._install_task = self._async_schedule_addon_operation(
self.async_install_addon, catch_error=catch_error
)
return self._install_task
@callback
def async_schedule_install_setup_addon(
self,
addon_config: dict[str, Any],
catch_error: bool = False,
) -> asyncio.Task:
"""Schedule a task that installs and sets up the managed add-on.
Only schedule a new install task if the there's no running task.
"""
if not self._install_task or self._install_task.done():
self._logger.info(
"%s add-on is not installed. Installing add-on", self.addon_name
)
self._install_task = self._async_schedule_addon_operation(
self.async_install_addon,
partial(
self.async_configure_addon,
addon_config,
),
self.async_start_addon,
catch_error=catch_error,
)
return self._install_task
@callback
def async_schedule_update_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that updates and sets up the managed add-on.
Only schedule a new update task if the there's no running task.
"""
if not self._update_task or self._update_task.done():
self._logger.info("Trying to update the %s add-on", self.addon_name)
self._update_task = self._async_schedule_addon_operation(
self.async_update_addon,
catch_error=catch_error,
)
return self._update_task
@callback
def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that starts the managed add-on.
Only schedule a new start task if the there's no running task.
"""
if not self._start_task or self._start_task.done():
self._logger.info(
"%s add-on is not running. Starting add-on", self.addon_name
)
self._start_task = self._async_schedule_addon_operation(
self.async_start_addon, catch_error=catch_error
)
return self._start_task
@callback
def async_schedule_restart_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that restarts the managed add-on.
Only schedule a new restart task if the there's no running task.
"""
if not self._restart_task or self._restart_task.done():
self._logger.info("Restarting %s add-on", self.addon_name)
self._restart_task = self._async_schedule_addon_operation(
self.async_restart_addon, catch_error=catch_error
)
return self._restart_task
@callback
def async_schedule_setup_addon(
self,
addon_config: dict[str, Any],
catch_error: bool = False,
) -> asyncio.Task:
"""Schedule a task that configures and starts the managed add-on.
Only schedule a new setup task if there's no running task.
"""
if not self._start_task or self._start_task.done():
self._logger.info(
"%s add-on is not running. Starting add-on", self.addon_name
)
self._start_task = self._async_schedule_addon_operation(
partial(
self.async_configure_addon,
addon_config,
),
self.async_start_addon,
catch_error=catch_error,
)
return self._start_task
@callback
def _async_schedule_addon_operation(
self, *funcs: Callable, catch_error: bool = False
) -> asyncio.Task:
"""Schedule an add-on task."""
async def addon_operation() -> None:
"""Do the add-on operation and catch AddonError."""
for func in funcs:
try:
await func()
except AddonError as err:
if not catch_error:
raise
self._logger.error(err)
break
return self._hass.async_create_task(addon_operation(), eager_start=False)
class AddonError(HomeAssistantError):
"""Represent an error with the managed add-on."""