diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..a4dce4c2b --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,4 @@ +template: | + ## What's Changed + + $CHANGES diff --git a/.travis.yml b/.travis.yml index e16f6a9fb..137d61017 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,6 @@ -sudo: false -matrix: - fast_finish: true - include: - - python: "3.6" - -cache: - directories: - - $HOME/.cache/pip +sudo: true +dist: xenial install: pip install -U tox language: python +python: 3.7 script: tox diff --git a/API.md b/API.md index 47ad52f69..f3a6c0935 100644 --- a/API.md +++ b/API.md @@ -273,7 +273,9 @@ return: ```json { "version": "2.3", + "version_cli": "7", "version_latest": "2.4", + "version_cli_latest": "8", "board": "ova|rpi" } ``` @@ -285,6 +287,13 @@ return: } ``` +- POST `/hassos/update/cli` +```json +{ + "version": "optional" +} +``` + - POST `/hassos/config/sync` Load host configs from a USB stick. diff --git a/Dockerfile b/Dockerfile index 568280d71..38543f9ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ ENV LANG C.UTF-8 # Setup base RUN apk add --no-cache \ - python3 \ git \ socat \ glib \ @@ -14,12 +13,11 @@ RUN apk add --no-cache \ eudev-libs \ && apk add --no-cache --virtual .build-dependencies \ make \ - python3-dev \ g++ \ && pip3 install --no-cache-dir \ uvloop==0.10.2 \ cchardet==2.1.1 \ - pycryptodome==3.4.11 \ + pycryptodome==3.6.4 \ && apk del .build-dependencies # Install HassIO diff --git a/hassio/__main__.py b/hassio/__main__.py index b64bad447..129b46e29 100644 --- a/hassio/__main__.py +++ b/hassio/__main__.py @@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor import logging import sys -import hassio.bootstrap as bootstrap +from hassio import bootstrap _LOGGER = logging.getLogger(__name__) diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 51804ca26..bc0383fab 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -14,7 +14,7 @@ from voluptuous.humanize import humanize_error from .validate import ( validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME, RE_SERVICE) -from .utils import check_installed +from .utils import check_installed, remove_data from ..const import ( ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP, ATTR_OPTIONS, ATTR_PORTS, ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY, @@ -636,7 +636,7 @@ class Addon(CoreSysAttributes): if self.path_data.is_dir(): _LOGGER.info( "Remove Home-Assistant addon data folder %s", self.path_data) - shutil.rmtree(str(self.path_data)) + await remove_data(self.path_data) # Cleanup audio settings if self.path_asound.exists(): @@ -856,12 +856,12 @@ class Addon(CoreSysAttributes): # restore data def _restore_data(): """Restore data.""" - if self.path_data.is_dir(): - shutil.rmtree(str(self.path_data), ignore_errors=True) shutil.copytree(str(Path(temp, "data")), str(self.path_data)) + _LOGGER.info("Restore data for addon %s", self._id) + if self.path_data.is_dir(): + await remove_data(self.path_data) try: - _LOGGER.info("Restore data for addon %s", self._id) await self.sys_run_in_executor(_restore_data) except shutil.Error as err: _LOGGER.error("Can't restore origin data: %s", err) diff --git a/hassio/addons/utils.py b/hassio/addons/utils.py index f7a2a9514..c876312d0 100644 --- a/hassio/addons/utils.py +++ b/hassio/addons/utils.py @@ -1,4 +1,5 @@ """Util addons functions.""" +import asyncio import hashlib import logging import re @@ -33,3 +34,20 @@ def check_installed(method): return await method(addon, *args, **kwargs) return wrap_check + + +async def remove_data(folder): + """Remove folder and reset privileged.""" + try: + proc = await asyncio.create_subprocess_exec( + "rm", "-rf", str(folder), + stdout=asyncio.subprocess.DEVNULL + ) + + _, error_msg = await proc.communicate() + except OSError as err: + error_msg = str(err) + + if proc.returncode == 0: + return + _LOGGER.error("Can't remove Add-on Data: %s", error_msg) diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index f5866c930..c5cf8eee6 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -76,6 +76,7 @@ class RestAPI(CoreSysAttributes): self.webapp.add_routes([ web.get('/hassos/info', api_hassos.info), web.post('/hassos/update', api_hassos.update), + web.post('/hassos/update/cli', api_hassos.update_cli), web.post('/hassos/config/sync', api_hassos.config_sync), ]) diff --git a/hassio/api/hassos.py b/hassio/api/hassos.py index c5faeefaf..ac8166027 100644 --- a/hassio/api/hassos.py +++ b/hassio/api/hassos.py @@ -5,7 +5,9 @@ import logging import voluptuous as vol from .utils import api_process, api_validate -from ..const import ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST +from ..const import ( + ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST, ATTR_VERSION_CLI, + ATTR_VERSION_CLI_LATEST) from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) @@ -23,7 +25,9 @@ class APIHassOS(CoreSysAttributes): """Return hassos information.""" return { ATTR_VERSION: self.sys_hassos.version, + ATTR_VERSION_CLI: self.sys_hassos.version_cli, ATTR_VERSION_LATEST: self.sys_hassos.version_latest, + ATTR_VERSION_CLI_LATEST: self.sys_hassos.version_cli_latest, ATTR_BOARD: self.sys_hassos.board, } @@ -35,6 +39,14 @@ class APIHassOS(CoreSysAttributes): await asyncio.shield(self.sys_hassos.update(version)) + @api_process + async def update_cli(self, request): + """Update HassOS CLI.""" + body = await api_validate(SCHEMA_VERSION, request) + version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest) + + await asyncio.shield(self.sys_hassos.update_cli(version)) + @api_process def config_sync(self, request): """Trigger config reload on HassOS.""" diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index dd5e9698b..0cda6dcdb 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -10,7 +10,7 @@ from ..const import ( ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT, ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_WAIT_BOOT, ATTR_MACHINE, - CONTENT_TYPE_BINARY) + ATTR_REFRESH_TOKEN, CONTENT_TYPE_BINARY) from ..coresys import CoreSysAttributes from ..validate import NETWORK_PORT, DOCKER_IMAGE @@ -30,6 +30,8 @@ SCHEMA_OPTIONS = vol.Schema({ vol.Optional(ATTR_WATCHDOG): vol.Boolean(), vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)), + # Required once we enforce user system + vol.Optional(ATTR_REFRESH_TOKEN): str, }) SCHEMA_VERSION = vol.Schema({ @@ -83,8 +85,10 @@ class APIHomeAssistant(CoreSysAttributes): if ATTR_WAIT_BOOT in body: self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT] + if ATTR_REFRESH_TOKEN in body: + self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN] + self.sys_homeassistant.save_data() - return True @api_process async def stats(self, request): @@ -109,11 +113,7 @@ class APIHomeAssistant(CoreSysAttributes): body = await api_validate(SCHEMA_VERSION, request) version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version) - if version == self.sys_homeassistant.version: - raise RuntimeError("Version {} is already in use".format(version)) - - return await asyncio.shield( - self.sys_homeassistant.update(version)) + await asyncio.shield(self.sys_homeassistant.update(version)) @api_process def stop(self, request): @@ -123,7 +123,7 @@ class APIHomeAssistant(CoreSysAttributes): @api_process def start(self, request): """Start homeassistant.""" - return asyncio.shield(self.sys_homeassistant.start()) + asyncio.shield(self.sys_homeassistant.start()) @api_process def restart(self, request): diff --git a/hassio/api/proxy.py b/hassio/api/proxy.py index a6e32e87b..24c4b72aa 100644 --- a/hassio/api/proxy.py +++ b/hassio/api/proxy.py @@ -1,15 +1,18 @@ """Utils for HomeAssistant Proxy.""" import asyncio +from contextlib import asynccontextmanager import logging import aiohttp from aiohttp import web -from aiohttp.web_exceptions import HTTPBadGateway, HTTPInternalServerError +from aiohttp.web_exceptions import ( + HTTPBadGateway, HTTPInternalServerError, HTTPUnauthorized) from aiohttp.hdrs import CONTENT_TYPE import async_timeout from ..const import HEADER_HA_ACCESS from ..coresys import CoreSysAttributes +from ..exceptions import HomeAssistantAuthError, HomeAssistantAPIError _LOGGER = logging.getLogger(__name__) @@ -23,49 +26,43 @@ class APIProxy(CoreSysAttributes): addon = self.sys_addons.from_uuid(hassio_token) if not addon: - _LOGGER.warning("Unknown Home-Assistant API access!") + _LOGGER.warning("Unknown HomeAssistant API access!") + elif not addon.access_homeassistant_api: + _LOGGER.warning("Not permitted API access: %s", addon.slug) else: _LOGGER.info("%s access from %s", request.path, addon.slug) + return + raise HTTPUnauthorized() + + @asynccontextmanager async def _api_client(self, request, path, timeout=300): """Return a client request with proxy origin for Home-Assistant.""" - url = f"{self.sys_homeassistant.api_url}/api/{path}" - try: - data = None - headers = {} - method = getattr(self.sys_websession_ssl, request.method.lower()) - params = request.query or None - # read data with async_timeout.timeout(30): data = await request.read() if data: - headers.update({CONTENT_TYPE: request.content_type}) + content_type = request.content_type + else: + content_type = None - # need api password? - if self.sys_homeassistant.api_password: - headers = { - HEADER_HA_ACCESS: self.sys_homeassistant.api_password, - } - - # reset headers - if not headers: - headers = None - - client = await method( - url, data=data, headers=headers, timeout=timeout, - params=params - ) - - return client + async with self.sys_homeassistant.make_request( + request.method.lower(), f'api/{path}', + content_type=content_type, + data=data, + timeout=timeout, + ) as resp: + yield resp + return + except HomeAssistantAuthError: + _LOGGER.error("Authenticate error on API for request %s", path) except aiohttp.ClientError as err: - _LOGGER.error("Client error on API %s request %s.", path, err) - + _LOGGER.error("Client error on API %s request %s", path, err) except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on API request %s.", path) + _LOGGER.error("Client timeout error on API request %s", path) raise HTTPBadGateway() @@ -74,30 +71,25 @@ class APIProxy(CoreSysAttributes): self._check_access(request) _LOGGER.info("Home-Assistant EventStream start") - client = await self._api_client(request, 'stream', timeout=None) + async with self._api_client(request, 'stream', timeout=None) as client: + response = web.StreamResponse() + response.content_type = request.headers.get(CONTENT_TYPE) + try: + await response.prepare(request) + while True: + data = await client.content.read(10) + if not data: + break + await response.write(data) - response = web.StreamResponse() - response.content_type = request.headers.get(CONTENT_TYPE) - try: - await response.prepare(request) - while True: - data = await client.content.read(10) - if not data: - await response.write_eof() - break - await response.write(data) + except aiohttp.ClientError: + pass - except aiohttp.ClientError: - await response.write_eof() + finally: + client.close() + _LOGGER.info("Home-Assistant EventStream close") - except asyncio.CancelledError: - pass - - finally: - client.close() - _LOGGER.info("Home-Assistant EventStream close") - - return response + return response async def api(self, request): """Proxy HomeAssistant API Requests.""" @@ -105,14 +97,13 @@ class APIProxy(CoreSysAttributes): # Normal request path = request.match_info.get('path', '') - client = await self._api_client(request, path) - - data = await client.read() - return web.Response( - body=data, - status=client.status, - content_type=client.content_type - ) + async with self._api_client(request, path) as client: + data = await client.read() + return web.Response( + body=data, + status=client.status, + content_type=client.content_type + ) async def _websocket_client(self): """Initialize a websocket api connection.""" @@ -123,19 +114,44 @@ class APIProxy(CoreSysAttributes): url, heartbeat=60, verify_ssl=False) # handle authentication - for _ in range(2): - data = await client.receive_json() - if data.get('type') == 'auth_ok': - return client - elif data.get('type') == 'auth_required': - await client.send_json({ - 'type': 'auth', - 'api_password': self.sys_homeassistant.api_password, - }) + data = await client.receive_json() - _LOGGER.error("Authentication to Home-Assistant websocket") + if data.get('type') == 'auth_ok': + return client - except (aiohttp.ClientError, RuntimeError) as err: + if data.get('type') != 'auth_required': + # Invalid protocol + _LOGGER.error( + 'Got unexpected response from HA websocket: %s', data) + raise HTTPBadGateway() + + if self.sys_homeassistant.refresh_token: + await self.sys_homeassistant.ensure_access_token() + await client.send_json({ + 'type': 'auth', + 'access_token': self.sys_homeassistant.access_token, + }) + else: + await client.send_json({ + 'type': 'auth', + 'api_password': self.sys_homeassistant.api_password, + }) + + data = await client.receive_json() + + if data.get('type') == 'auth_ok': + return client + + # Renew the Token is invalid + if (data.get('type') == 'invalid_auth' and + self.sys_homeassistant.refresh_token): + self.sys_homeassistant.access_token = None + return await self._websocket_client() + + _LOGGER.error( + "Failed authentication to Home-Assistant websocket: %s", data) + + except (RuntimeError, HomeAssistantAPIError) as err: _LOGGER.error("Client error on websocket API %s.", err) raise HTTPBadGateway() @@ -157,13 +173,19 @@ class APIProxy(CoreSysAttributes): # Check API access response = await server.receive_json() - hassio_token = response.get('api_password') + hassio_token = (response.get('api_password') or + response.get('access_token')) addon = self.sys_addons.from_uuid(hassio_token) - if not addon: + if not addon or not addon.access_homeassistant_api: _LOGGER.warning("Unauthorized websocket access!") - else: - _LOGGER.info("Websocket access from %s", addon.slug) + await server.send_json({ + 'type': 'auth_invalid', + 'message': 'Invalid access', + }) + return server + + _LOGGER.info("Websocket access from %s", addon.slug) await server.send_json({ 'type': 'auth_ok', diff --git a/hassio/const.py b/hassio/const.py index fb51c190d..a0e27a4f2 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -2,7 +2,7 @@ from pathlib import Path from ipaddress import ip_network -HASSIO_VERSION = '115' +HASSIO_VERSION = '116' URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_VERSION = \ @@ -50,7 +50,7 @@ CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_TEXT = 'text/plain' CONTENT_TYPE_TAR = 'application/tar' HEADER_HA_ACCESS = 'x-ha-access' -HEADER_TOKEN = 'X-HASSIO-KEY' +HEADER_TOKEN = 'x-hassio-key' ENV_TOKEN = 'HASSIO_TOKEN' ENV_TIME = 'TZ' @@ -174,6 +174,10 @@ ATTR_DEVICETREE = 'devicetree' ATTR_CPE = 'cpe' ATTR_BOARD = 'board' ATTR_HASSOS = 'hassos' +ATTR_HASSOS_CLI = 'hassos_cli' +ATTR_VERSION_CLI = 'version_cli' +ATTR_VERSION_CLI_LATEST = 'version_cli_latest' +ATTR_REFRESH_TOKEN = 'refresh_token' SERVICE_MQTT = 'mqtt' diff --git a/hassio/core.py b/hassio/core.py index 4181d8f27..d7b846257 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -26,6 +26,9 @@ class HassIO(CoreSysAttributes): self.sys_config.timezone = \ await fetch_timezone(self.sys_websession) + # Load Supervisor + await self.sys_supervisor.load() + # Load DBus await self.sys_dbus.load() @@ -35,9 +38,6 @@ class HassIO(CoreSysAttributes): # Load HassOS await self.sys_hassos.load() - # Load Supervisor - await self.sys_supervisor.load() - # Load Home Assistant await self.sys_homeassistant.load() diff --git a/hassio/docker/hassos_cli.py b/hassio/docker/hassos_cli.py new file mode 100644 index 000000000..e76e9d8a8 --- /dev/null +++ b/hassio/docker/hassos_cli.py @@ -0,0 +1,37 @@ +"""HassOS Cli docker object.""" +import logging + +import docker + +from .interface import DockerInterface +from ..coresys import CoreSysAttributes + +_LOGGER = logging.getLogger(__name__) + + +class DockerHassOSCli(DockerInterface, CoreSysAttributes): + """Docker hassio wrapper for HassOS Cli.""" + + @property + def image(self): + """Return name of HassOS cli image.""" + return f"homeassistant/{self.sys_arch}-hassio-cli" + + def _stop(self): + """Don't need stop.""" + return True + + def _attach(self): + """Attach to running docker container. + Need run inside executor. + """ + try: + image = self.sys_docker.images.get(self.image) + + except docker.errors.DockerException: + _LOGGER.warning("Can't find a HassOS cli %s", self.image) + + else: + self._meta = image.attrs + _LOGGER.info("Found HassOS cli %s with version %s", + self.image, self.version) diff --git a/hassio/docker/supervisor.py b/hassio/docker/supervisor.py index 1a93015e7..66b6d4d94 100644 --- a/hassio/docker/supervisor.py +++ b/hassio/docker/supervisor.py @@ -11,7 +11,7 @@ _LOGGER = logging.getLogger(__name__) class DockerSupervisor(DockerInterface, CoreSysAttributes): - """Docker hassio wrapper for HomeAssistant.""" + """Docker hassio wrapper for Supervisor.""" @property def name(self): diff --git a/hassio/exceptions.py b/hassio/exceptions.py index bfa8fb899..cd9499ba3 100644 --- a/hassio/exceptions.py +++ b/hassio/exceptions.py @@ -1,4 +1,7 @@ """Core Exceptions.""" +import asyncio + +import aiohttp class HassioError(Exception): @@ -11,6 +14,29 @@ class HassioNotSupportedError(HassioError): pass +# HomeAssistant + +class HomeAssistantError(HassioError): + """Home Assistant exception.""" + pass + + +class HomeAssistantUpdateError(HomeAssistantError): + """Error on update of a Home Assistant.""" + pass + + +class HomeAssistantAuthError(HomeAssistantError): + """Home Assistant Auth API exception.""" + pass + + +class HomeAssistantAPIError( + HomeAssistantAuthError, asyncio.TimeoutError, aiohttp.ClientError): + """Home Assistant API exception.""" + pass + + # HassOS class HassOSError(HassioError): diff --git a/hassio/hassos.py b/hassio/hassos.py index e646289df..f48698d35 100644 --- a/hassio/hassos.py +++ b/hassio/hassos.py @@ -7,6 +7,7 @@ from cpe import CPE from .coresys import CoreSysAttributes from .const import URL_HASSOS_OTA +from .docker.hassos_cli import DockerHassOSCli from .exceptions import HassOSNotSupportedError, HassOSUpdateError, DBusError _LOGGER = logging.getLogger(__name__) @@ -18,6 +19,7 @@ class HassOS(CoreSysAttributes): def __init__(self, coresys): """Initialize HassOS handler.""" self.coresys = coresys + self.instance = DockerHassOSCli(coresys) self._available = False self._version = None self._board = None @@ -32,11 +34,31 @@ class HassOS(CoreSysAttributes): """Return version of HassOS.""" return self._version + @property + def version_cli(self): + """Return version of HassOS cli.""" + return self.instance.version + @property def version_latest(self): """Return version of HassOS.""" return self.sys_updater.version_hassos + @property + def version_cli_latest(self): + """Return version of HassOS.""" + return self.sys_updater.version_hassos_cli + + @property + def need_update(self): + """Return true if a HassOS update is available.""" + return self.version != self.version_latest + + @property + def need_cli_update(self): + """Return true if a HassOS cli update is available.""" + return self.version_cli != self.version_cli_latest + @property def board(self): """Return board name.""" @@ -56,6 +78,10 @@ class HassOS(CoreSysAttributes): try: _LOGGER.info("Fetch OTA update from %s", url) async with self.sys_websession.get(url) as request: + if request.status != 200: + raise HassOSUpdateError() + + # Download RAUCB file with raucb.open('wb') as ota_file: while True: chunk = await request.content.read(1048576) @@ -86,7 +112,7 @@ class HassOS(CoreSysAttributes): cpe = CPE(self.sys_host.info.cpe) assert cpe.get_product()[0] == 'hassos' except (AssertionError, NotImplementedError): - _LOGGER.debug("Ignore HassOS") + _LOGGER.debug("Found no HassOS") return # Store meta data @@ -95,6 +121,7 @@ class HassOS(CoreSysAttributes): self._board = cpe.get_target_hardware()[0] _LOGGER.info("Detect HassOS %s on host system", self.version) + await self.instance.attach() def config_sync(self): """Trigger a host config reload from usb. @@ -142,3 +169,17 @@ class HassOS(CoreSysAttributes): _LOGGER.error( "HassOS update fails with: %s", rauc_status.get('LastError')) raise HassOSUpdateError() + + async def update_cli(self, version=None): + """Update local HassOS cli.""" + version = version or self.version_cli_latest + + if version == self.version_cli: + _LOGGER.warning("Version %s is already installed for CLI", version) + raise HassOSUpdateError() + + if await self.instance.update(version): + return + + _LOGGER.error("HassOS CLI update fails.") + raise HassOSUpdateError() diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index bb2ea661c..404b0686a 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -1,5 +1,6 @@ """HomeAssistant control object.""" import asyncio +from contextlib import asynccontextmanager, suppress import logging import os import re @@ -7,15 +8,19 @@ import socket import time import aiohttp -from aiohttp.hdrs import CONTENT_TYPE +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, HEADER_HA_ACCESS, CONTENT_TYPE_JSON) + ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, + HEADER_HA_ACCESS) from .coresys import CoreSysAttributes from .docker.homeassistant import DockerHomeAssistant +from .exceptions import ( + HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError, + HomeAssistantAuthError) from .utils import convert_to_ascii, process_lock from .utils.json import JsonConfig from .validate import SCHEMA_HASS_CONFIG @@ -25,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") # pylint: disable=invalid-name -ConfigResult = attr.make_class('ConfigResult', ['valid', 'log']) +ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'], frozen=True) class HomeAssistant(JsonConfig, CoreSysAttributes): @@ -38,6 +43,8 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): 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 async def load(self): """Prepare HomeAssistant object.""" @@ -175,6 +182,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): """Return a UUID of this HomeAssistant.""" return self._data[ATTR_UUID] + @property + def refresh_token(self): + """Return the refresh token to authenticate with HomeAssistant.""" + return self._data.get(ATTR_REFRESH_TOKEN) + + @refresh_token.setter + def refresh_token(self, value): + """Set Home Assistant refresh_token.""" + self._data[ATTR_REFRESH_TOKEN] = value + @process_lock async def install_landingpage(self): """Install a landingpage.""" @@ -186,7 +203,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): await asyncio.sleep(60) # Run landingpage after installation - await self._start() + _LOGGER.info("Start landingpage") + try: + await self._start() + except HomeAssistantError: + _LOGGER.warning("Can't start landingpage") @process_lock async def install(self): @@ -205,9 +226,15 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): # finishing _LOGGER.info("HomeAssistant docker now installed") - if self.boot: + try: + if not self.boot: + return + _LOGGER.info("Start HomeAssistant") await self._start() - await self.instance.cleanup() + except HomeAssistantError: + _LOGGER.error("Can't start HomeAssistant!") + finally: + await self.instance.cleanup() @process_lock async def update(self, version=None): @@ -219,32 +246,37 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): if exists and version == self.instance.version: _LOGGER.warning("Version %s is already installed", version) - return False + return HomeAssistantUpdateError() # process a update async def _update(to_version): """Run Home Assistant update.""" try: - return await self.instance.update(to_version) + _LOGGER.info("Update HomeAssistant to version %s", to_version) + if not await self.instance.update(to_version): + raise HomeAssistantUpdateError() finally: if running: await self._start() + _LOGGER.info("Successfull run HomeAssistant %s", to_version) # Update Home Assistant - ret = await _update(version) + with suppress(HomeAssistantError): + await _update(version) + return # Update going wrong, revert it if self.error_state and rollback: - _LOGGER.fatal("Home Assistant update fails -> rollback!") - ret = await _update(rollback) - - return ret + _LOGGER.fatal("HomeAssistant update fails -> rollback!") + await _update(rollback) + else: + raise HomeAssistantUpdateError() async def _start(self): """Start HomeAssistant docker & wait.""" if not await self.instance.run(): - return False - return await self._block_till_run() + raise HomeAssistantError() + await self._block_till_run() @process_lock def start(self): @@ -266,7 +298,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): async def restart(self): """Restart HomeAssistant docker.""" await self.instance.stop() - return await self._start() + await self._start() def logs(self): """Get HomeAssistant docker logs. @@ -309,7 +341,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): # if not valid if result.exit_code is None: - return ConfigResult(False, "") + raise HomeAssistantError() # parse output log = convert_to_ascii(result.output) @@ -317,51 +349,84 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): return ConfigResult(False, log) return ConfigResult(True, log) - async def check_api_state(self): - """Check if Home-Assistant up and running.""" - url = f"{self.api_url}/api/" - header = {CONTENT_TYPE: CONTENT_TYPE_JSON} + async def ensure_access_token(self): + """Ensures there is an access token.""" + if self.access_token is not None: + return - if self.api_password: - header.update({HEADER_HA_ACCESS: self.api_password}) - - try: - # pylint: disable=bad-continuation + with suppress(asyncio.TimeoutError, aiohttp.ClientError): async with self.sys_websession_ssl.get( - url, headers=header, timeout=30) as request: - status = request.status + f"{self.api_url}/auth/token", + timeout=30, + data={ + "grant_type": "refresh_token", + "refresh_token": self.refresh_token + } + ) as resp: + if resp.status != 200: + _LOGGER.error("Authenticate problem with HomeAssistant!") + raise HomeAssistantAuthError() + tokens = await resp.json() + self.access_token = tokens['access_token'] + return - except (asyncio.TimeoutError, aiohttp.ClientError): - return False + _LOGGER.error("Can't update HomeAssistant access token!") + raise HomeAssistantAPIError() - if status not in (200, 201): - _LOGGER.warning("Home-Assistant API config missmatch") - return True + @asynccontextmanager + async def make_request(self, method, path, json=None, content_type=None, + data=None, timeout=30): + """Async context manager to make a request with right auth.""" + url = f"{self.api_url}/{path}" + headers = {} + + if content_type is not None: + headers[hdrs.CONTENT_TYPE] = content_type + + elif self.api_password: + headers[HEADER_HA_ACCESS] = self.api_password + + for _ in (1, 2): + # Prepare Access token + if self.refresh_token: + await self.ensure_access_token() + headers[hdrs.AUTHORIZATION] = f'Bearer {self.access_token}' + + async with getattr(self.sys_websession_ssl, method)( + url, timeout=timeout, json=json + ) as resp: + # Access token expired + if resp.status == 401 and self.refresh_token: + self.access_token = None + continue + yield resp + return + + raise HomeAssistantAPIError() + + async def check_api_state(self): + """Return True if Home-Assistant up and running.""" + with suppress(HomeAssistantAPIError): + async with self.make_request('get', 'api/') as resp: + if resp.status in (200, 201): + return True + err = resp.status + + _LOGGER.warning("Home-Assistant API config missmatch: %d", err) + return False async def send_event(self, event_type, event_data=None): """Send event to Home-Assistant.""" - url = f"{self.api_url}/api/events/{event_type}" - header = {CONTENT_TYPE: CONTENT_TYPE_JSON} + with suppress(HomeAssistantAPIError): + async with self.make_request( + 'get', f'api/events/{event_type}' + ) as resp: + if resp.status in (200, 201): + return + err = resp.status - if self.api_password: - header.update({HEADER_HA_ACCESS: self.api_password}) - - try: - # pylint: disable=bad-continuation - async with self.sys_websession_ssl.post( - url, headers=header, timeout=30, - json=event_data) as request: - status = request.status - - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - _LOGGER.warning( - "Home-Assistant event %s fails: %s", event_type, err) - return False - - if status not in (200, 201): - _LOGGER.warning("Home-Assistant event %s fails", event_type) - return False - return True + _LOGGER.warning("HomeAssistant event %s fails: %s", event_type, err) + return HomeAssistantError() async def _block_till_run(self): """Block until Home-Assistant is booting up or startup timeout.""" @@ -374,27 +439,28 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): result = sock.connect_ex((str(self.api_ip), self.api_port)) sock.close() + # Check if the port is available if result == 0: return True - return False except OSError: pass + return False while time.monotonic() - start_time < self.wait_boot: # Check if API response if await self.sys_run_in_executor(check_port): - _LOGGER.info("Detect a running Home-Assistant instance") + _LOGGER.info("Detect a running HomeAssistant instance") self._error_state = False - return True + return + + # wait and don't hit the system + await asyncio.sleep(10) # Check if Container is is_running if not await self.instance.is_running(): _LOGGER.error("Home Assistant is crashed!") break - # wait and don't hit the system - await asyncio.sleep(10) - - _LOGGER.warning("Don't wait anymore of Home-Assistant startup!") + _LOGGER.warning("Don't wait anymore of HomeAssistant startup!") self._error_state = True - return False + raise HomeAssistantError() diff --git a/hassio/snapshots/validate.py b/hassio/snapshots/validate.py index ab051ffbc..fa1681cba 100644 --- a/hassio/snapshots/validate.py +++ b/hassio/snapshots/validate.py @@ -16,7 +16,7 @@ ALL_FOLDERS = [FOLDER_HOMEASSISTANT, FOLDER_SHARE, FOLDER_ADDONS, FOLDER_SSL] def unique_addons(addons_list): """Validate that an add-on is unique.""" - single = set([addon[ATTR_SLUG] for addon in addons_list]) + single = set(addon[ATTR_SLUG] for addon in addons_list) if len(single) != len(addons_list): raise vol.Invalid("Invalid addon list on snapshot!") diff --git a/hassio/tasks.py b/hassio/tasks.py index f83132f02..5a0abdd8e 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -10,6 +10,7 @@ HASS_WATCHDOG_API = 'HASS_WATCHDOG_API' RUN_UPDATE_SUPERVISOR = 29100 RUN_UPDATE_ADDONS = 57600 +RUN_UPDATE_HASSOSCLI = 29100 RUN_RELOAD_ADDONS = 21600 RUN_RELOAD_SNAPSHOTS = 72000 @@ -35,6 +36,8 @@ class Tasks(CoreSysAttributes): self._update_addons, RUN_UPDATE_ADDONS)) self.jobs.add(self.sys_scheduler.register_task( self._update_supervisor, RUN_UPDATE_SUPERVISOR)) + self.jobs.add(self.sys_scheduler.register_task( + self._update_hassos_cli, RUN_UPDATE_HASSOSCLI)) self.jobs.add(self.sys_scheduler.register_task( self.sys_addons.reload, RUN_RELOAD_ADDONS)) @@ -79,7 +82,7 @@ class Tasks(CoreSysAttributes): if not self.sys_supervisor.need_update: return - # don't perform an update on beta/dev channel + # don't perform an update on dev channel if self.sys_dev: _LOGGER.warning("Ignore Hass.io update on dev channel!") return @@ -131,5 +134,20 @@ class Tasks(CoreSysAttributes): return _LOGGER.error("Watchdog found a problem with Home-Assistant API!") - await self.sys_homeassistant.restart() - self._cache[HASS_WATCHDOG_API] = 0 + try: + await self.sys_homeassistant.restart() + finally: + self._cache[HASS_WATCHDOG_API] = 0 + + async def _update_hassos_cli(self): + """Check and run update of HassOS CLI.""" + if not self.sys_hassos.need_cli_update: + return + + # don't perform an update on dev channel + if self.sys_dev: + _LOGGER.warning("Ignore HassOS CLI update on dev channel!") + return + + _LOGGER.info("Found new HassOS CLI version") + await self.sys_hassos.update_cli() diff --git a/hassio/updater.py b/hassio/updater.py index 78cb3bed6..fb8efe876 100644 --- a/hassio/updater.py +++ b/hassio/updater.py @@ -8,7 +8,7 @@ import aiohttp from .const import ( URL_HASSIO_VERSION, FILE_HASSIO_UPDATER, ATTR_HOMEASSISTANT, ATTR_HASSIO, - ATTR_CHANNEL, ATTR_HASSOS) + ATTR_CHANNEL, ATTR_HASSOS, ATTR_HASSOS_CLI) from .coresys import CoreSysAttributes from .utils import AsyncThrottle from .utils.json import JsonConfig @@ -51,6 +51,11 @@ class Updater(JsonConfig, CoreSysAttributes): """Return last version of hassos.""" return self._data.get(ATTR_HASSOS) + @property + def version_hassos_cli(self): + """Return last version of hassos cli.""" + return self._data.get(ATTR_HASSOS_CLI) + @property def channel(self): """Return upstream channel of hassio instance.""" @@ -99,6 +104,7 @@ class Updater(JsonConfig, CoreSysAttributes): # update hassos version if self.sys_hassos.available and board: self._data[ATTR_HASSOS] = data['hassos'][board] + self._data[ATTR_HASSOS_CLI] = data['hassos-cli'] except KeyError as err: _LOGGER.warning("Can't process version data: %s", err) diff --git a/hassio/utils/gdbus.py b/hassio/utils/gdbus.py index 9a8cd26a5..9b9c3373b 100644 --- a/hassio/utils/gdbus.py +++ b/hassio/utils/gdbus.py @@ -247,7 +247,7 @@ class DBusSignalWrapper: self._proc.send_signal(SIGINT) await self._proc.communicate() - async def __aiter__(self): + def __aiter__(self): """Start Iteratation.""" return self diff --git a/hassio/validate.py b/hassio/validate.py index e31164d5a..86a8b1e85 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -9,7 +9,8 @@ from .const import ( ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS, ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, - ATTR_WAIT_BOOT, ATTR_UUID, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) + ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI, + CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) RE_REPOSITORY = re.compile(r"^(?P[^#]+)(?:#(?P[\w\-]+))?$") @@ -88,6 +89,7 @@ SCHEMA_HASS_CONFIG = vol.Schema({ vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT, vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)), + vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)), vol.Optional(ATTR_SSL, default=False): vol.Boolean(), vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), vol.Optional(ATTR_WAIT_BOOT, default=600): @@ -100,6 +102,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({ vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), vol.Optional(ATTR_HASSIO): vol.Coerce(str), vol.Optional(ATTR_HASSOS): vol.Coerce(str), + vol.Optional(ATTR_HASSOS_CLI): vol.Coerce(str), }, extra=vol.REMOVE_EXTRA) diff --git a/pylintrc b/pylintrc index 4c0b15230..6bbac9854 100644 --- a/pylintrc +++ b/pylintrc @@ -21,7 +21,6 @@ disable= abstract-class-little-used, abstract-class-not-used, unused-argument, - global-statement, redefined-variable-type, too-many-arguments, too-many-branches, @@ -32,7 +31,10 @@ disable= too-many-statements, too-many-lines, too-few-public-methods, - abstract-method + abstract-method, + no-else-return, + useless-return, + not-async-context-manager [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError diff --git a/setup.py b/setup.py index 1444f408b..8214c8a27 100644 --- a/setup.py +++ b/setup.py @@ -43,13 +43,13 @@ setup( 'attr==0.3.1', 'async_timeout==3.0.0', 'aiohttp==3.3.2', - 'docker==3.3.0', + 'docker==3.4.0', 'colorlog==3.1.2', 'voluptuous==0.11.1', 'gitpython==2.1.10', 'pytz==2018.4', 'pyudev==0.21.0', - 'pycryptodome==3.4.11', + 'pycryptodome==3.6.4', "cpe==1.2.1" ] ) diff --git a/tox.ini b/tox.ini index 14c887d08..734921741 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,8 @@ envlist = lint [testenv] deps = - flake8 - pylint + flake8==3.5.0 + pylint==2.0.0 [testenv:lint] basepython = python3