diff --git a/API.md b/API.md index e8ed55d10..cab120df5 100644 --- a/API.md +++ b/API.md @@ -659,7 +659,10 @@ authentication system. Needs an ingress session as cookie. "uuid": "uuid", "config": {} } - ] + ], + "services": { + "ozw": ["core_zwave"] + } } ``` diff --git a/requirements.txt b/requirements.txt index 704eee543..0d982e758 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ colorlog==4.1.0 cpe==1.2.1 cryptography==2.9.2 docker==4.2.0 -gitpython==3.1.2 +gitpython==3.1.3 jinja2==2.11.2 packaging==20.4 ptvsd==4.3.2 diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 34140dfc1..a3f84ede2 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -5,7 +5,7 @@ import logging import tarfile from typing import Dict, List, Optional, Union -from ..const import BOOT_AUTO, STATE_STARTED +from ..const import BOOT_AUTO, STATE_STARTED, AddonStartup from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import ( AddonsError, @@ -78,30 +78,49 @@ class AddonManager(CoreSysAttributes): # Sync DNS await self.sync_dns() - async def boot(self, stage: str) -> None: + async def boot(self, stage: AddonStartup) -> None: """Boot add-ons with mode auto.""" - tasks = [] + tasks: List[Addon] = [] for addon in self.installed: if addon.boot != BOOT_AUTO or addon.startup != stage: continue - tasks.append(addon.start()) + tasks.append(addon) + # Evaluate add-ons which need to be started _LOGGER.info("Phase '%s' start %d add-ons", stage, len(tasks)) - if tasks: - await asyncio.wait(tasks) - await asyncio.sleep(self.sys_config.wait_boot) + if not tasks: + return - async def shutdown(self, stage: str) -> None: + # Start Add-ons sequential + # avoid issue on slow IO + for addon in tasks: + try: + await addon.start() + except Exception as err: # pylint: disable=broad-except + _LOGGER.warning("Can't start Add-on %s: %s", addon.slug, err) + + await asyncio.sleep(self.sys_config.wait_boot) + + async def shutdown(self, stage: AddonStartup) -> None: """Shutdown addons.""" - tasks = [] + tasks: List[Addon] = [] for addon in self.installed: if await addon.state() != STATE_STARTED or addon.startup != stage: continue - tasks.append(addon.stop()) + tasks.append(addon) + # Evaluate add-ons which need to be stopped _LOGGER.info("Phase '%s' stop %d add-ons", stage, len(tasks)) - if tasks: - await asyncio.wait(tasks) + if not tasks: + return + + # Stop Add-ons sequential + # avoid issue on slow IO + for addon in tasks: + try: + await addon.stop() + except Exception as err: # pylint: disable=broad-except + _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) async def install(self, slug: str) -> None: """Install an add-on.""" diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 9c14d47fb..09cbb5f50 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -81,7 +81,7 @@ class Addon(AddonModel): @property def ip_address(self) -> IPv4Address: - """Return IP of Add-on instance.""" + """Return IP of add-on instance.""" return self.instance.ip_address @property @@ -366,7 +366,7 @@ class Addon(AddonModel): write_json_file(self.path_options, options) except vol.Invalid as ex: _LOGGER.error( - "Add-on %s have wrong options: %s", + "Add-on %s has invalid options: %s", self.slug, humanize_error(options, ex), ) @@ -524,7 +524,7 @@ class Addon(AddonModel): Return a coroutine. """ if not self.with_stdin: - _LOGGER.error("Add-on don't support write to stdin!") + _LOGGER.error("Add-on %s does not support writing to stdin!", self.slug) raise AddonsNotSupportedError() try: @@ -622,7 +622,7 @@ class Addon(AddonModel): # If available if not self._available(data[ATTR_SYSTEM]): - _LOGGER.error("Add-on %s is not available for this Platform", self.slug) + _LOGGER.error("Add-on %s is not available for this platform", self.slug) raise AddonsNotSupportedError() # Restore local add-on information @@ -673,7 +673,9 @@ class Addon(AddonModel): try: await self.sys_host.apparmor.load_profile(self.slug, profile_file) except HostAppArmorError: - _LOGGER.error("Can't restore AppArmor profile") + _LOGGER.error( + "Can't restore AppArmor profile for add-on %s", self.slug + ) raise AddonsError() from None # Run add-on diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 2fe2547f5..94731ae10 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -1,4 +1,5 @@ """Init file for Supervisor add-ons.""" +from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Awaitable, Dict, List, Optional @@ -64,6 +65,7 @@ from ..const import ( SECURITY_DISABLE, SECURITY_PROFILE, AddonStages, + AddonStartup, ) from ..coresys import CoreSys, CoreSysAttributes from .validate import RE_SERVICE, RE_VOLUME, schema_ui_options, validate_options @@ -71,7 +73,7 @@ from .validate import RE_SERVICE, RE_VOLUME, schema_ui_options, validate_options Data = Dict[str, Any] -class AddonModel(CoreSysAttributes): +class AddonModel(CoreSysAttributes, ABC): """Add-on Data layout.""" def __init__(self, coresys: CoreSys, slug: str): @@ -80,19 +82,19 @@ class AddonModel(CoreSysAttributes): self.slug: str = slug @property + @abstractmethod def data(self) -> Data: - """Return Add-on config/data.""" - raise NotImplementedError() + """Return add-on config/data.""" @property + @abstractmethod def is_installed(self) -> bool: """Return True if an add-on is installed.""" - raise NotImplementedError() @property + @abstractmethod def is_detached(self) -> bool: """Return True if add-on is detached.""" - raise NotImplementedError() @property def available(self) -> bool: @@ -193,9 +195,9 @@ class AddonModel(CoreSysAttributes): return True @property - def startup(self) -> Optional[str]: + def startup(self) -> AddonStartup: """Return startup type of add-on.""" - return self.data.get(ATTR_STARTUP) + return self.data[ATTR_STARTUP] @property def advanced(self) -> bool: diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index a44eac36e..246b18b46 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -85,12 +85,10 @@ from ..const import ( PRIVILEGED_ALL, ROLE_ALL, ROLE_DEFAULT, - STARTUP_ALL, - STARTUP_APPLICATION, - STARTUP_SERVICES, STATE_STARTED, STATE_STOPPED, AddonStages, + AddonStartup, ) from ..coresys import CoreSys from ..discovery.validate import valid_discovery_service @@ -169,12 +167,12 @@ MACHINE_ALL = [ ] -def _simple_startup(value): +def _simple_startup(value) -> str: """Define startup schema.""" if value == "before": - return STARTUP_SERVICES + return AddonStartup.SERVICES.value if value == "after": - return STARTUP_APPLICATION + return AddonStartup.APPLICATION.value return value @@ -188,7 +186,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema( vol.Required(ATTR_ARCH): [vol.In(ARCH_ALL)], vol.Optional(ATTR_MACHINE): [vol.In(MACHINE_ALL)], vol.Optional(ATTR_URL): vol.Url(), - vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.In(STARTUP_ALL)), + vol.Required(ATTR_STARTUP): vol.All(_simple_startup, vol.Coerce(AddonStartup)), vol.Required(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), vol.Optional(ATTR_INIT, default=True): vol.Boolean(), vol.Optional(ATTR_ADVANCED, default=False): vol.Boolean(), diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index eba1c5689..4ac5e1902 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -137,10 +137,10 @@ class APIAddons(CoreSysAttributes): addon = self.sys_addons.get(addon_slug) if not addon: - raise APIError("Addon does not exist") + raise APIError(f"Addon {addon_slug} does not exist", addon_slug) if check_installed and not addon.is_installed: - raise APIError("Addon is not installed") + raise APIError(f"Addon {addon.slug} is not installed") return addon @@ -398,7 +398,7 @@ class APIAddons(CoreSysAttributes): """Return icon from add-on.""" addon: AnyAddon = self._extract_addon(request, check_installed=False) if not addon.with_icon: - raise APIError("No icon found!") + raise APIError(f"No icon found for add-on {addon.slug}!") with addon.path_icon.open("rb") as png: return png.read() @@ -408,7 +408,7 @@ class APIAddons(CoreSysAttributes): """Return logo from add-on.""" addon: AnyAddon = self._extract_addon(request, check_installed=False) if not addon.with_logo: - raise APIError("No logo found!") + raise APIError(f"No logo found for add-on {addon.slug}!") with addon.path_logo.open("rb") as png: return png.read() @@ -418,7 +418,7 @@ class APIAddons(CoreSysAttributes): """Return changelog from add-on.""" addon: AnyAddon = self._extract_addon(request, check_installed=False) if not addon.with_changelog: - raise APIError("No changelog found!") + raise APIError(f"No changelog found for add-on {addon.slug}!") with addon.path_changelog.open("r") as changelog: return changelog.read() @@ -428,7 +428,7 @@ class APIAddons(CoreSysAttributes): """Return documentation from add-on.""" addon: AnyAddon = self._extract_addon(request, check_installed=False) if not addon.with_documentation: - raise APIError("No documentation found!") + raise APIError(f"No documentation found for add-on {addon.slug}!") with addon.path_documentation.open("r") as documentation: return documentation.read() @@ -438,7 +438,7 @@ class APIAddons(CoreSysAttributes): """Write to stdin of add-on.""" addon: AnyAddon = self._extract_addon(request) if not addon.with_stdin: - raise APIError("STDIN not supported by add-on") + raise APIError(f"STDIN not supported the {addon.slug} add-on") data = await request.read() await asyncio.shield(addon.write_stdin(data)) diff --git a/supervisor/api/discovery.py b/supervisor/api/discovery.py index b6077942c..d606e6136 100644 --- a/supervisor/api/discovery.py +++ b/supervisor/api/discovery.py @@ -5,6 +5,7 @@ from ..const import ( ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, + ATTR_SERVICES, ATTR_SERVICE, ATTR_UUID, REQUEST_FROM, @@ -42,6 +43,7 @@ class APIDiscovery(CoreSysAttributes): """Show register services.""" self._check_permission_ha(request) + # Get available discovery discovery = [] for message in self.sys_discovery.list_messages: discovery.append( @@ -53,7 +55,13 @@ class APIDiscovery(CoreSysAttributes): } ) - return {ATTR_DISCOVERY: discovery} + # Get available services/add-ons + services = {} + for addon in self.sys_addons.all: + for name in addon.discovery: + services.setdefault(name, []).append(addon.slug) + + return {ATTR_DISCOVERY: discovery, ATTR_SERVICES: services} @api_process async def set_discovery(self, request): diff --git a/supervisor/api/proxy.py b/supervisor/api/proxy.py index 99548f2e0..812390ff4 100644 --- a/supervisor/api/proxy.py +++ b/supervisor/api/proxy.py @@ -75,6 +75,8 @@ class APIProxy(CoreSysAttributes): async def stream(self, request: web.Request): """Proxy HomeAssistant EventStream Requests.""" self._check_access(request) + if not await self.sys_homeassistant.check_api_state(): + raise HTTPBadGateway() _LOGGER.info("Home Assistant EventStream start") async with self._api_client(request, "stream", timeout=None) as client: @@ -94,6 +96,8 @@ class APIProxy(CoreSysAttributes): async def api(self, request: web.Request): """Proxy Home Assistant API Requests.""" self._check_access(request) + if not await self.sys_homeassistant.check_api_state(): + raise HTTPBadGateway() # Normal request path = request.match_info.get("path", "") @@ -153,6 +157,8 @@ class APIProxy(CoreSysAttributes): async def websocket(self, request: web.Request): """Initialize a WebSocket API connection.""" + if not await self.sys_homeassistant.check_api_state(): + raise HTTPBadGateway() _LOGGER.info("Home Assistant WebSocket API request initialize") # init server diff --git a/supervisor/const.py b/supervisor/const.py index c09bf55e6..25377ad76 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -3,7 +3,7 @@ from enum import Enum from ipaddress import ip_network from pathlib import Path -SUPERVISOR_VERSION = "225" +SUPERVISOR_VERSION = "226" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" @@ -245,20 +245,6 @@ PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" WANT_SERVICE = "want" -STARTUP_INITIALIZE = "initialize" -STARTUP_SYSTEM = "system" -STARTUP_SERVICES = "services" -STARTUP_APPLICATION = "application" -STARTUP_ONCE = "once" - -STARTUP_ALL = [ - STARTUP_ONCE, - STARTUP_INITIALIZE, - STARTUP_SYSTEM, - STARTUP_SERVICES, - STARTUP_APPLICATION, -] - BOOT_AUTO = "auto" BOOT_MANUAL = "manual" @@ -339,6 +325,16 @@ CHAN_ID = "chan_id" CHAN_TYPE = "chan_type" +class AddonStartup(str, Enum): + """Startup types of Add-on.""" + + INITIALIZE = "initialize" + SYSTEM = "system" + SERVICES = "services" + APPLICATION = "application" + ONCE = "once" + + class AddonStages(str, Enum): """Stage types of add-on.""" diff --git a/supervisor/core.py b/supervisor/core.py index aa6cda192..5e000a161 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -5,13 +5,7 @@ import logging import async_timeout -from .const import ( - STARTUP_APPLICATION, - STARTUP_INITIALIZE, - STARTUP_SERVICES, - STARTUP_SYSTEM, - CoreStates, -) +from .const import AddonStartup, CoreStates from .coresys import CoreSys, CoreSysAttributes from .exceptions import HassioError, HomeAssistantError, SupervisorUpdateError @@ -133,7 +127,7 @@ class Core(CoreSysAttributes): ) # Start addon mark as initialize - await self.sys_addons.boot(STARTUP_INITIALIZE) + await self.sys_addons.boot(AddonStartup.INITIALIZE) try: # HomeAssistant is already running / supervisor have only reboot @@ -145,10 +139,10 @@ class Core(CoreSysAttributes): self.sys_services.reset() # start addon mark as system - await self.sys_addons.boot(STARTUP_SYSTEM) + await self.sys_addons.boot(AddonStartup.SYSTEM) # start addon mark as services - await self.sys_addons.boot(STARTUP_SERVICES) + await self.sys_addons.boot(AddonStartup.SERVICES) # run HomeAssistant if ( @@ -161,7 +155,7 @@ class Core(CoreSysAttributes): _LOGGER.info("Skip start of Home Assistant") # start addon mark as application - await self.sys_addons.boot(STARTUP_APPLICATION) + await self.sys_addons.boot(AddonStartup.APPLICATION) # store new last boot self._update_last_boot() @@ -214,22 +208,24 @@ class Core(CoreSysAttributes): async def shutdown(self): """Shutdown all running containers in correct order.""" # don't process scheduler anymore - self.state = CoreStates.STOPPING + if self.state == CoreStates.RUNNING: + self.state = CoreStates.STOPPING # Shutdown Application Add-ons, using Home Assistant API - await self.sys_addons.shutdown(STARTUP_APPLICATION) + await self.sys_addons.shutdown(AddonStartup.APPLICATION) # Close Home Assistant with suppress(HassioError): await self.sys_homeassistant.stop() # Shutdown System Add-ons - await self.sys_addons.shutdown(STARTUP_SERVICES) - await self.sys_addons.shutdown(STARTUP_SYSTEM) - await self.sys_addons.shutdown(STARTUP_INITIALIZE) + await self.sys_addons.shutdown(AddonStartup.SERVICES) + await self.sys_addons.shutdown(AddonStartup.SYSTEM) + await self.sys_addons.shutdown(AddonStartup.INITIALIZE) # Shutdown all Plugins - await self.sys_plugins.shutdown() + if self.state == CoreStates.STOPPING: + await self.sys_plugins.shutdown() def _update_last_boot(self): """Update last boot time.""" diff --git a/supervisor/discovery/__init__.py b/supervisor/discovery/__init__.py index 4f7426c30..060b90528 100644 --- a/supervisor/discovery/__init__.py +++ b/supervisor/discovery/__init__.py @@ -118,7 +118,7 @@ class Discovery(CoreSysAttributes, JsonConfig): async def _push_discovery(self, message: Message, command: str) -> None: """Send a discovery request.""" if not await self.sys_homeassistant.check_api_state(): - _LOGGER.info("Discovery %s mesage ignore", message.uuid) + _LOGGER.info("Discovery %s message ignore", message.uuid) return data = attr.asdict(message) diff --git a/supervisor/hassos.py b/supervisor/hassos.py index d307ea33a..0ba2f54d5 100644 --- a/supervisor/hassos.py +++ b/supervisor/hassos.py @@ -97,7 +97,7 @@ class HassOS(CoreSysAttributes): if cpe.get_product()[0] != "hassos": raise NotImplementedError() except NotImplementedError: - _LOGGER.warning("No Home Assistant Operating-System found!") + _LOGGER.warning("No Home Assistant Operating System found!") return else: self._available = True diff --git a/supervisor/homeassistant.py b/supervisor/homeassistant.py index 0aeba636e..f2b43b6bf 100644 --- a/supervisor/homeassistant.py +++ b/supervisor/homeassistant.py @@ -374,6 +374,10 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self.instance.run() except DockerAPIError: raise HomeAssistantError() from None + + # Don't block for landingpage + if self.version == "landingpage": + return await self._block_till_run() @process_lock @@ -559,12 +563,21 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): async def check_api_state(self) -> bool: """Return True if Home Assistant up and running.""" + # Check if port is up + if not await self.sys_run_in_executor( + check_port, self.ip_address, self.api_port + ): + return False + + # Check if API is up with suppress(HomeAssistantAPIError): - async with self.make_request("get", "api/") as resp: + async with self.make_request("get", "api/config") as resp: if resp.status in (200, 201): - return True - status = resp.status - _LOGGER.warning("Home Assistant API config mismatch: %s", status) + data = await resp.json() + if data.get("state", "RUNNING") == "RUNNING": + return True + else: + _LOGGER.debug("Home Assistant API return: %d", resp.status) return False @@ -589,9 +602,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): break # 2: Check if API response - if await self.sys_run_in_executor( - check_port, self.ip_address, self.api_port - ): + if await self.check_api_state(): _LOGGER.info("Detect a running Home Assistant instance") self._error_state = False return diff --git a/supervisor/misc/tasks.py b/supervisor/misc/tasks.py index 412c52ab7..e3610517c 100644 --- a/supervisor/misc/tasks.py +++ b/supervisor/misc/tasks.py @@ -1,9 +1,9 @@ """A collection of tasks.""" -import asyncio import logging from ..coresys import CoreSysAttributes from ..exceptions import ( + AddonsError, AudioError, CliError, CoreDNSError, @@ -131,24 +131,26 @@ class Tasks(CoreSysAttributes): async def _update_addons(self): """Check if an update is available for an Add-on and update it.""" - tasks = [] for addon in self.sys_addons.all: if not addon.is_installed or not addon.auto_update: continue + # Evaluate available updates if addon.version == addon.latest_version: continue - - if addon.test_update_schema(): - tasks.append(addon.update()) - else: + if not addon.test_update_schema(): _LOGGER.warning( "Add-on %s will be ignored, schema tests fails", addon.slug ) + continue - if tasks: - _LOGGER.info("Add-on auto update process %d tasks", len(tasks)) - await asyncio.wait(tasks) + # Run Add-on update sequential + # avoid issue on slow IO + _LOGGER.info("Add-on auto update process %s", addon.slug) + try: + await addon.update() + except AddonsError: + _LOGGER.error("Can't auto update Add-on %s", addon.slug) async def _update_supervisor(self): """Check and run update of Supervisor Supervisor.""" diff --git a/supervisor/plugins/__init__.py b/supervisor/plugins/__init__.py index 674a3eef9..81a746c30 100644 --- a/supervisor/plugins/__init__.py +++ b/supervisor/plugins/__init__.py @@ -51,9 +51,17 @@ class PluginManager(CoreSysAttributes): async def load(self): """Load Supervisor plugins.""" - await asyncio.wait( - [self.dns.load(), self.audio.load(), self.cli.load(), self.multicast.load()] - ) + # Sequential to avoid issue on slow IO + for plugin in ( + self.dns, + self.audio, + self.cli, + self.multicast, + ): + try: + await plugin.load() + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Can't load plugin %s", type(plugin).__name__) # Check requirements for plugin, required_version in ( @@ -103,11 +111,14 @@ class PluginManager(CoreSysAttributes): async def shutdown(self) -> None: """Shutdown Supervisor plugin.""" - await asyncio.wait( - [ - self.dns.stop(), - self.audio.stop(), - self.cli.stop(), - self.multicast.stop(), - ] - ) + # Sequential to avoid issue on slow IO + for plugin in ( + self.audio, + self.cli, + self.multicast, + self.dns, + ): + try: + await plugin.stop() + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Can't stop plugin %s", type(plugin).__name__)