diff --git a/API.md b/API.md index bbec173cf..08aa71395 100644 --- a/API.md +++ b/API.md @@ -303,6 +303,7 @@ Output is the raw Docker log. - POST `/homeassistant/restart` - POST `/homeassistant/options` +- POST `/homeassistant/check` ```json { diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 9358c2ec8..645a4fca4 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -68,10 +68,11 @@ class RestAPI(object): api_hass = APIHomeAssistant(self.config, self.loop, dock_homeassistant) self.webapp.router.add_get('/homeassistant/info', api_hass.info) + self.webapp.router.add_get('/homeassistant/logs', api_hass.logs) self.webapp.router.add_post('/homeassistant/options', api_hass.options) self.webapp.router.add_post('/homeassistant/update', api_hass.update) self.webapp.router.add_post('/homeassistant/restart', api_hass.restart) - self.webapp.router.add_get('/homeassistant/logs', api_hass.logs) + self.webapp.router.add_post('/homeassistant/check', api_hass.check) def register_addons(self, addons): """Register homeassistant function.""" diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 3451f81f8..b0dc165f9 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -170,19 +170,13 @@ class APIAddons(object): @api_process def uninstall(self, request): - """Uninstall addon. - - Return a coroutine. - """ + """Uninstall addon.""" addon = self._extract_addon(request) return asyncio.shield(addon.uninstall(), loop=self.loop) @api_process def start(self, request): - """Start addon. - - Return a coroutine. - """ + """Start addon.""" addon = self._extract_addon(request) # check options @@ -196,10 +190,7 @@ class APIAddons(object): @api_process def stop(self, request): - """Stop addon. - - Return a coroutine. - """ + """Stop addon.""" addon = self._extract_addon(request) return asyncio.shield(addon.stop(), loop=self.loop) @@ -218,19 +209,13 @@ class APIAddons(object): @api_process def restart(self, request): - """Restart addon. - - Return a coroutine. - """ + """Restart addon.""" addon = self._extract_addon(request) return asyncio.shield(addon.restart(), loop=self.loop) @api_process_raw(CONTENT_TYPE_BINARY) def logs(self, request): - """Return logs from addon. - - Return a coroutine. - """ + """Return logs from addon.""" addon = self._extract_addon(request) return addon.logs() diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index 83415b6a0..59558624a 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -73,16 +73,19 @@ class APIHomeAssistant(object): @api_process def restart(self, request): - """Restart homeassistant. - - Return a coroutine. - """ + """Restart homeassistant.""" return asyncio.shield(self.homeassistant.restart(), loop=self.loop) @api_process_raw(CONTENT_TYPE_BINARY) def logs(self, request): - """Return homeassistant docker logs. - - Return a coroutine. - """ + """Return homeassistant docker logs.""" return self.homeassistant.logs() + + @api_process + async def check(self, request): + """Check config of homeassistant.""" + code, message = await self.homeassistant.check_config() + if not code: + raise RuntimeError(message) + + return True diff --git a/hassio/api/host.py b/hassio/api/host.py index 39fbbeb78..5815ebf76 100644 --- a/hassio/api/host.py +++ b/hassio/api/host.py @@ -59,18 +59,12 @@ class APIHost(object): @api_process_hostcontrol def reboot(self, request): - """Reboot host. - - Return a coroutine. - """ + """Reboot host.""" return self.host_control.reboot() @api_process_hostcontrol def shutdown(self, request): - """Poweroff host. - - Return a coroutine. - """ + """Poweroff host.""" return self.host_control.shutdown() @api_process_hostcontrol diff --git a/hassio/api/snapshots.py b/hassio/api/snapshots.py index 42467acd2..70ec3bf79 100644 --- a/hassio/api/snapshots.py +++ b/hassio/api/snapshots.py @@ -112,10 +112,7 @@ class APISnapshots(object): @api_process def restore_full(self, request): - """Full-Restore a snapshot. - - Return a coroutine. - """ + """Full-Restore a snapshot.""" snapshot = self._extract_snapshot(request) return asyncio.shield( self.snapshots.do_restore_full(snapshot), loop=self.loop) diff --git a/hassio/api/supervisor.py b/hassio/api/supervisor.py index 7616fe3ef..3c1e5e09f 100644 --- a/hassio/api/supervisor.py +++ b/hassio/api/supervisor.py @@ -121,8 +121,5 @@ class APISupervisor(object): @api_process_raw(CONTENT_TYPE_BINARY) def logs(self, request): - """Return supervisor docker logs. - - Return a coroutine. - """ + """Return supervisor docker logs.""" return self.supervisor.logs() diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index 90d0c601d..f166c5502 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -5,6 +5,7 @@ import logging import docker +from .util import docker_process from ..const import LABEL_VERSION, LABEL_ARCH _LOGGER = logging.getLogger(__name__) @@ -52,14 +53,10 @@ class DockerBase(object): if need_arch and LABEL_ARCH in metadata['Config']['Labels']: self.arch = metadata['Config']['Labels'][LABEL_ARCH] - async def install(self, tag): + @docker_process + def install(self, tag): """Pull docker image.""" - if self._lock.locked(): - _LOGGER.error("Can't excute install while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor(None, self._install, tag) + return self.loop.run_in_executor(None, self._install, tag) def _install(self, tag): """Pull docker image. @@ -80,10 +77,7 @@ class DockerBase(object): return True def exists(self): - """Return True if docker image exists in local repo. - - Return a Future. - """ + """Return True if docker image exists in local repo.""" return self.loop.run_in_executor(None, self._exists) def _exists(self): @@ -126,14 +120,10 @@ class DockerBase(object): return True - async def attach(self): + @docker_process + def attach(self): """Attach to running docker container.""" - if self._lock.locked(): - _LOGGER.error("Can't excute attach while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor(None, self._attach) + return self.loop.run_in_executor(None, self._attach) def _attach(self): """Attach to running docker container. @@ -154,14 +144,10 @@ class DockerBase(object): return True - async def run(self): + @docker_process + def run(self): """Run docker image.""" - if self._lock.locked(): - _LOGGER.error("Can't excute run while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor(None, self._run) + return self.loop.run_in_executor(None, self._run) def _run(self): """Run docker image. @@ -170,15 +156,10 @@ class DockerBase(object): """ raise NotImplementedError() - async def stop(self): + @docker_process + def stop(self): """Stop/remove docker container.""" - if self._lock.locked(): - _LOGGER.error("Can't excute stop while a task is in progress") - return False - - async with self._lock: - await self.loop.run_in_executor(None, self._stop) - return True + return self.loop.run_in_executor(None, self._stop) def _stop(self): """Stop/remove and remove docker container. @@ -188,7 +169,7 @@ class DockerBase(object): try: container = self.dock.containers.get(self.name) except docker.errors.DockerException: - return + return False if container.status == 'running': _LOGGER.info("Stop %s docker application", self.image) @@ -199,14 +180,12 @@ class DockerBase(object): _LOGGER.info("Clean %s docker application", self.image) container.remove(force=True) - async def remove(self): - """Remove docker images.""" - if self._lock.locked(): - _LOGGER.error("Can't excute remove while a task is in progress") - return False + return True - async with self._lock: - return await self.loop.run_in_executor(None, self._remove) + @docker_process + def remove(self): + """Remove docker images.""" + return self.loop.run_in_executor(None, self._remove) def _remove(self): """remove docker images. @@ -235,16 +214,13 @@ class DockerBase(object): # clean metadata self.version = None self.arch = None + return True - async def update(self, tag): + @docker_process + def update(self, tag): """Update a docker image.""" - if self._lock.locked(): - _LOGGER.error("Can't excute update while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor(None, self._update, tag) + return self.loop.run_in_executor(None, self._update, tag) def _update(self, tag): """Update a docker image. @@ -258,22 +234,16 @@ class DockerBase(object): if not self._install(tag): return False - # container + # stop container & cleanup self._stop() - - # cleanup images self._cleanup() return True - async def logs(self): + @docker_process + def logs(self): """Return docker logs of container.""" - if self._lock.locked(): - _LOGGER.error("Can't excute logs while a task is in progress") - return b"" - - async with self._lock: - return await self.loop.run_in_executor(None, self._logs) + return self.loop.run_in_executor(None, self._logs) def _logs(self): """Return docker logs of container. @@ -290,14 +260,10 @@ class DockerBase(object): except docker.errors.DockerException as err: _LOGGER.warning("Can't grap logs from %s -> %s", self.image, err) - async def restart(self): + @docker_process + def restart(self): """Restart docker container.""" - if self._lock.locked(): - _LOGGER.error("Can't excute restart while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor(None, self._restart) + return self.loop.run_in_executor(None, self._restart) def _restart(self): """Restart docker container. @@ -319,14 +285,10 @@ class DockerBase(object): return True - async def cleanup(self): + @docker_process + def cleanup(self): """Check if old version exists and cleanup.""" - if self._lock.locked(): - _LOGGER.error("Can't excute cleanup while a task is in progress") - return False - - async with self._lock: - await self.loop.run_in_executor(None, self._cleanup) + return self.loop.run_in_executor(None, self._cleanup) def _cleanup(self): """Check if old version exists and cleanup. @@ -337,7 +299,7 @@ class DockerBase(object): latest = self.dock.images.get(self.image) except docker.errors.DockerException: _LOGGER.warning("Can't find %s for cleanup", self.image) - return + return False for image in self.dock.images.list(name=self.image): if latest.id == image.id: @@ -346,3 +308,17 @@ class DockerBase(object): with suppress(docker.errors.DockerException): _LOGGER.info("Cleanup docker images: %s", image.tags) self.dock.images.remove(image.id, force=True) + + return True + + @docker_process + def execute_command(self, command): + """Create a temporary container and run command.""" + return self.loop.run_in_executor(None, self._execute_command, command) + + def _execute_command(self, command): + """Create a temporary container and run command. + + Need run inside executor. + """ + raise NotImplementedError() diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index b9e56829f..bbc119960 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -7,7 +7,7 @@ import docker import requests from . import DockerBase -from .util import dockerfile_template +from .util import dockerfile_template, docker_process from ..const import ( META_ADDON, MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE) @@ -218,15 +218,10 @@ class DockerAddon(DockerBase): finally: shutil.rmtree(str(build_dir), ignore_errors=True) - async def export_image(self, path): + @docker_process + def export_image(self, path): """Export current images into a tar file.""" - if self._lock.locked(): - _LOGGER.error("Can't excute export while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor( - None, self._export_image, path) + return self.loop.run_in_executor(None, self._export_image, path) def _export_image(self, tar_file): """Export current images into a tar file. @@ -250,15 +245,10 @@ class DockerAddon(DockerBase): _LOGGER.info("Export image %s to %s", self.image, tar_file) return True - async def import_image(self, path, tag): + @docker_process + def import_image(self, path, tag): """Import a tar file as image.""" - if self._lock.locked(): - _LOGGER.error("Can't excute import while a task is in progress") - return False - - async with self._lock: - return await self.loop.run_in_executor( - None, self._import_image, path, tag) + return self.loop.run_in_executor(None, self._import_image, path, tag) def _import_image(self, tar_file, tag): """Import a tar file as image. diff --git a/hassio/dock/homeassistant.py b/hassio/dock/homeassistant.py index 5e485f2a3..ee9ee3460 100644 --- a/hassio/dock/homeassistant.py +++ b/hassio/dock/homeassistant.py @@ -1,4 +1,5 @@ """Init file for HassIO docker object.""" +from contextlib import suppress import logging import docker @@ -66,7 +67,8 @@ class DockerHomeAssistant(DockerBase): {'bind': '/ssl', 'mode': 'ro'}, str(self.config.path_extern_share): {'bind': '/share', 'mode': 'rw'}, - }) + } + ) except docker.errors.DockerException as err: _LOGGER.error("Can't run %s -> %s", self.image, err) @@ -75,3 +77,41 @@ class DockerHomeAssistant(DockerBase): _LOGGER.info( "Start homeassistant %s with version %s", self.image, self.version) return True + + def _execute_command(self, command): + """Create a temporary container and run command. + + Need run inside executor. + """ + _LOGGER.info("Run command '%s' on %s", command, self.image) + try: + container = self.dock.containers.run( + self.image, + command=command, + detach=True, + stdout=True, + stderr=True, + environment={ + 'TZ': self.config.timezone, + }, + volumes={ + str(self.config.path_extern_config): + {'bind': '/config', 'mode': 'ro'}, + str(self.config.path_extern_ssl): + {'bind': '/ssl', 'mode': 'ro'}, + } + ) + + # wait until command is done + container.wait() + output = container.logs() + + except docker.errors.DockerException as err: + _LOGGER.error("Can't execute command -> %s", err) + return b"" + + # cleanup container + with suppress(docker.errors.DockerException): + container.remove(force=True) + + return output diff --git a/hassio/dock/supervisor.py b/hassio/dock/supervisor.py index 7e9dabc7b..abc491caf 100644 --- a/hassio/dock/supervisor.py +++ b/hassio/dock/supervisor.py @@ -3,6 +3,7 @@ import logging import os from . import DockerBase +from .util import docker_process from ..const import RESTART_EXIT_CODE _LOGGER = logging.getLogger(__name__) @@ -21,20 +22,16 @@ class DockerSupervisor(DockerBase): """Return name of docker container.""" return os.environ['SUPERVISOR_NAME'] + @docker_process async def update(self, tag): """Update a supervisor docker image.""" - if self._lock.locked(): - _LOGGER.error("Can't excute update while a task is in progress") - return False - _LOGGER.info("Update supervisor docker to %s:%s", self.image, tag) - async with self._lock: - if await self.loop.run_in_executor(None, self._install, tag): - self.loop.create_task(self.stop_callback(RESTART_EXIT_CODE)) - return True + if await self.loop.run_in_executor(None, self._install, tag): + self.loop.create_task(self.stop_callback(RESTART_EXIT_CODE)) + return True - return False + return False async def run(self): """Run docker image.""" diff --git a/hassio/dock/util.py b/hassio/dock/util.py index fa46db88f..1f7ccf48f 100644 --- a/hassio/dock/util.py +++ b/hassio/dock/util.py @@ -1,8 +1,10 @@ """HassIO docker utilitys.""" +import logging import re from ..const import ARCH_AARCH64, ARCH_ARMHF, ARCH_I386, ARCH_AMD64 +_LOGGER = logging.getLogger(__name__) HASSIO_BASE_IMAGE = { ARCH_ARMHF: "homeassistant/armhf-base:latest", @@ -40,3 +42,19 @@ def create_metadata(version, arch, meta_type): return ('LABEL io.hass.version="{}" ' 'io.hass.arch="{}" ' 'io.hass.type="{}"').format(version, arch, meta_type) + + +# pylint: disable=protected-access +def docker_process(method): + """Wrap function with only run once.""" + async def wrap_api(api, *args, **kwargs): + """Return api wrapper.""" + if api._lock.locked(): + _LOGGER.error( + "Can't excute %s while a task is in progress", method.__name__) + return False + + async with api._lock: + return await method(api, *args, **kwargs) + + return wrap_api diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index ad0e34ce3..5e2f044f0 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -2,16 +2,19 @@ import asyncio import logging import os +import re from .const import ( FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_VERSION) from .dock.homeassistant import DockerHomeAssistant -from .tools import JsonConfig +from .tools import JsonConfig, convert_to_ascii from .validate import SCHEMA_HASS_CONFIG _LOGGER = logging.getLogger(__name__) +RE_CONFIG_CHECK = re.compile(r"error", re.IGNORECASE) + class HomeAssistant(JsonConfig): """Hass core object for handle it.""" @@ -165,3 +168,20 @@ class HomeAssistant(JsonConfig): def in_progress(self): """Return True if a task is in progress.""" return self.docker.in_progress + + async def check_config(self): + """Run homeassistant config check.""" + log = await self.docker.execute_command( + "python3 -m homeassistant -c /config --script check_config" + ) + + # if not valid + if not log: + return (False, "") + + # parse output + log = convert_to_ascii(log) + if RE_CONFIG_CHECK.search(log): + return (False, log) + + return (True, log) diff --git a/hassio/tools.py b/hassio/tools.py index 6832b22df..65891cfb5 100644 --- a/hassio/tools.py +++ b/hassio/tools.py @@ -5,6 +5,7 @@ from datetime import datetime import json import logging import socket +import re import aiohttp import async_timeout @@ -15,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) FREEGEOIP_URL = "https://freegeoip.io/json/" +RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") + def get_local_ip(loop): """Retrieve local IP address. @@ -68,6 +71,11 @@ async def fetch_timezone(websession): return data.get('time_zone', 'UTC') +def convert_to_ascii(raw): + """Convert binary to ascii and remove colors.""" + return RE_STRING.sub("", raw.decode()) + + class JsonConfig(object): """Hass core object for handle it."""