From b52f90187b8feff9cd9a5076daf382114d62a423 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 27 Mar 2019 17:20:05 +0100 Subject: [PATCH] Make homeassistant container constant (#808) * Make homeassistant container constant * Update homeassistant.py * Update homeassistant.py * Update interface.py * Update homeassistant.py * Fix handling * add start function * Add typing * Fix lint * Add API call * Update logs * Fix some issue with watchdog * Fix lint --- API.md | 1 + hassio/api/__init__.py | 58 +++++----- hassio/api/homeassistant.py | 90 ++++++++------- hassio/core.py | 24 ++-- hassio/docker/hassos_cli.py | 7 +- hassio/docker/homeassistant.py | 60 +++++----- hassio/docker/interface.py | 122 +++++++++++++++++---- hassio/homeassistant.py | 195 +++++++++++++++++++-------------- hassio/tasks.py | 4 +- 9 files changed, 351 insertions(+), 210 deletions(-) diff --git a/API.md b/API.md index a8905a943..e54496400 100644 --- a/API.md +++ b/API.md @@ -376,6 +376,7 @@ Output is the raw Docker log. - POST `/homeassistant/check` - POST `/homeassistant/start` - POST `/homeassistant/stop` +- POST `/homeassistant/rebuild` - POST `/homeassistant/options` diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 4f60380e5..c105500e6 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -1,23 +1,24 @@ """Init file for Hass.io RESTful API.""" import logging from pathlib import Path +from typing import Optional from aiohttp import web +from ..coresys import CoreSys, CoreSysAttributes from .addons import APIAddons from .auth import APIAuth from .discovery import APIDiscovery -from .homeassistant import APIHomeAssistant from .hardware import APIHardware -from .host import APIHost from .hassos import APIHassOS +from .homeassistant import APIHomeAssistant +from .host import APIHost from .info import APIInfo from .proxy import APIProxy -from .supervisor import APISupervisor -from .snapshots import APISnapshots -from .services import APIServices from .security import SecurityMiddleware -from ..coresys import CoreSysAttributes +from .services import APIServices +from .snapshots import APISnapshots +from .supervisor import APISupervisor _LOGGER = logging.getLogger(__name__) @@ -25,18 +26,18 @@ _LOGGER = logging.getLogger(__name__) class RestAPI(CoreSysAttributes): """Handle RESTful API for Hass.io.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize Docker base wrapper.""" - self.coresys = coresys - self.security = SecurityMiddleware(coresys) - self.webapp = web.Application( + self.coresys: CoreSys = coresys + self.security: SecurityMiddleware = SecurityMiddleware(coresys) + self.webapp: web.Application = web.Application( middlewares=[self.security.token_validation]) # service stuff - self._runner = web.AppRunner(self.webapp) - self._site = None + self._runner: web.AppRunner = web.AppRunner(self.webapp) + self._site: Optional[web.TCPSite] = None - async def load(self): + async def load(self) -> None: """Register REST API Calls.""" self._register_supervisor() self._register_host() @@ -52,7 +53,7 @@ class RestAPI(CoreSysAttributes): self._register_info() self._register_auth() - def _register_host(self): + def _register_host(self) -> None: """Register hostcontrol functions.""" api_host = APIHost() api_host.coresys = self.coresys @@ -72,7 +73,7 @@ class RestAPI(CoreSysAttributes): api_host.service_reload), ]) - def _register_hassos(self): + def _register_hassos(self) -> None: """Register HassOS functions.""" api_hassos = APIHassOS() api_hassos.coresys = self.coresys @@ -84,7 +85,7 @@ class RestAPI(CoreSysAttributes): web.post('/hassos/config/sync', api_hassos.config_sync), ]) - def _register_hardware(self): + def _register_hardware(self) -> None: """Register hardware functions.""" api_hardware = APIHardware() api_hardware.coresys = self.coresys @@ -94,7 +95,7 @@ class RestAPI(CoreSysAttributes): web.get('/hardware/audio', api_hardware.audio), ]) - def _register_info(self): + def _register_info(self) -> None: """Register info functions.""" api_info = APIInfo() api_info.coresys = self.coresys @@ -103,7 +104,7 @@ class RestAPI(CoreSysAttributes): web.get('/info', api_info.info), ]) - def _register_auth(self): + def _register_auth(self) -> None: """Register auth functions.""" api_auth = APIAuth() api_auth.coresys = self.coresys @@ -112,7 +113,7 @@ class RestAPI(CoreSysAttributes): web.post('/auth', api_auth.auth), ]) - def _register_supervisor(self): + def _register_supervisor(self) -> None: """Register Supervisor functions.""" api_supervisor = APISupervisor() api_supervisor.coresys = self.coresys @@ -127,7 +128,7 @@ class RestAPI(CoreSysAttributes): web.post('/supervisor/options', api_supervisor.options), ]) - def _register_homeassistant(self): + def _register_homeassistant(self) -> None: """Register Home Assistant functions.""" api_hass = APIHomeAssistant() api_hass.coresys = self.coresys @@ -142,9 +143,10 @@ class RestAPI(CoreSysAttributes): web.post('/homeassistant/stop', api_hass.stop), web.post('/homeassistant/start', api_hass.start), web.post('/homeassistant/check', api_hass.check), + web.post('/homeassistant/rebuild', api_hass.rebuild), ]) - def _register_proxy(self): + def _register_proxy(self) -> None: """Register Home Assistant API Proxy.""" api_proxy = APIProxy() api_proxy.coresys = self.coresys @@ -158,7 +160,7 @@ class RestAPI(CoreSysAttributes): web.get('/homeassistant/api/', api_proxy.api), ]) - def _register_addons(self): + def _register_addons(self) -> None: """Register Add-on functions.""" api_addons = APIAddons() api_addons.coresys = self.coresys @@ -184,7 +186,7 @@ class RestAPI(CoreSysAttributes): web.get('/addons/{addon}/stats', api_addons.stats), ]) - def _register_snapshots(self): + def _register_snapshots(self) -> None: """Register snapshots functions.""" api_snapshots = APISnapshots() api_snapshots.coresys = self.coresys @@ -204,7 +206,7 @@ class RestAPI(CoreSysAttributes): web.get('/snapshots/{snapshot}/download', api_snapshots.download), ]) - def _register_services(self): + def _register_services(self) -> None: """Register services functions.""" api_services = APIServices() api_services.coresys = self.coresys @@ -216,7 +218,7 @@ class RestAPI(CoreSysAttributes): web.delete('/services/{service}', api_services.del_service), ]) - def _register_discovery(self): + def _register_discovery(self) -> None: """Register discovery functions.""" api_discovery = APIDiscovery() api_discovery.coresys = self.coresys @@ -228,7 +230,7 @@ class RestAPI(CoreSysAttributes): web.post('/discovery', api_discovery.set_discovery), ]) - def _register_panel(self): + def _register_panel(self) -> None: """Register panel for Home Assistant.""" panel_dir = Path(__file__).parent.joinpath("panel") @@ -256,7 +258,7 @@ class RestAPI(CoreSysAttributes): # This route is for HA > 0.70 self.webapp.add_routes([web.static('/app', panel_dir)]) - async def start(self): + async def start(self) -> None: """Run RESTful API webserver.""" await self._runner.setup() self._site = web.TCPSite( @@ -270,7 +272,7 @@ class RestAPI(CoreSysAttributes): else: _LOGGER.info("Start API on %s", self.sys_docker.network.supervisor) - async def stop(self): + async def stop(self) -> None: """Stop RESTful API webserver.""" if not self._site: return diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index d30c986a2..619858d06 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -1,15 +1,34 @@ """Init file for Hass.io Home Assistant RESTful API.""" import asyncio import logging +from typing import Coroutine, Dict, Any import voluptuous as vol +from aiohttp import web from ..const import ( - ATTR_ARCH, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_BOOT, ATTR_CPU_PERCENT, - ATTR_CUSTOM, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_MACHINE, ATTR_MEMORY_LIMIT, - ATTR_MEMORY_USAGE, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_PASSWORD, - ATTR_PORT, ATTR_REFRESH_TOKEN, ATTR_SSL, ATTR_VERSION, ATTR_WAIT_BOOT, - ATTR_WATCHDOG, CONTENT_TYPE_BINARY) + ATTR_ARCH, + ATTR_BLK_READ, + ATTR_BLK_WRITE, + ATTR_BOOT, + ATTR_CPU_PERCENT, + ATTR_CUSTOM, + ATTR_IMAGE, + ATTR_LAST_VERSION, + ATTR_MACHINE, + ATTR_MEMORY_LIMIT, + ATTR_MEMORY_USAGE, + ATTR_NETWORK_RX, + ATTR_NETWORK_TX, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_REFRESH_TOKEN, + ATTR_SSL, + ATTR_VERSION, + ATTR_WAIT_BOOT, + ATTR_WATCHDOG, + CONTENT_TYPE_BINARY, +) from ..coresys import CoreSysAttributes from ..exceptions import APIError from ..validate import DOCKER_IMAGE, NETWORK_PORT @@ -18,37 +37,28 @@ from .utils import api_process, api_process_raw, api_validate _LOGGER = logging.getLogger(__name__) # pylint: disable=no-value-for-parameter -SCHEMA_OPTIONS = vol.Schema({ - vol.Optional(ATTR_BOOT): - vol.Boolean(), - vol.Inclusive(ATTR_IMAGE, 'custom_hass'): - vol.Maybe(vol.Coerce(str)), - vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): - vol.Any(None, DOCKER_IMAGE), - vol.Optional(ATTR_PORT): - NETWORK_PORT, - vol.Optional(ATTR_PASSWORD): - vol.Maybe(vol.Coerce(str)), - vol.Optional(ATTR_SSL): - vol.Boolean(), - vol.Optional(ATTR_WATCHDOG): - vol.Boolean(), - vol.Optional(ATTR_WAIT_BOOT): - vol.All(vol.Coerce(int), vol.Range(min=60)), - vol.Optional(ATTR_REFRESH_TOKEN): - vol.Maybe(vol.Coerce(str)), -}) +SCHEMA_OPTIONS = vol.Schema( + { + vol.Optional(ATTR_BOOT): vol.Boolean(), + vol.Inclusive(ATTR_IMAGE, "custom_hass"): vol.Maybe(vol.Coerce(str)), + vol.Inclusive(ATTR_LAST_VERSION, "custom_hass"): vol.Any(None, DOCKER_IMAGE), + vol.Optional(ATTR_PORT): NETWORK_PORT, + vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_SSL): vol.Boolean(), + vol.Optional(ATTR_WATCHDOG): vol.Boolean(), + vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)), + vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)), + } +) -SCHEMA_VERSION = vol.Schema({ - vol.Optional(ATTR_VERSION): vol.Coerce(str), -}) +SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)}) class APIHomeAssistant(CoreSysAttributes): """Handle RESTful API for Home Assistant functions.""" @api_process - async def info(self, request): + async def info(self, request: web.Request) -> Dict[str, Any]: """Return host information.""" return { ATTR_VERSION: self.sys_homeassistant.version, @@ -65,7 +75,7 @@ class APIHomeAssistant(CoreSysAttributes): } @api_process - async def options(self, request): + async def options(self, request: web.Request) -> None: """Set Home Assistant options.""" body = await api_validate(SCHEMA_OPTIONS, request) @@ -81,6 +91,7 @@ class APIHomeAssistant(CoreSysAttributes): if ATTR_PASSWORD in body: self.sys_homeassistant.api_password = body[ATTR_PASSWORD] + self.sys_homeassistant.refresh_token = None if ATTR_SSL in body: self.sys_homeassistant.api_ssl = body[ATTR_SSL] @@ -97,7 +108,7 @@ class APIHomeAssistant(CoreSysAttributes): self.sys_homeassistant.save_data() @api_process - async def stats(self, request): + async def stats(self, request: web.Request) -> Dict[Any, str]: """Return resource information.""" stats = await self.sys_homeassistant.stats() if not stats: @@ -114,7 +125,7 @@ class APIHomeAssistant(CoreSysAttributes): } @api_process - async def update(self, request): + async def update(self, request: web.Request) -> None: """Update Home Assistant.""" body = await api_validate(SCHEMA_VERSION, request) version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version) @@ -122,27 +133,32 @@ class APIHomeAssistant(CoreSysAttributes): await asyncio.shield(self.sys_homeassistant.update(version)) @api_process - def stop(self, request): + def stop(self, request: web.Request) -> Coroutine: """Stop Home Assistant.""" return asyncio.shield(self.sys_homeassistant.stop()) @api_process - def start(self, request): + def start(self, request: web.Request) -> Coroutine: """Start Home Assistant.""" return asyncio.shield(self.sys_homeassistant.start()) @api_process - def restart(self, request): + def restart(self, request: web.Request) -> Coroutine: """Restart Home Assistant.""" return asyncio.shield(self.sys_homeassistant.restart()) + @api_process + def rebuild(self, request: web.Request) -> Coroutine: + """Rebuild Home Assistant.""" + return asyncio.shield(self.sys_homeassistant.rebuild()) + @api_process_raw(CONTENT_TYPE_BINARY) - def logs(self, request): + def logs(self, request: web.Request) -> Coroutine: """Return Home Assistant Docker logs.""" return self.sys_homeassistant.logs() @api_process - async def check(self, request): + async def check(self, request: web.Request) -> None: """Check configuration of Home Assistant.""" result = await self.sys_homeassistant.check_config() if not result.valid: diff --git a/hassio/core.py b/hassio/core.py index d5819d35a..b9ae489ef 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -6,8 +6,12 @@ import logging import async_timeout from .coresys import CoreSysAttributes -from .const import (STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, - STARTUP_INITIALIZE) +from .const import ( + STARTUP_SYSTEM, + STARTUP_SERVICES, + STARTUP_APPLICATION, + STARTUP_INITIALIZE, +) from .exceptions import HassioError, HomeAssistantError _LOGGER = logging.getLogger(__name__) @@ -108,7 +112,7 @@ class HassIO(CoreSysAttributes): await self.sys_tasks.load() # If landingpage / run upgrade in background - if self.sys_homeassistant.version == 'landingpage': + if self.sys_homeassistant.version == "landingpage": self.sys_create_task(self.sys_homeassistant.install()) _LOGGER.info("Hass.io is up and running") @@ -121,12 +125,14 @@ class HassIO(CoreSysAttributes): # process async stop tasks try: with async_timeout.timeout(10): - await asyncio.wait([ - self.sys_api.stop(), - self.sys_dns.stop(), - self.sys_websession.close(), - self.sys_websession_ssl.close() - ]) + await asyncio.wait( + [ + self.sys_api.stop(), + self.sys_dns.stop(), + self.sys_websession.close(), + self.sys_websession_ssl.close(), + ] + ) except asyncio.TimeoutError: _LOGGER.warning("Force Shutdown!") diff --git a/hassio/docker/hassos_cli.py b/hassio/docker/hassos_cli.py index 315448899..97a0c0a5f 100644 --- a/hassio/docker/hassos_cli.py +++ b/hassio/docker/hassos_cli.py @@ -17,7 +17,7 @@ class DockerHassOSCli(DockerInterface, CoreSysAttributes): """Return name of HassOS CLI image.""" return f"homeassistant/{self.sys_arch.supervisor}-hassio-cli" - def _stop(self): + def _stop(self, remove_container=True): """Don't need stop.""" return True @@ -33,5 +33,6 @@ class DockerHassOSCli(DockerInterface, CoreSysAttributes): else: self._meta = image.attrs - _LOGGER.info("Found HassOS CLI %s with version %s", self.image, - self.version) + _LOGGER.info( + "Found HassOS CLI %s with version %s", self.image, self.version + ) diff --git a/hassio/docker/homeassistant.py b/hassio/docker/homeassistant.py index e219fb163..873015bdf 100644 --- a/hassio/docker/homeassistant.py +++ b/hassio/docker/homeassistant.py @@ -8,7 +8,7 @@ from ..const import ENV_TOKEN, ENV_TIME, LABEL_MACHINE _LOGGER = logging.getLogger(__name__) -HASS_DOCKER_NAME = 'homeassistant' +HASS_DOCKER_NAME = "homeassistant" class DockerHomeAssistant(DockerInterface): @@ -17,8 +17,8 @@ class DockerHomeAssistant(DockerInterface): @property def machine(self): """Return machine of Home Assistant Docker image.""" - if self._meta and LABEL_MACHINE in self._meta['Config']['Labels']: - return self._meta['Config']['Labels'][LABEL_MACHINE] + if self._meta and LABEL_MACHINE in self._meta["Config"]["Labels"]: + return self._meta["Config"]["Labels"][LABEL_MACHINE] return None @property @@ -58,25 +58,29 @@ class DockerHomeAssistant(DockerInterface): privileged=True, init=True, devices=self.devices, - network_mode='host', + network_mode="host", environment={ - 'HASSIO': self.sys_docker.network.supervisor, + "HASSIO": self.sys_docker.network.supervisor, ENV_TIME: self.sys_timezone, ENV_TOKEN: self.sys_homeassistant.hassio_token, }, volumes={ - str(self.sys_config.path_extern_homeassistant): - {'bind': '/config', 'mode': 'rw'}, - str(self.sys_config.path_extern_ssl): - {'bind': '/ssl', 'mode': 'ro'}, - str(self.sys_config.path_extern_share): - {'bind': '/share', 'mode': 'rw'}, - } + str(self.sys_config.path_extern_homeassistant): { + "bind": "/config", + "mode": "rw", + }, + str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"}, + str(self.sys_config.path_extern_share): { + "bind": "/share", + "mode": "rw", + }, + }, ) if ret: - _LOGGER.info("Start homeassistant %s with version %s", - self.image, self.version) + _LOGGER.info( + "Start homeassistant %s with version %s", self.image, self.version + ) return ret @@ -94,17 +98,18 @@ class DockerHomeAssistant(DockerInterface): detach=True, stdout=True, stderr=True, - environment={ - ENV_TIME: self.sys_timezone, - }, + environment={ENV_TIME: self.sys_timezone}, volumes={ - str(self.sys_config.path_extern_homeassistant): - {'bind': '/config', 'mode': 'rw'}, - str(self.sys_config.path_extern_ssl): - {'bind': '/ssl', 'mode': 'ro'}, - str(self.sys_config.path_extern_share): - {'bind': '/share', 'mode': 'ro'}, - } + str(self.sys_config.path_extern_homeassistant): { + "bind": "/config", + "mode": "rw", + }, + str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"}, + str(self.sys_config.path_extern_share): { + "bind": "/share", + "mode": "ro", + }, + }, ) def is_initialize(self): @@ -117,8 +122,13 @@ class DockerHomeAssistant(DockerInterface): Need run inside executor. """ try: - self.sys_docker.containers.get(self.name) + docker_container = self.sys_docker.containers.get(self.name) + docker_image = self.sys_docker.images.get(self.image) except docker.errors.DockerException: return False + # we run on an old image, stop and start it + if docker_container.image.id != docker_image.id: + return False + return True diff --git a/hassio/docker/interface.py b/hassio/docker/interface.py index b427cab53..e227035cb 100644 --- a/hassio/docker/interface.py +++ b/hassio/docker/interface.py @@ -5,10 +5,10 @@ import logging import docker -from .stats import DockerStats -from ..const import LABEL_VERSION, LABEL_ARCH +from ..const import LABEL_ARCH, LABEL_VERSION from ..coresys import CoreSysAttributes from ..utils import process_lock +from .stats import DockerStats _LOGGER = logging.getLogger(__name__) @@ -37,17 +37,17 @@ class DockerInterface(CoreSysAttributes): """Return meta data of configuration for container/image.""" if not self._meta: return {} - return self._meta.get('Config', {}) + return self._meta.get("Config", {}) @property def meta_labels(self): """Return meta data of labels for container/image.""" - return self.meta_config.get('Labels') or {} + return self.meta_config.get("Labels") or {} @property def image(self): """Return name of Docker image.""" - return self.meta_config.get('Image') + return self.meta_config.get("Image") @property def version(self): @@ -80,7 +80,7 @@ class DockerInterface(CoreSysAttributes): _LOGGER.info("Pull image %s tag %s.", image, tag) docker_image = self.sys_docker.images.pull(f"{image}:{tag}") - docker_image.tag(image, tag='latest') + docker_image.tag(image, tag="latest") self._meta = docker_image.attrs except docker.errors.APIError as err: _LOGGER.error("Can't install %s:%s -> %s.", image, tag, err) @@ -125,7 +125,7 @@ class DockerInterface(CoreSysAttributes): return False # container is not running - if docker_container.status != 'running': + if docker_container.status != "running": return False # we run on an old image, stop and start it @@ -152,8 +152,7 @@ class DockerInterface(CoreSysAttributes): except docker.errors.DockerException: return False - _LOGGER.info("Attach to image %s with version %s", self.image, - self.version) + _LOGGER.info("Attach to image %s with version %s", self.image, self.version) return True @@ -170,12 +169,12 @@ class DockerInterface(CoreSysAttributes): raise NotImplementedError() @process_lock - def stop(self): + def stop(self, remove_container=True): """Stop/remove Docker container.""" - return self.sys_run_in_executor(self._stop) + return self.sys_run_in_executor(self._stop, remove_container) - def _stop(self): - """Stop/remove and remove docker container. + def _stop(self, remove_container=True): + """Stop/remove Docker container. Need run inside executor. """ @@ -184,14 +183,39 @@ class DockerInterface(CoreSysAttributes): except docker.errors.DockerException: return False - if docker_container.status == 'running': + if docker_container.status == "running": _LOGGER.info("Stop %s Docker application", self.image) with suppress(docker.errors.DockerException): docker_container.stop(timeout=self.timeout) - with suppress(docker.errors.DockerException): - _LOGGER.info("Clean %s Docker application", self.image) - docker_container.remove(force=True) + if remove_container: + with suppress(docker.errors.DockerException): + _LOGGER.info("Clean %s Docker application", self.image) + docker_container.remove(force=True) + + return True + + @process_lock + def start(self): + """Start Docker container.""" + return self.sys_run_in_executor(self._start) + + def _start(self): + """Start docker container. + + Need run inside executor. + """ + try: + docker_container = self.sys_docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + _LOGGER.info("Start %s", self.image) + try: + docker_container.start() + except docker.errors.DockerException as err: + _LOGGER.error("Can't start %s: %s", self.image, err) + return False return True @@ -208,17 +232,16 @@ class DockerInterface(CoreSysAttributes): # Cleanup container self._stop() - _LOGGER.info("Remove Docker %s with latest and %s", self.image, - self.version) + _LOGGER.info("Remove Docker %s with latest and %s", self.image, self.version) try: with suppress(docker.errors.ImageNotFound): - self.sys_docker.images.remove( - image=f"{self.image}:latest", force=True) + self.sys_docker.images.remove(image=f"{self.image}:latest", force=True) with suppress(docker.errors.ImageNotFound): self.sys_docker.images.remove( - image=f"{self.image}:{self.version}", force=True) + image=f"{self.image}:{self.version}", force=True + ) except docker.errors.DockerException as err: _LOGGER.warning("Can't remove image %s: %s", self.image, err) @@ -239,8 +262,9 @@ class DockerInterface(CoreSysAttributes): """ image = image or self.image - _LOGGER.info("Update Docker %s:%s to %s:%s", self.image, self.version, - image, tag) + _LOGGER.info( + "Update Docker %s:%s to %s:%s", self.image, self.version, image, tag + ) # Update docker image if not self._install(tag, image): @@ -300,6 +324,29 @@ class DockerInterface(CoreSysAttributes): return True + @process_lock + def restart(self): + """Restart docker container.""" + return self.sys_loop.run_in_executor(None, self._restart) + + def _restart(self): + """Restart docker container. + + Need run inside executor. + """ + try: + container = self.sys_docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + _LOGGER.info("Restart %s", self.image) + try: + container.restart(timeout=self.timeout) + except docker.errors.DockerException as err: + _LOGGER.warning("Can't restart %s: %s", self.image, err) + return False + return True + @process_lock def execute_command(self, command): """Create a temporary container and run command.""" @@ -332,3 +379,30 @@ class DockerInterface(CoreSysAttributes): except docker.errors.DockerException as err: _LOGGER.error("Can't read stats from %s: %s", self.name, err) return None + + def is_fails(self): + """Return True if Docker is failing state. + + Return a Future. + """ + return self.sys_run_in_executor(self._is_fails) + + def _is_fails(self): + """Return True if Docker is failing state. + + Need run inside executor. + """ + try: + docker_container = self.sys_docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + # container is not running + if docker_container.status != "exited": + return False + + # Check return value + if int(docker_container.attrs["State"]["ExitCode"]) != 0: + return True + + return False diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index 5b6930cbb..579451089 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -2,26 +2,44 @@ import asyncio from contextlib import asynccontextmanager, suppress from datetime import datetime, timedelta +from ipaddress import IPv4Address import logging import os -import re from pathlib import Path +import re import socket import time +from typing import Any, AsyncContextManager, Coroutine, Dict, Optional +from uuid import UUID import aiohttp from aiohttp import hdrs import attr -from .const import (FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, - ATTR_UUID, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, - ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, - ATTR_ACCESS_TOKEN, HEADER_HA_ACCESS) -from .coresys import CoreSysAttributes +from .const import ( + ATTR_ACCESS_TOKEN, + ATTR_BOOT, + ATTR_IMAGE, + ATTR_LAST_VERSION, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_REFRESH_TOKEN, + ATTR_SSL, + ATTR_UUID, + ATTR_WAIT_BOOT, + ATTR_WATCHDOG, + FILE_HASSIO_HOMEASSISTANT, + HEADER_HA_ACCESS, +) +from .coresys import CoreSys, CoreSysAttributes from .docker.homeassistant import DockerHomeAssistant -from .exceptions import (HomeAssistantUpdateError, HomeAssistantError, - HomeAssistantAPIError, HomeAssistantAuthError) -from .utils import convert_to_ascii, process_lock, create_token +from .exceptions import ( + HomeAssistantAPIError, + HomeAssistantAuthError, + HomeAssistantError, + HomeAssistantUpdateError, +) +from .utils import convert_to_ascii, create_token, process_lock from .utils.json import JsonConfig from .validate import SCHEMA_HASS_CONFIG @@ -40,18 +58,19 @@ class ConfigResult: class HomeAssistant(JsonConfig, CoreSysAttributes): """Home Assistant core object for handle it.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize Home Assistant object.""" super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG) - self.coresys = coresys - self.instance = DockerHomeAssistant(coresys) - self.lock = asyncio.Lock(loop=coresys.loop) - self._error_state = False - # We don't persist access tokens. Instead we fetch new ones when needed - self.access_token = None - self._access_token_expires = None + self.coresys: CoreSys = coresys + self.instance: DockerHomeAssistant = DockerHomeAssistant(coresys) + self.lock: asyncio.Lock = asyncio.Lock(loop=coresys.loop) + self._error_state: bool = False - async def load(self): + # We don't persist access tokens. Instead we fetch new ones when needed + self.access_token: Optional[str] = None + self._access_token_expires: Optional[datetime] = None + + async def load(self) -> None: """Prepare Home Assistant object.""" if await self.instance.attach(): return @@ -60,95 +79,95 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self.install_landingpage() @property - def machine(self): + def machine(self) -> str: """Return the system machines.""" return self.instance.machine @property - def arch(self): + def arch(self) -> str: """Return arch of running Home Assistant.""" return self.instance.arch @property - def error_state(self): + def error_state(self) -> bool: """Return True if system is in error.""" return self._error_state @property - def api_ip(self): + def api_ip(self) -> IPv4Address: """Return IP of Home Assistant instance.""" return self.sys_docker.network.gateway @property - def api_port(self): + def api_port(self) -> int: """Return network port to Home Assistant instance.""" return self._data[ATTR_PORT] @api_port.setter - def api_port(self, value): + def api_port(self, value: int) -> None: """Set network port for Home Assistant instance.""" self._data[ATTR_PORT] = value @property - def api_password(self): + def api_password(self) -> str: """Return password for Home Assistant instance.""" return self._data.get(ATTR_PASSWORD) @api_password.setter - def api_password(self, value): + def api_password(self, value: str): """Set password for Home Assistant instance.""" self._data[ATTR_PASSWORD] = value @property - def api_ssl(self): + def api_ssl(self) -> bool: """Return if we need ssl to Home Assistant instance.""" return self._data[ATTR_SSL] @api_ssl.setter - def api_ssl(self, value): + def api_ssl(self, value: bool): """Set SSL for Home Assistant instance.""" self._data[ATTR_SSL] = value @property - def api_url(self): + def api_url(self) -> str: """Return API url to Home Assistant.""" return "{}://{}:{}".format('https' if self.api_ssl else 'http', self.api_ip, self.api_port) @property - def watchdog(self): + def watchdog(self) -> bool: """Return True if the watchdog should protect Home Assistant.""" return self._data[ATTR_WATCHDOG] @watchdog.setter - def watchdog(self, value): + def watchdog(self, value: bool): """Return True if the watchdog should protect Home Assistant.""" self._data[ATTR_WATCHDOG] = value @property - def wait_boot(self): + def wait_boot(self) -> int: """Return time to wait for Home Assistant startup.""" return self._data[ATTR_WAIT_BOOT] @wait_boot.setter - def wait_boot(self, value): + def wait_boot(self, value: int): """Set time to wait for Home Assistant startup.""" self._data[ATTR_WAIT_BOOT] = value @property - def version(self): + def version(self) -> str: """Return version of running Home Assistant.""" return self.instance.version @property - def last_version(self): + def last_version(self) -> str: """Return last available version of Home Assistant.""" if self.is_custom_image: return self._data.get(ATTR_LAST_VERSION) return self.sys_updater.version_homeassistant @last_version.setter - def last_version(self, value): + def last_version(self, value: str): """Set last available version of Home Assistant.""" if value: self._data[ATTR_LAST_VERSION] = value @@ -156,14 +175,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data.pop(ATTR_LAST_VERSION, None) @property - def image(self): + def image(self) -> str: """Return image name of the Home Assistant container.""" if self._data.get(ATTR_IMAGE): return self._data[ATTR_IMAGE] return os.environ['HOMEASSISTANT_REPOSITORY'] @image.setter - def image(self, value): + def image(self, value: str): """Set image name of Home Assistant container.""" if value: self._data[ATTR_IMAGE] = value @@ -171,43 +190,43 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data.pop(ATTR_IMAGE, None) @property - def is_custom_image(self): + def is_custom_image(self) -> bool: """Return True if a custom image is used.""" return all( attr in self._data for attr in (ATTR_IMAGE, ATTR_LAST_VERSION)) @property - def boot(self): + def boot(self) -> bool: """Return True if Home Assistant boot is enabled.""" return self._data[ATTR_BOOT] @boot.setter - def boot(self, value): + def boot(self, value: bool): """Set Home Assistant boot options.""" self._data[ATTR_BOOT] = value @property - def uuid(self): + def uuid(self) -> UUID: """Return a UUID of this Home Assistant instance.""" return self._data[ATTR_UUID] @property - def hassio_token(self): + def hassio_token(self) -> str: """Return an access token for the Hass.io API.""" return self._data.get(ATTR_ACCESS_TOKEN) @property - def refresh_token(self): + def refresh_token(self) -> str: """Return the refresh token to authenticate with Home Assistant.""" return self._data.get(ATTR_REFRESH_TOKEN) @refresh_token.setter - def refresh_token(self, value): + def refresh_token(self, value: str): """Set Home Assistant refresh_token.""" self._data[ATTR_REFRESH_TOKEN] = value @process_lock - async def install_landingpage(self): + async def install_landingpage(self) -> None: """Install a landing page.""" _LOGGER.info("Setup HomeAssistant landingpage") while True: @@ -217,7 +236,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await asyncio.sleep(30) @process_lock - async def install(self): + async def install(self) -> None: """Install a landing page.""" _LOGGER.info("Setup Home Assistant") while True: @@ -244,7 +263,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self.instance.cleanup() @process_lock - async def update(self, version=None): + async def update(self, version=None) -> None: """Update HomeAssistant version.""" version = version or self.last_version rollback = self.version if not self.error_state else None @@ -258,14 +277,13 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): # process an update async def _update(to_version): """Run Home Assistant update.""" - try: - _LOGGER.info("Update Home Assistant to version %s", to_version) - if not await self.instance.update(to_version): - raise HomeAssistantUpdateError() - finally: - if running: - await self._start() - _LOGGER.info("Successful run Home Assistant %s", to_version) + _LOGGER.info("Update Home Assistant to version %s", to_version) + if not await self.instance.update(to_version): + raise HomeAssistantUpdateError() + + if running: + await self._start() + _LOGGER.info("Successful run Home Assistant %s", to_version) # Update Home Assistant with suppress(HomeAssistantError): @@ -279,7 +297,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): else: raise HomeAssistantUpdateError() - async def _start(self): + async def _start(self) -> None: """Start Home Assistant Docker & wait.""" if await self.instance.is_running(): _LOGGER.warning("Home Assistant is already running!") @@ -294,61 +312,74 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await self._block_till_run() @process_lock - def start(self): - """Run Home Assistant docker. + async def start(self) -> None: + """Run Home Assistant docker.""" + if await self.instance.is_running(): + await self.instance.restart() + elif await self.instance.is_initialize(): + await self.instance.start() + else: + await self._start() + return - Return a coroutine. - """ - return self._start() + await self._block_till_run() @process_lock - def stop(self): + def stop(self) -> Coroutine: """Stop Home Assistant Docker. Return a coroutine. """ - return self.instance.stop() + return self.instance.stop(remove_container=False) @process_lock - async def restart(self): + async def restart(self) -> None: """Restart Home Assistant Docker.""" + if not await self.instance.restart(): + raise HomeAssistantError() + + await self._block_till_run() + + @process_lock + async def rebuild(self) -> None: + """Rebuild Home Assistant Docker container.""" await self.instance.stop() await self._start() - def logs(self): + def logs(self) -> Coroutine: """Get HomeAssistant docker logs. Return a coroutine. """ return self.instance.logs() - def stats(self): + def stats(self) -> Coroutine: """Return stats of Home Assistant. Return a coroutine. """ return self.instance.stats() - def is_running(self): + def is_running(self) -> Coroutine: """Return True if Docker container is running. Return a coroutine. """ return self.instance.is_running() - def is_initialize(self): - """Return True if a Docker container is exists. + def is_fails(self) -> Coroutine: + """Return True if a Docker container is fails state. Return a coroutine. """ - return self.instance.is_initialize() + return self.instance.is_fails() @property - def in_progress(self): + def in_progress(self) -> bool: """Return True if a task is in progress.""" return self.instance.in_progress or self.lock.locked() - async def check_config(self): + async def check_config(self) -> ConfigResult: """Run Home Assistant config check.""" result = await self.instance.execute_command( "python3 -m homeassistant -c /config --script check_config") @@ -367,7 +398,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): _LOGGER.info("Home Assistant config is valid") return ConfigResult(True, log) - async def ensure_access_token(self): + async def ensure_access_token(self) -> None: """Ensures there is an access token.""" if self.access_token is not None and self._access_token_expires > datetime.utcnow(): return @@ -392,12 +423,12 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): @asynccontextmanager async def make_request(self, - method, - path, - json=None, - content_type=None, - data=None, - timeout=30): + method: str, + path: str, + json: Optional[Dict[str, Any]] = None, + content_type: Optional[str] = None, + data: Optional[bytes] = None, + timeout=30) -> AsyncContextManager[aiohttp.ClientResponse]: """Async context manager to make a request with right auth.""" url = f"{self.api_url}/{path}" headers = {} @@ -432,7 +463,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): raise HomeAssistantAPIError() - async def check_api_state(self): + async def check_api_state(self) -> bool: """Return True if Home Assistant up and running.""" with suppress(HomeAssistantAPIError): async with self.make_request('get', 'api/') as resp: @@ -443,7 +474,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): return False - async def _block_till_run(self): + async def _block_till_run(self) -> None: """Block until Home-Assistant is booting up or startup timeout.""" start_time = time.monotonic() migration_progress = False diff --git a/hassio/tasks.py b/hassio/tasks.py index 0752af7fd..1679e42ba 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -94,7 +94,7 @@ class Tasks(CoreSysAttributes): async def _watchdog_homeassistant_docker(self): """Check running state of Docker and start if they is close.""" # if Home Assistant is active - if not await self.sys_homeassistant.is_initialize() or \ + if not await self.sys_homeassistant.is_fails() or \ not self.sys_homeassistant.watchdog or \ self.sys_homeassistant.error_state: return @@ -117,7 +117,7 @@ class Tasks(CoreSysAttributes): a delay in our system. """ # If Home-Assistant is active - if not await self.sys_homeassistant.is_initialize() or \ + if not await self.sys_homeassistant.is_fails() or \ not self.sys_homeassistant.watchdog or \ self.sys_homeassistant.error_state: return