From 65eaed4f90d49a8826c7e3329fc8d8c48dd73132 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 17 Jun 2017 23:58:07 +0200 Subject: [PATCH 01/13] Pump version --- hassio/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hassio/const.py b/hassio/const.py index 826cbabee..1f7a8c0d4 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -1,7 +1,7 @@ """Const file for HassIO.""" from pathlib import Path -HASSIO_VERSION = '0.37' +HASSIO_VERSION = '0.38' URL_HASSIO_VERSION = ('https://raw.githubusercontent.com/home-assistant/' 'hassio/master/version.json') From 6dba8d4ef92d7d65c2ed7e3718c5a52ab7b2294a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 18 Jun 2017 00:40:13 +0200 Subject: [PATCH 02/13] Update HomeAssistant --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index a9604432a..97b1e76ee 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "hassio": "0.37", - "homeassistant": "0.46.1", + "homeassistant": "0.47", "resinos": "0.8", "resinhup": "0.1", "generic": "0.3" From c8343fdfb0811e4509afb9b1f415215ddf01dc44 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 21 Jun 2017 22:16:44 +0200 Subject: [PATCH 03/13] Update HomeAssistant 0.47.1 --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 97b1e76ee..c58e2df82 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "hassio": "0.37", + "hassio": "0.37.1", "homeassistant": "0.47", "resinos": "0.8", "resinhup": "0.1", From b7f5cc868b9145e3afeefc6de3035b774f9f6e51 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 21 Jun 2017 22:18:46 +0200 Subject: [PATCH 04/13] HomeAssistant 0.47.1 --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index c58e2df82..58fa35175 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { - "hassio": "0.37.1", - "homeassistant": "0.47", + "hassio": "0.37", + "homeassistant": "0.47.1", "resinos": "0.8", "resinhup": "0.1", "generic": "0.3" From 1b887e38d6fa664751e84c1622d4ff35745c6423 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 23 Jun 2017 00:51:28 +0200 Subject: [PATCH 05/13] Add new data Layer for addons (#81) * Add new data Layer for addons * draft v2 * part 3 * Cleanups for new addon layout * cleanup part 5 * fix lint p1 * fix lint p2 * fix api bug * Fix lint p3 * fix lint p4 * fix lint p5 * fix lint p6 * fix update * fix lint * Update repo add code part 1 * new repository load code * reorder code * Code cleanup p1 * Don't allow change options for not installed addons * Cleanup error handling * Fix addon restart config bug * minimize timeout for bad addons * set core timeout for docker to 15 * fix lint * fix start bug * Add startuptype * change names / fix update bug * fix update bug p2 * Cleanup arch * Ignore built-in repositories * fix lint * fix bug * fix bug p4 * fix arch handling / better bugfix for boot option * fix lint * fix arch * fix options * fix close is no coro * update api description & fix lint * Fix error * fix api validate config * cleanup api * Fix repo loader * cleanup slugs * fix lint --- API.md | 6 +- hassio/addons/__init__.py | 273 ++++++++++----------------- hassio/addons/addon.py | 356 ++++++++++++++++++++++++++++++++++++ hassio/addons/built-in.json | 2 - hassio/addons/data.py | 313 +++++-------------------------- hassio/addons/git.py | 6 +- hassio/addons/repository.py | 69 +++++++ hassio/addons/util.py | 5 - hassio/api/addons.py | 94 ++++------ hassio/api/supervisor.py | 63 +++---- hassio/api/util.py | 2 + hassio/config.py | 1 + hassio/const.py | 4 + hassio/core.py | 15 +- hassio/dock/__init__.py | 12 +- hassio/dock/addon.py | 50 +++-- hassio/tools.py | 11 -- 17 files changed, 680 insertions(+), 602 deletions(-) create mode 100644 hassio/addons/addon.py create mode 100644 hassio/addons/repository.py diff --git a/API.md b/API.md index aada231de..75db52f13 100644 --- a/API.md +++ b/API.md @@ -239,12 +239,12 @@ Output the raw docker log "url": "null|url of addon", "detached": "bool", "repository": "12345678|null", - "version": "VERSION", + "version": "null|VERSION_INSTALLED", "last_version": "LAST_VERSION", - "state": "started|stopped", + "state": "none|started|stopped", "boot": "auto|manual", "build": "bool", - "options": {}, + "options": "null|{}", } ``` diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py index 9860e9801..f59b47320 100644 --- a/hassio/addons/__init__.py +++ b/hassio/addons/__init__.py @@ -1,220 +1,133 @@ """Init file for HassIO addons.""" import asyncio import logging -import shutil -from .data import AddonsData -from .git import AddonsRepoHassIO, AddonsRepoCustom -from ..const import STATE_STOPPED, STATE_STARTED -from ..dock.addon import DockerAddon +from .addon import Addon +from .repository import Repository +from .data import Data +from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL, BOOT_AUTO _LOGGER = logging.getLogger(__name__) +BUILTIN_REPOSITORIES = set((REPOSITORY_CORE, REPOSITORY_LOCAL)) -class AddonManager(AddonsData): + +class AddonManager(object): """Manage addons inside HassIO.""" def __init__(self, config, loop, dock): """Initialize docker base wrapper.""" - super().__init__(config) - self.loop = loop + self.config = config self.dock = dock - self.repositories = [] - self.dockers = {} + self.data = Data(config) + self.addons = {} + self.repositories = {} - async def prepare(self, arch): + @property + def list_addons(self): + """Return a list of all addons.""" + return list(self.addons.values()) + + @property + def list_repositories(self): + """Return list of addon repositories.""" + return list(self.repositories.values()) + + def get(self, addon_slug): + """Return a adddon from slug.""" + return self.addons.get(addon_slug) + + async def prepare(self): """Startup addon management.""" - self.arch = arch + self.data.reload() - # init hassio repository - self.repositories.append(AddonsRepoHassIO(self.config, self.loop)) + # init hassio built-in repositories + repositories = \ + set(self.config.addons_repositories) | BUILTIN_REPOSITORIES - # init custom repositories - for url in self.config.addons_repositories: - self.repositories.append( - AddonsRepoCustom(self.config, self.loop, url)) - - # load addon repository - tasks = [addon.load() for addon in self.repositories] - if tasks: - await asyncio.wait(tasks, loop=self.loop) - - # read data from repositories - self.read_data_from_repositories() - self.merge_update_config() - - # load installed addons - for addon in self.list_installed: - self.dockers[addon] = DockerAddon( - self.config, self.loop, self.dock, self, addon) - await self.dockers[addon].attach() - - async def add_git_repository(self, url): - """Add a new custom repository.""" - if url in self.config.addons_repositories: - _LOGGER.warning("Repository already exists %s", url) - return False - - repo = AddonsRepoCustom(self.config, self.loop, url) - - if not await repo.load(): - _LOGGER.error("Can't load from repository %s", url) - return False - - self.config.addons_repositories = url - self.repositories.append(repo) - return True - - def drop_git_repository(self, url): - """Remove a custom repository.""" - for repo in self.repositories: - if repo.url == url: - self.repositories.remove(repo) - self.config.drop_addon_repository(url) - repo.remove() - return True - - return False + # init custom repositories & load addons + await self.load_repositories(repositories) async def reload(self): """Update addons from repo and reload list.""" - tasks = [addon.pull() for addon in self.repositories] - if not tasks: - return - - await asyncio.wait(tasks, loop=self.loop) - - # read data from repositories - self.read_data_from_repositories() - self.merge_update_config() - - # remove stalled addons - for addon in self.list_detached: - _LOGGER.warning("Dedicated addon '%s' found!", addon) - - async def auto_boot(self, start_type): - """Boot addons with mode auto.""" - boot_list = self.list_startup(start_type) - tasks = [self.start(addon) for addon in boot_list] - - _LOGGER.info("Startup %s run %d addons", start_type, len(tasks)) + tasks = [repository.update() for repository in + self.repositories.values()] if tasks: await asyncio.wait(tasks, loop=self.loop) - async def install(self, addon, version=None): - """Install a addon.""" - if not self.exists_addon(addon): - _LOGGER.error("Addon %s not exists for install", addon) - return False + # read data from repositories + self.data.reload() - if self.arch not in self.get_arch(addon): - _LOGGER.error("Addon %s not supported on %s", addon, self.arch) - return False + # update addons + await self.load_addons() - if self.is_installed(addon): - _LOGGER.error("Addon %s is already installed", addon) - return False + async def load_repositories(self, list_repositories): + """Add a new custom repository.""" + new_rep = set(list_repositories) + old_rep = set(self.repositories) - if not self.path_data(addon).is_dir(): - _LOGGER.info("Create Home-Assistant addon data folder %s", - self.path_data(addon)) - self.path_data(addon).mkdir() + # add new repository + async def _add_repository(url): + """Helper function to async add repository.""" + repository = Repository(self.config, self.loop, self.data, url) + if not await repository.load(): + _LOGGER.error("Can't load from repository %s", url) + return + self.repositories[url] = repository - addon_docker = DockerAddon( - self.config, self.loop, self.dock, self, addon) + # don't add built-in repository to config + if url not in BUILTIN_REPOSITORIES: + self.config.addons_repositories = url - version = version or self.get_last_version(addon) - if not await addon_docker.install(version): - return False + tasks = [_add_repository(url) for url in new_rep - old_rep] + if tasks: + await asyncio.wait(tasks, loop=self.loop) - self.dockers[addon] = addon_docker - self.set_addon_install(addon, version) - return True + # del new repository + for url in old_rep - new_rep - BUILTIN_REPOSITORIES: + self.repositories.pop(url).remove() + self.config.drop_addon_repository(url) - async def uninstall(self, addon): - """Remove a addon.""" - if not self.is_installed(addon): - _LOGGER.error("Addon %s is already uninstalled", addon) - return False + # update data + self.data.reload() + await self.load_addons() - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return False + async def load_addons(self): + """Update/add internal addon store.""" + all_addons = set(self.data.system) | set(self.data.cache) - if not await self.dockers[addon].remove(): - return False + # calc diff + add_addons = all_addons - set(self.addons) + del_addons = set(self.addons) - all_addons - if self.path_data(addon).is_dir(): - _LOGGER.info("Remove Home-Assistant addon data folder %s", - self.path_data(addon)) - shutil.rmtree(str(self.path_data(addon))) + _LOGGER.info("Load addons: %d all - %d new - %d remove", + len(all_addons), len(add_addons), len(del_addons)) - self.dockers.pop(addon) - self.set_addon_uninstall(addon) - return True + # new addons + tasks = [] + for addon_slug in add_addons: + addon = Addon( + self.config, self.loop, self.dock, self.data, addon_slug) - async def state(self, addon): - """Return running state of addon.""" - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return + tasks.append(addon.load()) + self.addons[addon_slug] = addon - if await self.dockers[addon].is_running(): - return STATE_STARTED - return STATE_STOPPED + if tasks: + await asyncio.wait(tasks, loop=self.loop) - async def start(self, addon): - """Set options and start addon.""" - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return False + # remove + for addon_slug in del_addons: + self.addons.pop(addon_slug) - if not self.write_addon_options(addon): - _LOGGER.error("Can't write options for addon %s", addon) - return False + async def auto_boot(self, stage): + """Boot addons with mode auto.""" + tasks = [] + for addon in self.addons.values(): + if addon.is_installed and addon.boot == BOOT_AUTO and \ + addon.startup == stage: + tasks.append(addon.start()) - return await self.dockers[addon].run() - - async def stop(self, addon): - """Stop addon.""" - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return False - - return await self.dockers[addon].stop() - - async def update(self, addon, version=None): - """Update addon.""" - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return False - - version = version or self.get_last_version(addon) - - # update - if not await self.dockers[addon].update(version): - return False - - self.set_addon_update(addon, version) - return True - - async def restart(self, addon): - """Restart addon.""" - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return False - - if not self.write_addon_options(addon): - _LOGGER.error("Can't write options for addon %s", addon) - return False - - return await self.dockers[addon].restart() - - async def logs(self, addon): - """Return addons log output.""" - if addon not in self.dockers: - _LOGGER.error("No docker found for addon %s", addon) - return False - - return await self.dockers[addon].logs() + _LOGGER.info("Startup %s run %d addons", stage, len(tasks)) + if tasks: + await asyncio.wait(tasks, loop=self.loop) diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py new file mode 100644 index 000000000..57eb1942a --- /dev/null +++ b/hassio/addons/addon.py @@ -0,0 +1,356 @@ +"""Init file for HassIO addons.""" +from copy import deepcopy +import logging +from pathlib import Path, PurePath +import re +import shutil + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from .validate import validate_options, MAP_VOLUME +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, + ATTR_URL, ATTR_ARCH, ATTR_LOCATON, ATTR_DEVICES, ATTR_ENVIRONMENT, + ATTR_HOST_NETWORK, ATTR_TMPFS, ATTR_PRIVILEGED, ATTR_STARTUP, + STATE_STARTED, STATE_STOPPED, STATE_NONE) +from ..dock.addon import DockerAddon +from ..tools import write_json_file + +_LOGGER = logging.getLogger(__name__) + +RE_VOLUME = re.compile(MAP_VOLUME) + + +class Addon(object): + """Hold data for addon inside HassIO.""" + + def __init__(self, config, loop, dock, data, addon_slug): + """Initialize data holder.""" + self.config = config + self.data = data + self._id = addon_slug + + if self._mesh is None: + raise RuntimeError("{} not a valid addon!".format(self._id)) + + self.addon_docker = DockerAddon(config, loop, dock, self) + + async def load(self): + """Async initialize of object.""" + if self.is_installed: + await self.addon_docker.attach() + + @property + def slug(self): + """Return slug/id of addon.""" + return self._id + + @property + def _mesh(self): + """Return addon data from system or cache.""" + return self.data.system.get(self._id, self.data.cache.get(self._id)) + + @property + def is_installed(self): + """Return True if a addon is installed.""" + return self._id in self.data.system + + @property + def is_detached(self): + """Return True if addon is detached.""" + return self._id not in self.data.cache + + @property + def version_installed(self): + """Return installed version.""" + return self.data.user.get(self._id, {}).get(ATTR_VERSION) + + def _set_install(self, version): + """Set addon as installed.""" + self.data.system[self._id] = deepcopy(self.data.cache[self._id]) + self.data.user[self._id] = { + ATTR_OPTIONS: {}, + ATTR_VERSION: version, + } + self.data.save() + + def _set_uninstall(self): + """Set addon as uninstalled.""" + self.data.system.pop(self._id, None) + self.data.user.pop(self._id, None) + self.data.save() + + def _set_update(self, version): + """Update version of addon.""" + self.data.system[self._id] = deepcopy(self.data.cache[self._id]) + self.data.user[self._id][ATTR_VERSION] = version + self.data.save() + + @property + def options(self): + """Return options with local changes.""" + if self.is_installed: + return { + **self.data.system[self._id][ATTR_OPTIONS], + **self.data.user[self._id][ATTR_OPTIONS], + } + + @options.setter + def options(self, value): + """Store user addon options.""" + self.data.user[self._id][ATTR_OPTIONS] = deepcopy(value) + self.data.save() + + @property + def boot(self): + """Return boot config with prio local settings.""" + if ATTR_BOOT in self.data.user.get(self._id, {}): + return self.data.user[self._id][ATTR_BOOT] + return self._mesh[ATTR_BOOT] + + @boot.setter + def boot(self, value): + """Store user boot options.""" + self.data.user[self._id][ATTR_BOOT] = value + self.data.save() + + @property + def name(self): + """Return name of addon.""" + return self._mesh[ATTR_NAME] + + @property + def description(self): + """Return description of addon.""" + return self._mesh[ATTR_DESCRIPTON] + + @property + def repository(self): + """Return repository of addon.""" + return self._mesh[ATTR_REPOSITORY] + + @property + def last_version(self): + """Return version of addon.""" + if self._id in self.data.cache: + return self.data.cache[self._id][ATTR_VERSION] + return self.version_installed + + @property + def startup(self): + """Return startup type of addon.""" + return self._mesh.get(ATTR_STARTUP) + + @property + def ports(self): + """Return ports of addon.""" + return self._mesh.get(ATTR_PORTS) + + @property + def network_mode(self): + """Return network mode of addon.""" + if self._mesh[ATTR_HOST_NETWORK]: + return 'host' + return 'bridge' + + @property + def devices(self): + """Return devices of addon.""" + return self._mesh.get(ATTR_DEVICES) + + @property + def tmpfs(self): + """Return tmpfs of addon.""" + return self._mesh.get(ATTR_TMPFS) + + @property + def environment(self): + """Return environment of addon.""" + return self._mesh.get(ATTR_ENVIRONMENT) + + @property + def privileged(self): + """Return list of privilege.""" + return self._mesh.get(ATTR_PRIVILEGED) + + @property + def url(self): + """Return url of addon.""" + return self._mesh.get(ATTR_URL) + + @property + def supported_arch(self): + """Return list of supported arch.""" + return self._mesh[ATTR_ARCH] + + @property + def image(self): + """Return image name of addon.""" + addon_data = self._mesh + + # Repository with dockerhub images + if ATTR_IMAGE in addon_data: + return addon_data[ATTR_IMAGE].format(arch=self.config.arch) + + # local build + return "{}/{}-addon-{}".format( + addon_data[ATTR_REPOSITORY], self.config.arch, + addon_data[ATTR_SLUG]) + + @property + def need_build(self): + """Return True if this addon need a local build.""" + return ATTR_IMAGE not in self._mesh + + @property + def map_volumes(self): + """Return a dict of {volume: policy} from addon.""" + volumes = {} + for volume in self._mesh[ATTR_MAP]: + result = RE_VOLUME.match(volume) + volumes[result.group(1)] = result.group(2) or 'ro' + + return volumes + + @property + def path_data(self): + """Return addon data path inside supervisor.""" + return Path(self.config.path_addons_data, self._id) + + @property + def path_extern_data(self): + """Return addon data path external for docker.""" + return PurePath(self.config.path_extern_addons_data, self._id) + + @property + def path_addon_options(self): + """Return path to addons options.""" + return Path(self.path_data, "options.json") + + @property + def path_addon_location(self): + """Return path to this addon.""" + return Path(self._mesh[ATTR_LOCATON]) + + def write_addon_options(self): + """Return True if addon options is written to data.""" + schema = self.schema + options = self.options + + try: + schema(options) + return write_json_file(self.path_addon_options, options) + except vol.Invalid as ex: + _LOGGER.error("Addon %s have wrong options -> %s", self._id, + humanize_error(options, ex)) + + return False + + @property + def schema(self): + """Create a schema for addon options.""" + raw_schema = self._mesh[ATTR_SCHEMA] + + if isinstance(raw_schema, bool): + return vol.Schema(dict) + return vol.Schema(vol.All(dict, validate_options(raw_schema))) + + async def install(self, version=None): + """Install a addon.""" + if self.config.arch not in self.supported_arch: + raise RuntimeError("Addon {} not supported on {}".format( + self._id, self.config.arch)) + + if self.is_installed: + raise RuntimeError( + "Addon {} is already installed".format(self._id)) + + if not self.path_data.is_dir(): + _LOGGER.info( + "Create Home-Assistant addon data folder %s", self.path_data) + self.path_data.mkdir() + + version = version or self.last_version + if not await self.addon_docker.install(version): + return False + + self._set_install(version) + return True + + async def uninstall(self): + """Remove a addon.""" + if not self.is_installed: + raise RuntimeError("Addon {} is not installed".format(self._id)) + + if not await self.addon_docker.remove(): + return False + + if self.path_data.is_dir(): + _LOGGER.info( + "Remove Home-Assistant addon data folder %s", self.path_data) + shutil.rmtree(str(self.path_data)) + + self._set_uninstall() + return True + + async def state(self): + """Return running state of addon.""" + if not self.is_installed: + return STATE_NONE + + if await self.addon_docker.is_running(): + return STATE_STARTED + return STATE_STOPPED + + async def start(self): + """Set options and start addon.""" + if not self.is_installed: + raise RuntimeError("Addon {} is not installed".format(self._id)) + + if not self.write_addon_options(): + raise RuntimeError( + "Can't write options for addon {}".format(self._id)) + + return await self.addon_docker.run() + + async def stop(self): + """Stop addon.""" + if not self.is_installed: + raise RuntimeError("Addon {} is not installed".format(self._id)) + + return await self.addon_docker.stop() + + async def update(self, version=None): + """Update addon.""" + if not self.is_installed: + raise RuntimeError("Addon {} is not installed".format(self._id)) + + version = version or self.last_version + if version == self.version_installed: + raise RuntimeError("Version is already in use") + + if not await self.addon_docker.update(version): + return False + + self._set_update(version) + return True + + async def restart(self): + """Restart addon.""" + if not self.is_installed: + raise RuntimeError("Addon {} is not installed".format(self._id)) + + if not self.write_addon_options(): + raise RuntimeError( + "Can't write options for addon {}".format(self._id)) + + return await self.addon_docker.restart() + + async def logs(self): + """Return addons log output.""" + if not self.is_installed: + raise RuntimeError("Addon {} is not installed".format(self._id)) + + return await self.addon_docker.logs() diff --git a/hassio/addons/built-in.json b/hassio/addons/built-in.json index 2b802f355..4d0b11ac6 100644 --- a/hassio/addons/built-in.json +++ b/hassio/addons/built-in.json @@ -1,12 +1,10 @@ { "local": { - "slug": "local", "name": "Local Add-Ons", "url": "https://home-assistant.io/hassio", "maintainer": "By our self" }, "core": { - "slug": "core", "name": "Built-in Add-Ons", "url": "https://home-assistant.io/addons", "maintainer": "Home Assistant authors" diff --git a/hassio/addons/data.py b/hassio/addons/data.py index e17f68041..ffb685dd9 100644 --- a/hassio/addons/data.py +++ b/hassio/addons/data.py @@ -2,7 +2,7 @@ import copy import logging import json -from pathlib import Path, PurePath +from pathlib import Path import re import voluptuous as vol @@ -10,29 +10,22 @@ from voluptuous.humanize import humanize_error from .util import extract_hash_from_path from .validate import ( - validate_options, SCHEMA_ADDON_CONFIG, SCHEMA_REPOSITORY_CONFIG, - MAP_VOLUME) + SCHEMA_ADDON_CONFIG, SCHEMA_REPOSITORY_CONFIG, MAP_VOLUME) from ..const import ( - FILE_HASSIO_ADDONS, ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, - ATTR_STARTUP, ATTR_BOOT, ATTR_MAP, ATTR_OPTIONS, ATTR_PORTS, BOOT_AUTO, - ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY, ATTR_URL, ATTR_ARCH, - ATTR_LOCATON, ATTR_DEVICES, ATTR_ENVIRONMENT, ATTR_HOST_NETWORK, - ATTR_TMPFS, ATTR_PRIVILEGED) + FILE_HASSIO_ADDONS, ATTR_VERSION, ATTR_SLUG, ATTR_REPOSITORY, ATTR_LOCATON, + REPOSITORY_CORE, REPOSITORY_LOCAL) from ..config import Config -from ..tools import read_json_file, write_json_file +from ..tools import read_json_file _LOGGER = logging.getLogger(__name__) SYSTEM = 'system' USER = 'user' -REPOSITORY_CORE = 'core' -REPOSITORY_LOCAL = 'local' - RE_VOLUME = re.compile(MAP_VOLUME) -class AddonsData(Config): +class Data(Config): """Hold data for addons inside HassIO.""" def __init__(self, config): @@ -41,9 +34,8 @@ class AddonsData(Config): self.config = config self._system_data = self._data.get(SYSTEM, {}) self._user_data = self._data.get(USER, {}) - self._addons_cache = {} + self._cache_data = {} self._repositories_data = {} - self.arch = None def save(self): """Store data to config file.""" @@ -53,9 +45,29 @@ class AddonsData(Config): } super().save() - def read_data_from_repositories(self): + @property + def user(self): + """Return local addon user data.""" + return self._user_data + + @property + def system(self): + """Return local addon data.""" + return self._system_data + + @property + def cache(self): + """Return addon data from cache/repositories.""" + return self._cache_data + + @property + def repositories(self): + """Return addon data from repositories.""" + return self._repositories_data + + def reload(self): """Read data from addons repository.""" - self._addons_cache = {} + self._cache_data = {} self._repositories_data = {} # read core repository @@ -74,17 +86,19 @@ class AddonsData(Config): if repository_element.is_dir(): self._read_git_repository(repository_element) + # update local data + self._merge_config() + def _read_git_repository(self, path): """Process a custom repository folder.""" slug = extract_hash_from_path(path) - repository_info = {ATTR_SLUG: slug} # exists repository json repository_file = Path(path, "repository.json") try: - repository_info.update(SCHEMA_REPOSITORY_CONFIG( + repository_info = SCHEMA_REPOSITORY_CONFIG( read_json_file(repository_file) - )) + ) except OSError: _LOGGER.warning("Can't read repository information from %s", @@ -115,7 +129,7 @@ class AddonsData(Config): # store addon_config[ATTR_REPOSITORY] = repository addon_config[ATTR_LOCATON] = str(addon.parent) - self._addons_cache[addon_slug] = addon_config + self._cache_data[addon_slug] = addon_config except OSError: _LOGGER.warning("Can't read %s", addon) @@ -133,33 +147,27 @@ class AddonsData(Config): _LOGGER.warning("Can't read built-in.json -> %s", err) return - # if core addons are available - for data in self._addons_cache.values(): - if data[ATTR_REPOSITORY] == REPOSITORY_CORE: - self._repositories_data[REPOSITORY_CORE] = \ - builtin_data[REPOSITORY_CORE] - break + # core repository + self._repositories_data[REPOSITORY_CORE] = \ + builtin_data[REPOSITORY_CORE] - # if local addons are available - for data in self._addons_cache.values(): - if data[ATTR_REPOSITORY] == REPOSITORY_LOCAL: - self._repositories_data[REPOSITORY_LOCAL] = \ - builtin_data[REPOSITORY_LOCAL] - break + # local repository + self._repositories_data[REPOSITORY_LOCAL] = \ + builtin_data[REPOSITORY_LOCAL] - def merge_update_config(self): + def _merge_config(self): """Update local config if they have update. - It need to be the same version as the local version is. + It need to be the same version as the local version is for merge. """ have_change = False - for addon in self.list_installed: + for addon in set(self._system_data): # detached - if addon not in self._addons_cache: + if addon not in self._cache_data: continue - cache = self._addons_cache[addon] + cache = self._cache_data[addon] data = self._system_data[addon] if data[ATTR_VERSION] == cache[ATTR_VERSION]: if data != cache: @@ -168,232 +176,3 @@ class AddonsData(Config): if have_change: self.save() - - @property - def list_installed(self): - """Return a list of installed addons.""" - return set(self._system_data) - - @property - def list_all(self): - """Return a dict of all addons.""" - return set(self._system_data) | set(self._addons_cache) - - def list_startup(self, start_type): - """Get list of installed addon with need start by type.""" - addon_list = set() - for addon in self._system_data.keys(): - if self.get_boot(addon) != BOOT_AUTO: - continue - - try: - if self._system_data[addon][ATTR_STARTUP] == start_type: - addon_list.add(addon) - except KeyError: - _LOGGER.warning("Orphaned addon detect %s", addon) - continue - - return addon_list - - @property - def list_detached(self): - """Return local addons they not support from repo.""" - addon_list = set() - for addon in self._system_data.keys(): - if addon not in self._addons_cache: - addon_list.add(addon) - - return addon_list - - @property - def list_repositories(self): - """Return list of addon repositories.""" - return list(self._repositories_data.values()) - - def exists_addon(self, addon): - """Return True if a addon exists.""" - return addon in self._addons_cache or addon in self._system_data - - def is_installed(self, addon): - """Return True if a addon is installed.""" - return addon in self._system_data - - def version_installed(self, addon): - """Return installed version.""" - return self._user_data.get(addon, {}).get(ATTR_VERSION) - - def set_addon_install(self, addon, version): - """Set addon as installed.""" - self._system_data[addon] = copy.deepcopy(self._addons_cache[addon]) - self._user_data[addon] = { - ATTR_OPTIONS: {}, - ATTR_VERSION: version, - } - self.save() - - def set_addon_uninstall(self, addon): - """Set addon as uninstalled.""" - self._system_data.pop(addon, None) - self._user_data.pop(addon, None) - self.save() - - def set_addon_update(self, addon, version): - """Update version of addon.""" - self._system_data[addon] = copy.deepcopy(self._addons_cache[addon]) - self._user_data[addon][ATTR_VERSION] = version - self.save() - - def set_options(self, addon, options): - """Store user addon options.""" - self._user_data[addon][ATTR_OPTIONS] = copy.deepcopy(options) - self.save() - - def set_boot(self, addon, boot): - """Store user boot options.""" - self._user_data[addon][ATTR_BOOT] = boot - self.save() - - def get_options(self, addon): - """Return options with local changes.""" - return { - **self._system_data[addon][ATTR_OPTIONS], - **self._user_data[addon][ATTR_OPTIONS], - } - - def get_boot(self, addon): - """Return boot config with prio local settings.""" - if ATTR_BOOT in self._user_data[addon]: - return self._user_data[addon][ATTR_BOOT] - - return self._system_data[addon][ATTR_BOOT] - - def get_name(self, addon): - """Return name of addon.""" - if addon in self._addons_cache: - return self._addons_cache[addon][ATTR_NAME] - return self._system_data[addon][ATTR_NAME] - - def get_description(self, addon): - """Return description of addon.""" - if addon in self._addons_cache: - return self._addons_cache[addon][ATTR_DESCRIPTON] - return self._system_data[addon][ATTR_DESCRIPTON] - - def get_repository(self, addon): - """Return repository of addon.""" - if addon in self._addons_cache: - return self._addons_cache[addon][ATTR_REPOSITORY] - return self._system_data[addon][ATTR_REPOSITORY] - - def get_last_version(self, addon): - """Return version of addon.""" - if addon in self._addons_cache: - return self._addons_cache[addon][ATTR_VERSION] - return self.version_installed(addon) - - def get_ports(self, addon): - """Return ports of addon.""" - return self._system_data[addon].get(ATTR_PORTS) - - def get_network_mode(self, addon): - """Return network mode of addon.""" - if self._system_data[addon][ATTR_HOST_NETWORK]: - return 'host' - return 'bridge' - - def get_devices(self, addon): - """Return devices of addon.""" - return self._system_data[addon].get(ATTR_DEVICES) - - def get_tmpfs(self, addon): - """Return tmpfs of addon.""" - return self._system_data[addon].get(ATTR_TMPFS) - - def get_environment(self, addon): - """Return environment of addon.""" - return self._system_data[addon].get(ATTR_ENVIRONMENT) - - def get_privileged(self, addon): - """Return list of privilege.""" - return self._system_data[addon].get(ATTR_PRIVILEGED) - - def get_url(self, addon): - """Return url of addon.""" - if addon in self._addons_cache: - return self._addons_cache[addon].get(ATTR_URL) - return self._system_data[addon].get(ATTR_URL) - - def get_arch(self, addon): - """Return list of supported arch.""" - if addon in self._addons_cache: - return self._addons_cache[addon][ATTR_ARCH] - return self._system_data[addon][ATTR_ARCH] - - def get_image(self, addon): - """Return image name of addon.""" - addon_data = self._system_data.get( - addon, self._addons_cache.get(addon) - ) - - # Repository with dockerhub images - if ATTR_IMAGE in addon_data: - return addon_data[ATTR_IMAGE].format(arch=self.arch) - - # local build - return "{}/{}-addon-{}".format( - addon_data[ATTR_REPOSITORY], self.arch, addon_data[ATTR_SLUG]) - - def need_build(self, addon): - """Return True if this addon need a local build.""" - addon_data = self._system_data.get( - addon, self._addons_cache.get(addon) - ) - return ATTR_IMAGE not in addon_data - - def map_volumes(self, addon): - """Return a dict of {volume: policy} from addon.""" - volumes = {} - for volume in self._system_data[addon][ATTR_MAP]: - result = RE_VOLUME.match(volume) - volumes[result.group(1)] = result.group(2) or 'ro' - - return volumes - - def path_data(self, addon): - """Return addon data path inside supervisor.""" - return Path(self.config.path_addons_data, addon) - - def path_extern_data(self, addon): - """Return addon data path external for docker.""" - return PurePath(self.config.path_extern_addons_data, addon) - - def path_addon_options(self, addon): - """Return path to addons options.""" - return Path(self.path_data(addon), "options.json") - - def path_addon_location(self, addon): - """Return path to this addon.""" - return Path(self._addons_cache[addon][ATTR_LOCATON]) - - def write_addon_options(self, addon): - """Return True if addon options is written to data.""" - schema = self.get_schema(addon) - options = self.get_options(addon) - - try: - schema(options) - return write_json_file(self.path_addon_options(addon), options) - except vol.Invalid as ex: - _LOGGER.error("Addon %s have wrong options -> %s", addon, - humanize_error(options, ex)) - - return False - - def get_schema(self, addon): - """Create a schema for addon options.""" - raw_schema = self._system_data[addon][ATTR_SCHEMA] - - if isinstance(raw_schema, bool): - return vol.Schema(dict) - - return vol.Schema(vol.All(dict, validate_options(raw_schema))) diff --git a/hassio/addons/git.py b/hassio/addons/git.py index f9f8ab6bd..5e9c02618 100644 --- a/hassio/addons/git.py +++ b/hassio/addons/git.py @@ -12,7 +12,7 @@ from ..const import URL_HASSIO_ADDONS _LOGGER = logging.getLogger(__name__) -class AddonsRepo(object): +class GitRepo(object): """Manage addons git repo.""" def __init__(self, config, loop, path, url): @@ -77,7 +77,7 @@ class AddonsRepo(object): return True -class AddonsRepoHassIO(AddonsRepo): +class GitRepoHassIO(GitRepo): """HassIO addons repository.""" def __init__(self, config, loop): @@ -86,7 +86,7 @@ class AddonsRepoHassIO(AddonsRepo): config, loop, config.path_addons_core, URL_HASSIO_ADDONS) -class AddonsRepoCustom(AddonsRepo): +class GitRepoCustom(GitRepo): """Custom addons repository.""" def __init__(self, config, loop, url): diff --git a/hassio/addons/repository.py b/hassio/addons/repository.py new file mode 100644 index 000000000..71abf6edf --- /dev/null +++ b/hassio/addons/repository.py @@ -0,0 +1,69 @@ +"""Represent a HassIO repository.""" +from .git import GitRepoHassIO, GitRepoCustom +from .util import get_hash_from_repository +from ..const import ( + REPOSITORY_CORE, REPOSITORY_LOCAL, ATTR_NAME, ATTR_URL, ATTR_MAINTAINER) + + +class Repository(object): + """Repository in HassIO.""" + + def __init__(self, config, loop, data, repository): + """Initialize repository object.""" + self.data = data + self.source = None + self.git = None + + if repository == REPOSITORY_LOCAL: + self._id = repository + elif repository == REPOSITORY_CORE: + self._id = repository + self.git = GitRepoHassIO(config, loop) + else: + self._id = get_hash_from_repository(repository) + self.git = GitRepoCustom(config, loop, repository) + self.source = repository + + @property + def _mesh(self): + """Return data struct repository.""" + return self.data.repositories.get(self._id, {}) + + @property + def slug(self): + """Return slug of repository.""" + return self._id + + @property + def name(self): + """Return name of repository.""" + return self._mesh.get(ATTR_NAME, self.source) + + @property + def url(self): + """Return url of repository.""" + return self._mesh.get(ATTR_URL) + + @property + def maintainer(self): + """Return url of repository.""" + return self._mesh.get(ATTR_MAINTAINER) + + async def load(self): + """Load addon repository.""" + if self.git: + return await self.git.load() + return True + + async def update(self): + """Update addon repository.""" + if self.git: + return await self.git.pull() + return True + + def remove(self): + """Remove addon repository.""" + if self._id in (REPOSITORY_CORE, REPOSITORY_LOCAL): + raise RuntimeError("Can't remove built-in repositories!") + + self.git.remove() diff --git a/hassio/addons/util.py b/hassio/addons/util.py index c0d097734..152c28866 100644 --- a/hassio/addons/util.py +++ b/hassio/addons/util.py @@ -19,8 +19,3 @@ def extract_hash_from_path(path): if not RE_SHA1.match(repo_dir): return get_hash_from_repository(repo_dir) return repo_dir - - -def create_hash_index_list(name_list): - """Create a dict with hash from repositories list.""" - return {get_hash_from_repository(repo): repo for repo in name_list} diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 3f15c67b1..ed12b98da 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -3,13 +3,12 @@ import asyncio import logging import voluptuous as vol -from voluptuous.humanize import humanize_error from .util import api_process, api_process_raw, api_validate from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS, ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY, - ATTR_BUILD, STATE_STOPPED, STATE_STARTED, BOOT_AUTO, BOOT_MANUAL) + ATTR_BUILD, BOOT_AUTO, BOOT_MANUAL) _LOGGER = logging.getLogger(__name__) @@ -31,15 +30,11 @@ class APIAddons(object): self.loop = loop self.addons = addons - def _extract_addon(self, request, check_installed=True): + def _extract_addon(self, request): """Return addon and if not exists trow a exception.""" - addon = request.match_info.get('addon') - - # check data - if not self.addons.exists_addon(addon): + addon = self.addons.get(request.match_info.get('addon')) + if not addon: raise RuntimeError("Addon not exists") - if check_installed and not self.addons.is_installed(addon): - raise RuntimeError("Addon is not installed") return addon @@ -49,35 +44,37 @@ class APIAddons(object): addon = self._extract_addon(request) return { - ATTR_NAME: self.addons.get_name(addon), - ATTR_DESCRIPTON: self.addons.get_description(addon), - ATTR_VERSION: self.addons.version_installed(addon), - ATTR_REPOSITORY: self.addons.get_repository(addon), - ATTR_LAST_VERSION: self.addons.get_last_version(addon), - ATTR_STATE: await self.addons.state(addon), - ATTR_BOOT: self.addons.get_boot(addon), - ATTR_OPTIONS: self.addons.get_options(addon), - ATTR_URL: self.addons.get_url(addon), - ATTR_DETACHED: addon in self.addons.list_detached, - ATTR_BUILD: self.addons.need_build(addon), + ATTR_NAME: addon.name, + ATTR_DESCRIPTON: addon.description, + ATTR_VERSION: addon.version_installed, + ATTR_REPOSITORY: addon.repository, + ATTR_LAST_VERSION: addon.last_version, + ATTR_STATE: await addon.state(), + ATTR_BOOT: addon.boot, + ATTR_OPTIONS: addon.options, + ATTR_URL: addon.url, + ATTR_DETACHED: addon.is_detached, + ATTR_BUILD: addon.need_build, } @api_process async def options(self, request): """Store user options for addon.""" addon = self._extract_addon(request) - options_schema = self.addons.get_schema(addon) + + if not addon.is_installed: + raise RuntimeError("Addon {} is not installed!".format(addon.slug)) addon_schema = SCHEMA_OPTIONS.extend({ - vol.Optional(ATTR_OPTIONS): options_schema, + vol.Optional(ATTR_OPTIONS): addon.schema, }) body = await api_validate(addon_schema, request) if ATTR_OPTIONS in body: - self.addons.set_options(addon, body[ATTR_OPTIONS]) + addon.options = body[ATTR_OPTIONS] if ATTR_BOOT in body: - self.addons.set_boot(addon, body[ATTR_BOOT]) + addon.boot = body[ATTR_BOOT] return True @@ -85,78 +82,49 @@ class APIAddons(object): async def install(self, request): """Install addon.""" body = await api_validate(SCHEMA_VERSION, request) - addon = self._extract_addon(request, check_installed=False) - version = body.get( - ATTR_VERSION, self.addons.get_last_version(addon)) - - # check if arch supported - if self.addons.arch not in self.addons.get_arch(addon): - raise RuntimeError( - "Addon is not supported on {}".format(self.addons.arch)) + addon = self._extract_addon(request) + version = body.get(ATTR_VERSION) return await asyncio.shield( - self.addons.install(addon, version), loop=self.loop) + addon.install(version=version), loop=self.loop) @api_process async def uninstall(self, request): """Uninstall addon.""" addon = self._extract_addon(request) - return await asyncio.shield( - self.addons.uninstall(addon), loop=self.loop) + return await asyncio.shield(addon.uninstall(), loop=self.loop) @api_process async def start(self, request): """Start addon.""" addon = self._extract_addon(request) - - if await self.addons.state(addon) == STATE_STARTED: - raise RuntimeError("Addon is already running") - - # validate options - try: - schema = self.addons.get_schema(addon) - options = self.addons.get_options(addon) - schema(options) - except vol.Invalid as ex: - raise RuntimeError(humanize_error(options, ex)) from None - - return await asyncio.shield( - self.addons.start(addon), loop=self.loop) + return await asyncio.shield(addon.start(), loop=self.loop) @api_process async def stop(self, request): """Stop addon.""" addon = self._extract_addon(request) - - if await self.addons.state(addon) == STATE_STOPPED: - raise RuntimeError("Addon is already stoped") - - return await asyncio.shield( - self.addons.stop(addon), loop=self.loop) + return await asyncio.shield(addon.stop(), loop=self.loop) @api_process async def update(self, request): """Update addon.""" body = await api_validate(SCHEMA_VERSION, request) addon = self._extract_addon(request) - version = body.get( - ATTR_VERSION, self.addons.get_last_version(addon)) - - if version == self.addons.version_installed(addon): - raise RuntimeError("Version is already in use") + version = body.get(ATTR_VERSION) return await asyncio.shield( - self.addons.update(addon, version), loop=self.loop) + addon.update(version=version), loop=self.loop) @api_process async def restart(self, request): """Restart addon.""" addon = self._extract_addon(request) - return await asyncio.shield(self.addons.restart(addon), loop=self.loop) + return await asyncio.shield(addon.restart(), loop=self.loop) @api_process_raw def logs(self, request): """Return logs from addon.""" addon = self._extract_addon(request) - return self.addons.logs(addon) + return addon.logs() diff --git a/hassio/api/supervisor.py b/hassio/api/supervisor.py index 0b51d9be4..6ce483770 100644 --- a/hassio/api/supervisor.py +++ b/hassio/api/supervisor.py @@ -5,7 +5,6 @@ import logging import voluptuous as vol from .util import api_process, api_process_raw, api_validate -from ..addons.util import create_hash_index_list from ..const import ( ATTR_ADDONS, ATTR_VERSION, ATTR_LAST_VERSION, ATTR_BETA_CHANNEL, HASSIO_VERSION, ATTR_ADDONS_REPOSITORIES, ATTR_REPOSITORIES, @@ -41,26 +40,22 @@ class APISupervisor(object): def _addons_list(self, only_installed=False): """Return a list of addons.""" - detached = self.addons.list_detached - - if only_installed: - addons = self.addons.list_installed - else: - addons = self.addons.list_all - data = [] - for addon in addons: + for addon in self.addons.list_addons: + if only_installed and not addon.is_installed: + continue + data.append({ - ATTR_NAME: self.addons.get_name(addon), - ATTR_SLUG: addon, - ATTR_DESCRIPTON: self.addons.get_description(addon), - ATTR_VERSION: self.addons.get_last_version(addon), - ATTR_INSTALLED: self.addons.version_installed(addon), - ATTR_ARCH: self.addons.get_arch(addon), - ATTR_DETACHED: addon in detached, - ATTR_REPOSITORY: self.addons.get_repository(addon), - ATTR_BUILD: self.addons.need_build(addon), - ATTR_URL: self.addons.get_url(addon), + ATTR_NAME: addon.name, + ATTR_SLUG: addon.slug, + ATTR_DESCRIPTON: addon.description, + ATTR_VERSION: addon.last_version, + ATTR_INSTALLED: addon.version_installed, + ATTR_ARCH: addon.supported_arch, + ATTR_DETACHED: addon.is_detached, + ATTR_REPOSITORY: addon.repository, + ATTR_BUILD: addon.need_build, + ATTR_URL: addon.url, }) return data @@ -68,15 +63,13 @@ class APISupervisor(object): def _repositories_list(self): """Return a list of addons repositories.""" data = [] - list_id = create_hash_index_list(self.config.addons_repositories) - for repository in self.addons.list_repositories: data.append({ - ATTR_SLUG: repository[ATTR_SLUG], - ATTR_NAME: repository[ATTR_NAME], - ATTR_SOURCE: list_id.get(repository[ATTR_SLUG]), - ATTR_URL: repository.get(ATTR_URL), - ATTR_MAINTAINER: repository.get(ATTR_MAINTAINER), + ATTR_SLUG: repository.slug, + ATTR_NAME: repository.name, + ATTR_SOURCE: repository.source, + ATTR_URL: repository.url, + ATTR_MAINTAINER: repository.maintainer, }) return data @@ -93,7 +86,7 @@ class APISupervisor(object): ATTR_VERSION: HASSIO_VERSION, ATTR_LAST_VERSION: self.config.last_hassio, ATTR_BETA_CHANNEL: self.config.upstream_beta, - ATTR_ARCH: self.addons.arch, + ATTR_ARCH: self.config.arch, ATTR_TIMEZONE: self.config.timezone, ATTR_ADDONS: self._addons_list(only_installed=True), ATTR_ADDONS_REPOSITORIES: self.config.addons_repositories, @@ -120,21 +113,7 @@ class APISupervisor(object): if ATTR_ADDONS_REPOSITORIES in body: new = set(body[ATTR_ADDONS_REPOSITORIES]) - old = set(self.config.addons_repositories) - - # add new repositories - tasks = [self.addons.add_git_repository(url) for url in - set(new - old)] - if tasks: - await asyncio.shield( - asyncio.wait(tasks, loop=self.loop), loop=self.loop) - - # remove old repositories - for url in set(old - new): - self.addons.drop_git_repository(url) - - # read repository - self.addons.read_data_from_repositories() + await asyncio.shield(self.addons.load_repositories(new)) return True diff --git a/hassio/api/util.py b/hassio/api/util.py index b59352dea..0c8583c43 100644 --- a/hassio/api/util.py +++ b/hassio/api/util.py @@ -81,6 +81,8 @@ def api_process_raw(method): def api_return_error(message=None): """Return a API error message.""" + _LOGGER.error(message) + return web.json_response({ JSON_RESULT: RESULT_ERROR, JSON_MESSAGE: message, diff --git a/hassio/config.py b/hassio/config.py index 00f074c4d..9e7ea3793 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -91,6 +91,7 @@ class CoreConfig(Config): def __init__(self, websession): """Initialize config object.""" self.websession = websession + self.arch = None super().__init__(FILE_HASSIO_CONFIG) diff --git a/hassio/const.py b/hassio/const.py index 1f7a8c0d4..a3ba6bcac 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -92,6 +92,7 @@ BOOT_MANUAL = 'manual' STATE_STARTED = 'started' STATE_STOPPED = 'stopped' +STATE_NONE = 'none' MAP_CONFIG = 'config' MAP_SSL = 'ssl' @@ -103,3 +104,6 @@ ARCH_ARMHF = 'armhf' ARCH_AARCH64 = 'aarch64' ARCH_AMD64 = 'amd64' ARCH_I386 = 'i386' + +REPOSITORY_CORE = 'core' +REPOSITORY_LOCAL = 'local' diff --git a/hassio/core.py b/hassio/core.py index 32767418d..93792476b 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -1,5 +1,4 @@ """Main file for HassIO.""" -import asyncio import logging import aiohttp @@ -20,7 +19,7 @@ from .dock.supervisor import DockerSupervisor from .tasks import ( hassio_update, homeassistant_watchdog, homeassistant_setup, api_sessions_cleanup) -from .tools import get_arch_from_image, get_local_ip, fetch_timezone +from .tools import get_local_ip, fetch_timezone _LOGGER = logging.getLogger(__name__) @@ -58,6 +57,9 @@ class HassIO(object): _LOGGER.fatal("Can't attach to supervisor docker container!") await self.supervisor.cleanup() + # set running arch + self.config.arch = self.supervisor.arch + # set api endpoint self.config.api_endpoint = await get_local_ip(self.loop) @@ -101,8 +103,7 @@ class HassIO(object): await self.homeassistant.attach() # Load addons - arch = get_arch_from_image(self.supervisor.image) - await self.addons.prepare(arch) + await self.addons.prepare() # schedule addon update task self.scheduler.register_task( @@ -148,9 +149,9 @@ class HassIO(object): # don't process scheduler anymore self.scheduler.stop() - # process stop task pararell - tasks = [self.websession.close(), self.api.stop()] - await asyncio.wait(tasks, loop=self.loop) + # process stop tasks + self.websession.close() + await self.api.stop() self.exit_code = exit_code self.loop.stop() diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index 764edd67c..17731c004 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -5,7 +5,7 @@ import logging import docker -from ..const import LABEL_VERSION +from ..const import LABEL_VERSION, LABEL_ARCH _LOGGER = logging.getLogger(__name__) @@ -20,6 +20,7 @@ class DockerBase(object): self.dock = dock self.image = image self.version = None + self.arch = None self._lock = asyncio.Lock(loop=loop) @property @@ -38,13 +39,18 @@ class DockerBase(object): if not self.image: self.image = metadata['Config']['Image'] - # read metadata + # read version need_version = force or not self.version if need_version and LABEL_VERSION in metadata['Config']['Labels']: self.version = metadata['Config']['Labels'][LABEL_VERSION] elif need_version: _LOGGER.warning("Can't read version from %s", self.name) + # read arch + need_arch = force or not self.arch + if need_arch and LABEL_ARCH in metadata['Config']['Labels']: + self.arch = metadata['Config']['Labels'][LABEL_ARCH] + async def install(self, tag): """Pull docker image.""" if self._lock.locked(): @@ -187,7 +193,7 @@ class DockerBase(object): if container.status == 'running': with suppress(docker.errors.DockerException): - container.stop() + container.stop(timeout=15) with suppress(docker.errors.DockerException): container.remove(force=True) diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index fbd8bca02..c0e196787 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -1,4 +1,5 @@ """Init file for HassIO addon docker object.""" +from contextlib import suppress import logging from pathlib import Path import shutil @@ -16,22 +17,21 @@ _LOGGER = logging.getLogger(__name__) class DockerAddon(DockerBase): """Docker hassio wrapper for HomeAssistant.""" - def __init__(self, config, loop, dock, addons_data, addon): + def __init__(self, config, loop, dock, addon): """Initialize docker homeassistant wrapper.""" super().__init__( - config, loop, dock, image=addons_data.get_image(addon)) + config, loop, dock, image=addon.image) self.addon = addon - self.addons_data = addons_data @property def name(self): """Return name of docker container.""" - return "addon_{}".format(self.addon) + return "addon_{}".format(self.addon.slug) @property def environment(self): """Return environment for docker add-on.""" - addon_env = self.addons_data.get_environment(self.addon) or {} + addon_env = self.addon.environment or {} return { **addon_env, @@ -41,7 +41,7 @@ class DockerAddon(DockerBase): @property def tmpfs(self): """Return tmpfs for docker add-on.""" - options = self.addons_data.get_tmpfs(self.addon) + options = self.addon.tmpfs if options: return {"/tmpfs": "{}".format(options)} return None @@ -50,11 +50,11 @@ class DockerAddon(DockerBase): def volumes(self): """Generate volumes for mappings.""" volumes = { - str(self.addons_data.path_extern_data(self.addon)): { + str(self.addon.path_extern_data): { 'bind': '/data', 'mode': 'rw' }} - addon_mapping = self.addons_data.map_volumes(self.addon) + addon_mapping = self.addon.map_volumes if MAP_CONFIG in addon_mapping: volumes.update({ @@ -104,10 +104,10 @@ class DockerAddon(DockerBase): self.image, name=self.name, detach=True, - network_mode=self.addons_data.get_network_mode(self.addon), - ports=self.addons_data.get_ports(self.addon), - devices=self.addons_data.get_devices(self.addon), - cap_add=self.addons_data.get_privileged(self.addon), + network_mode=self.addon.network_mode, + ports=self.addon.ports, + devices=self.addon.devices, + cap_add=self.addon.privileged, environment=self.environment, volumes=self.volumes, tmpfs=self.tmpfs @@ -126,7 +126,7 @@ class DockerAddon(DockerBase): Need run inside executor. """ - if self.addons_data.need_build(self.addon): + if self.addon.need_build: return self._build(tag) return super()._install(tag) @@ -145,11 +145,11 @@ class DockerAddon(DockerBase): Need run inside executor. """ - build_dir = Path(self.config.path_addons_build, self.addon) + build_dir = Path(self.config.path_addons_build, self.addon.slug) try: # prepare temporary addon build folder try: - source = self.addons_data.path_addon_location(self.addon) + source = self.addon.path_addon_location shutil.copytree(str(source), str(build_dir)) except shutil.Error as err: _LOGGER.error("Can't copy %s to temporary build folder -> %s", @@ -159,7 +159,7 @@ class DockerAddon(DockerBase): # prepare Dockerfile try: dockerfile_template( - Path(build_dir, 'Dockerfile'), self.addons_data.arch, + Path(build_dir, 'Dockerfile'), self.config.arch, tag, META_ADDON) except OSError as err: _LOGGER.error("Can't prepare dockerfile -> %s", err) @@ -184,3 +184,21 @@ class DockerAddon(DockerBase): finally: shutil.rmtree(str(build_dir), ignore_errors=True) + + def _restart(self): + """Restart docker container. + + Addons prepare some thing on start and that is normaly not repeatable. + Need run inside executor. + """ + try: + container = self.dock.containers.get(self.name) + except docker.errors.DockerException: + return False + + _LOGGER.info("Restart %s", self.image) + + with suppress(docker.errors.DockerException): + container.stop(timeout=15) + + return self._run() diff --git a/hassio/tools.py b/hassio/tools.py index 0879928fc..7147fe62d 100644 --- a/hassio/tools.py +++ b/hassio/tools.py @@ -3,7 +3,6 @@ import asyncio from contextlib import suppress import json import logging -import re import socket import aiohttp @@ -17,9 +16,6 @@ _LOGGER = logging.getLogger(__name__) FREEGEOIP_URL = "https://freegeoip.io/json/" -_RE_VERSION = re.compile(r"VERSION=(.*)") -_IMAGE_ARCH = re.compile(r".*/([a-z0-9]*)-hassio-supervisor") - async def fetch_last_versions(websession, beta=False): """Fetch current versions from github. @@ -39,13 +35,6 @@ async def fetch_last_versions(websession, beta=False): _LOGGER.warning("Can't parse versions from %s! %s", url, err) -def get_arch_from_image(image): - """Return arch from hassio image name.""" - found = _IMAGE_ARCH.match(image) - if found: - return found.group(1) - - def get_local_ip(loop): """Retrieve local IP address. From 40343089b50b652f16a5120bbf1e71679d427b05 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 26 Jun 2017 09:17:09 +0200 Subject: [PATCH 06/13] Fix error handing (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- API.md | 6 +++--- hassio/addons/addon.py | 37 ++++++++++++++++++++++--------------- hassio/addons/repository.py | 8 +++++--- hassio/api/addons.py | 12 ++++++------ hassio/dock/__init__.py | 4 ++-- 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/API.md b/API.md index 75db52f13..93e5c7233 100644 --- a/API.md +++ b/API.md @@ -78,10 +78,10 @@ Get all available addons "repositories": [ { "slug": "12345678", - "name": "Repitory Name", + "name": "Repitory Name|unknown", "source": "URL_OF_REPOSITORY", - "url": "null|WEBSITE", - "maintainer": "null|BLA BLU " + "url": "WEBSITE|REPOSITORY", + "maintainer": "BLA BLU |unknown" } ] } diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 57eb1942a..1ef79e685 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -260,12 +260,13 @@ class Addon(object): async def install(self, version=None): """Install a addon.""" if self.config.arch not in self.supported_arch: - raise RuntimeError("Addon {} not supported on {}".format( - self._id, self.config.arch)) + _LOGGER.error( + "Addon %s not supported on %s", self._id, self.config.arch) + return False if self.is_installed: - raise RuntimeError( - "Addon {} is already installed".format(self._id)) + _LOGGER.error("Addon %s is already installed", self._id) + return False if not self.path_data.is_dir(): _LOGGER.info( @@ -282,7 +283,8 @@ class Addon(object): async def uninstall(self): """Remove a addon.""" if not self.is_installed: - raise RuntimeError("Addon {} is not installed".format(self._id)) + _LOGGER.error("Addon %s is not installed", self._id) + return False if not await self.addon_docker.remove(): return False @@ -307,29 +309,33 @@ class Addon(object): async def start(self): """Set options and start addon.""" if not self.is_installed: - raise RuntimeError("Addon {} is not installed".format(self._id)) + _LOGGER.error("Addon %s is not installed", self._id) + return False if not self.write_addon_options(): - raise RuntimeError( - "Can't write options for addon {}".format(self._id)) + return False return await self.addon_docker.run() async def stop(self): """Stop addon.""" if not self.is_installed: - raise RuntimeError("Addon {} is not installed".format(self._id)) + _LOGGER.error("Addon %s is not installed", self._id) + return False return await self.addon_docker.stop() async def update(self, version=None): """Update addon.""" if not self.is_installed: - raise RuntimeError("Addon {} is not installed".format(self._id)) + _LOGGER.error("Addon %s is not installed", self._id) + return False version = version or self.last_version if version == self.version_installed: - raise RuntimeError("Version is already in use") + _LOGGER.warning( + "Addon %s is already installed in %s", self._id, version) + return True if not await self.addon_docker.update(version): return False @@ -340,17 +346,18 @@ class Addon(object): async def restart(self): """Restart addon.""" if not self.is_installed: - raise RuntimeError("Addon {} is not installed".format(self._id)) + _LOGGER.error("Addon %s is not installed", self._id) + return False if not self.write_addon_options(): - raise RuntimeError( - "Can't write options for addon {}".format(self._id)) + return False return await self.addon_docker.restart() async def logs(self): """Return addons log output.""" if not self.is_installed: - raise RuntimeError("Addon {} is not installed".format(self._id)) + _LOGGER.error("Addon %s is not installed", self._id) + return False return await self.addon_docker.logs() diff --git a/hassio/addons/repository.py b/hassio/addons/repository.py index 71abf6edf..73859c987 100644 --- a/hassio/addons/repository.py +++ b/hassio/addons/repository.py @@ -4,6 +4,8 @@ from .util import get_hash_from_repository from ..const import ( REPOSITORY_CORE, REPOSITORY_LOCAL, ATTR_NAME, ATTR_URL, ATTR_MAINTAINER) +UNKNOWN = 'unknown' + class Repository(object): """Repository in HassIO.""" @@ -37,17 +39,17 @@ class Repository(object): @property def name(self): """Return name of repository.""" - return self._mesh.get(ATTR_NAME, self.source) + return self._mesh.get(ATTR_NAME, UNKNOWN) @property def url(self): """Return url of repository.""" - return self._mesh.get(ATTR_URL) + return self._mesh.get(ATTR_URL, self.source) @property def maintainer(self): """Return url of repository.""" - return self._mesh.get(ATTR_MAINTAINER) + return self._mesh.get(ATTR_MAINTAINER, UNKNOWN) async def load(self): """Load addon repository.""" diff --git a/hassio/api/addons.py b/hassio/api/addons.py index ed12b98da..504a5f85b 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -30,18 +30,21 @@ class APIAddons(object): self.loop = loop self.addons = addons - def _extract_addon(self, request): + def _extract_addon(self, request, check_installed=True): """Return addon and if not exists trow a exception.""" addon = self.addons.get(request.match_info.get('addon')) if not addon: raise RuntimeError("Addon not exists") + if check_installed and not addon.is_installed: + raise RuntimeError("Addon is not installed") + return addon @api_process async def info(self, request): """Return addon information.""" - addon = self._extract_addon(request) + addon = self._extract_addon(request, check_installed=False) return { ATTR_NAME: addon.name, @@ -62,9 +65,6 @@ class APIAddons(object): """Store user options for addon.""" addon = self._extract_addon(request) - if not addon.is_installed: - raise RuntimeError("Addon {} is not installed!".format(addon.slug)) - addon_schema = SCHEMA_OPTIONS.extend({ vol.Optional(ATTR_OPTIONS): addon.schema, }) @@ -79,7 +79,7 @@ class APIAddons(object): return True @api_process - async def install(self, request): + async def install(self, request, check_installed=False): """Install addon.""" body = await api_validate(SCHEMA_VERSION, request) addon = self._extract_addon(request) diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index 17731c004..ba8c8b2f5 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -189,13 +189,13 @@ class DockerBase(object): except docker.errors.DockerException: return - _LOGGER.info("Stop %s docker application", self.image) - if container.status == 'running': + _LOGGER.info("Stop %s docker application", self.image) with suppress(docker.errors.DockerException): container.stop(timeout=15) with suppress(docker.errors.DockerException): + _LOGGER.info("Clean %s docker application", self.image) container.remove(force=True) async def remove(self): From d5eb66bc0da4c9ffc7ce59f713f46dbfca75c9c5 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 28 Jun 2017 16:22:44 +0200 Subject: [PATCH 07/13] Use tmp folder / fix log bug / add executor (#83) * Use tmp folder / fix log bug / add executor * Update __main__.py * Update .travis.yml * Add autoupdate on startup. * Fix bug * Move selfupdate code part into start --- .travis.yml | 2 +- hassio/__main__.py | 14 +++++++++++++- hassio/addons/addon.py | 1 + hassio/api/addons.py | 4 ++-- hassio/bootstrap.py | 25 +++++++++++++++++++------ hassio/config.py | 40 +++++++++++++++++++++------------------- hassio/const.py | 6 +++--- hassio/core.py | 40 ++++++++++++++++++++-------------------- hassio/dock/__init__.py | 2 +- hassio/dock/addon.py | 2 +- hassio/tasks.py | 7 ++++--- 11 files changed, 86 insertions(+), 57 deletions(-) diff --git a/.travis.yml b/.travis.yml index 129484ea8..e16f6a9fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ sudo: false matrix: fast_finish: true include: - - python: "3.5" + - python: "3.6" cache: directories: diff --git a/hassio/__main__.py b/hassio/__main__.py index 8f4d2bd0d..d8ba5bd6c 100644 --- a/hassio/__main__.py +++ b/hassio/__main__.py @@ -1,5 +1,6 @@ """Main file for HassIO.""" import asyncio +from concurrent.futures import ThreadPoolExecutor import logging import sys @@ -17,7 +18,14 @@ if __name__ == "__main__": exit(1) loop = asyncio.get_event_loop() - hassio = core.HassIO(loop) + executor = ThreadPoolExecutor(thread_name_prefix="SyncWorker") + loop.set_default_executor(executor) + + _LOGGER.info("Initialize Hassio setup") + config = bootstrap.initialize_system_data() + hassio = core.HassIO(loop, config) + + bootstrap.migrate_system_env(config) _LOGGER.info("Run Hassio setup") loop.run_until_complete(hassio.setup()) @@ -26,7 +34,11 @@ if __name__ == "__main__": loop.call_soon_threadsafe(loop.create_task, hassio.start()) loop.call_soon_threadsafe(bootstrap.reg_signal, loop, hassio) + _LOGGER.info("Run Hassio loop") loop.run_forever() + + _LOGGER.info("Cleanup system") + executor.shutdown(wait=False) loop.close() _LOGGER.info("Close Hassio") diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 1ef79e685..7b8da7299 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -96,6 +96,7 @@ class Addon(object): **self.data.system[self._id][ATTR_OPTIONS], **self.data.user[self._id][ATTR_OPTIONS], } + return self.data.cache[self._id][ATTR_OPTIONS] @options.setter def options(self, value): diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 504a5f85b..68f625550 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -79,10 +79,10 @@ class APIAddons(object): return True @api_process - async def install(self, request, check_installed=False): + async def install(self, request): """Install addon.""" body = await api_validate(SCHEMA_VERSION, request) - addon = self._extract_addon(request) + addon = self._extract_addon(request, check_installed=False) version = body.get(ATTR_VERSION) return await asyncio.shield( diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index 81648467e..0e161f9d5 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -2,6 +2,7 @@ import logging import os import signal +from pathlib import Path from colorlog import ColoredFormatter @@ -11,9 +12,9 @@ from .config import CoreConfig _LOGGER = logging.getLogger(__name__) -def initialize_system_data(websession): +def initialize_system_data(): """Setup default config and create folders.""" - config = CoreConfig(websession) + config = CoreConfig() # homeassistant config folder if not config.path_config.is_dir(): @@ -42,10 +43,10 @@ def initialize_system_data(websession): config.path_addons_git) config.path_addons_git.mkdir(parents=True) - if not config.path_addons_build.is_dir(): - _LOGGER.info("Create Home-Assistant addon build folder %s", - config.path_addons_build) - config.path_addons_build.mkdir(parents=True) + # hassio tmp folder + if not config.path_tmp.is_dir(): + _LOGGER.info("Create hassio temp folder %s", config.path_tmp) + config.path_tmp.mkdir(parents=True) # hassio backup folder if not config.path_backup.is_dir(): @@ -60,6 +61,18 @@ def initialize_system_data(websession): return config +def migrate_system_env(config): + """Cleanup some stuff after update.""" + + # hass.io 0.37 -> 0.38 + old_build = Path(config.path_hassio, "addons/build") + if old_build.is_dir(): + try: + old_build.rmdir() + except OSError: + _LOGGER.warning("Can't cleanup old addons build dir.") + + def initialize_logging(): """Setup the logging.""" logging.basicConfig(level=logging.INFO) diff --git a/hassio/config.py b/hassio/config.py index 9e7ea3793..30ff88d08 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -8,7 +8,7 @@ from pathlib import Path, PurePath import voluptuous as vol from voluptuous.humanize import humanize_error -from .const import FILE_HASSIO_CONFIG, HASSIO_SHARE +from .const import FILE_HASSIO_CONFIG, HASSIO_DATA from .tools import ( fetch_last_versions, write_json_file, read_json_file, validate_timezone) @@ -27,12 +27,11 @@ ADDONS_CORE = PurePath("addons/core") ADDONS_LOCAL = PurePath("addons/local") ADDONS_GIT = PurePath("addons/git") ADDONS_DATA = PurePath("addons/data") -ADDONS_BUILD = PurePath("addons/build") ADDONS_CUSTOM_LIST = 'addons_custom_list' BACKUP_DATA = PurePath("backup") - SHARE_DATA = PurePath("share") +TMP_DATA = PurePath("tmp") UPSTREAM_BETA = 'upstream_beta' API_ENDPOINT = 'api_endpoint' @@ -88,9 +87,8 @@ class Config(object): class CoreConfig(Config): """Hold all core config data.""" - def __init__(self, websession): + def __init__(self): """Initialize config object.""" - self.websession = websession self.arch = None super().__init__(FILE_HASSIO_CONFIG) @@ -103,10 +101,9 @@ class CoreConfig(Config): _LOGGER.warning( "Invalid config %s", humanize_error(self._data, ex)) - async def fetch_update_infos(self): + async def fetch_update_infos(self, websession): """Read current versions from web.""" - last = await fetch_last_versions( - self.websession, beta=self.upstream_beta) + last = await fetch_last_versions(websession, beta=self.upstream_beta) if last: self._data.update({ @@ -176,6 +173,11 @@ class CoreConfig(Config): """Actual version of hassio.""" return self._data.get(HASSIO_LAST) + @property + def path_hassio(self): + """Return hassio data path.""" + return HASSIO_DATA + @property def path_extern_hassio(self): """Return hassio data path extern for docker.""" @@ -189,7 +191,7 @@ class CoreConfig(Config): @property def path_config(self): """Return config path inside supervisor.""" - return Path(HASSIO_SHARE, HOMEASSISTANT_CONFIG) + return Path(HASSIO_DATA, HOMEASSISTANT_CONFIG) @property def path_extern_ssl(self): @@ -199,22 +201,22 @@ class CoreConfig(Config): @property def path_ssl(self): """Return SSL path inside supervisor.""" - return Path(HASSIO_SHARE, HASSIO_SSL) + return Path(HASSIO_DATA, HASSIO_SSL) @property def path_addons_core(self): """Return git path for core addons.""" - return Path(HASSIO_SHARE, ADDONS_CORE) + return Path(HASSIO_DATA, ADDONS_CORE) @property def path_addons_git(self): """Return path for git addons.""" - return Path(HASSIO_SHARE, ADDONS_GIT) + return Path(HASSIO_DATA, ADDONS_GIT) @property def path_addons_local(self): """Return path for customs addons.""" - return Path(HASSIO_SHARE, ADDONS_LOCAL) + return Path(HASSIO_DATA, ADDONS_LOCAL) @property def path_extern_addons_local(self): @@ -224,7 +226,7 @@ class CoreConfig(Config): @property def path_addons_data(self): """Return root addon data folder.""" - return Path(HASSIO_SHARE, ADDONS_DATA) + return Path(HASSIO_DATA, ADDONS_DATA) @property def path_extern_addons_data(self): @@ -232,14 +234,14 @@ class CoreConfig(Config): return PurePath(self.path_extern_hassio, ADDONS_DATA) @property - def path_addons_build(self): - """Return root addon build folder.""" - return Path(HASSIO_SHARE, ADDONS_BUILD) + def path_tmp(self): + """Return hass.io temp folder.""" + return Path(HASSIO_DATA, TMP_DATA) @property def path_backup(self): """Return root backup data folder.""" - return Path(HASSIO_SHARE, BACKUP_DATA) + return Path(HASSIO_DATA, BACKUP_DATA) @property def path_extern_backup(self): @@ -249,7 +251,7 @@ class CoreConfig(Config): @property def path_share(self): """Return root share data folder.""" - return Path(HASSIO_SHARE, SHARE_DATA) + return Path(HASSIO_DATA, SHARE_DATA) @property def path_extern_share(self): diff --git a/hassio/const.py b/hassio/const.py index a3ba6bcac..d1c9df809 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -10,7 +10,7 @@ URL_HASSIO_VERSION_BETA = ('https://raw.githubusercontent.com/home-assistant/' URL_HASSIO_ADDONS = 'https://github.com/home-assistant/hassio-addons' -HASSIO_SHARE = Path("/data") +HASSIO_DATA = Path("/data") RUN_UPDATE_INFO_TASKS = 28800 RUN_UPDATE_SUPERVISOR_TASKS = 29100 @@ -20,8 +20,8 @@ RUN_CLEANUP_API_SESSIONS = 900 RESTART_EXIT_CODE = 100 -FILE_HASSIO_ADDONS = Path(HASSIO_SHARE, "addons.json") -FILE_HASSIO_CONFIG = Path(HASSIO_SHARE, "config.json") +FILE_HASSIO_ADDONS = Path(HASSIO_DATA, "addons.json") +FILE_HASSIO_CONFIG = Path(HASSIO_DATA, "config.json") SOCKET_DOCKER = Path("/var/run/docker.sock") SOCKET_HC = Path("/var/run/hassio-hc.sock") diff --git a/hassio/core.py b/hassio/core.py index 93792476b..93f820b3e 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -4,7 +4,6 @@ import logging import aiohttp import docker -from . import bootstrap from .addons import AddonManager from .api import RestAPI from .host_control import HostControl @@ -27,28 +26,26 @@ _LOGGER = logging.getLogger(__name__) class HassIO(object): """Main object of hassio.""" - def __init__(self, loop): + def __init__(self, loop, config): """Initialize hassio object.""" self.exit_code = 0 self.loop = loop - self.websession = aiohttp.ClientSession(loop=self.loop) - self.config = bootstrap.initialize_system_data(self.websession) - self.scheduler = Scheduler(self.loop) - self.api = RestAPI(self.config, self.loop) + self.config = config + self.websession = aiohttp.ClientSession(loop=loop) + self.scheduler = Scheduler(loop) + self.api = RestAPI(config, loop) self.dock = docker.DockerClient( base_url="unix:/{}".format(str(SOCKET_DOCKER)), version='auto') # init basic docker container - self.supervisor = DockerSupervisor( - self.config, self.loop, self.dock, self.stop) - self.homeassistant = DockerHomeAssistant( - self.config, self.loop, self.dock) + self.supervisor = DockerSupervisor(config, loop, self.dock, self.stop) + self.homeassistant = DockerHomeAssistant(config, loop, self.dock) # init HostControl - self.host_control = HostControl(self.loop) + self.host_control = HostControl(loop) # init addon system - self.addons = AddonManager(self.config, self.loop, self.dock) + self.addons = AddonManager(config, loop, self.dock) async def setup(self): """Setup HassIO orchestration.""" @@ -72,8 +69,8 @@ class HassIO(object): # schedule update info tasks self.scheduler.register_task( - self.host_control.load, RUN_UPDATE_INFO_TASKS) + # rest api views self.api.register_host(self.host_control) self.api.register_network(self.host_control) @@ -89,16 +86,11 @@ class HassIO(object): api_sessions_cleanup(self.config), RUN_CLEANUP_API_SESSIONS, now=True) - # schedule update info tasks - self.scheduler.register_task( - self.config.fetch_update_infos, RUN_UPDATE_INFO_TASKS, - now=True) - # first start of supervisor? if not await self.homeassistant.exists(): _LOGGER.info("No HomeAssistant docker found.") await homeassistant_setup( - self.config, self.loop, self.homeassistant) + self.config, self.loop, self.homeassistant, self.websession) else: await self.homeassistant.attach() @@ -111,7 +103,7 @@ class HassIO(object): # schedule self update task self.scheduler.register_task( - hassio_update(self.config, self.supervisor), + hassio_update(self.config, self.supervisor, self.websession), RUN_UPDATE_SUPERVISOR_TASKS) # start addon mark as initialize @@ -119,6 +111,14 @@ class HassIO(object): async def start(self): """Start HassIO orchestration.""" + # on release channel, try update itself + # on beta channel, only read new versions + if not self.config.upstream_beta: + await self.loop.create_task( + hassio_update(self.config, self.supervisor, self.websession)()) + else: + await self.config.fetch_update_infos(self.websession) + # start api await self.api.start() _LOGGER.info("Start hassio api on %s", self.config.api_endpoint) diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index ba8c8b2f5..e53a361c4 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -267,7 +267,7 @@ class DockerBase(object): """Return docker logs of container.""" if self._lock.locked(): _LOGGER.error("Can't excute logs while a task is in progress") - return False + return b"" async with self._lock: return await self.loop.run_in_executor(None, self._logs) diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index c0e196787..1f6b1a317 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -145,7 +145,7 @@ class DockerAddon(DockerBase): Need run inside executor. """ - build_dir = Path(self.config.path_addons_build, self.addon.slug) + build_dir = Path(self.config.path_tmp, self.addon.slug) try: # prepare temporary addon build folder try: diff --git a/hassio/tasks.py b/hassio/tasks.py index 32e4c8a6d..824a5dc1c 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -18,10 +18,11 @@ def api_sessions_cleanup(config): return _api_sessions_cleanup -def hassio_update(config, supervisor): +def hassio_update(config, supervisor, websession): """Create scheduler task for update of supervisor hassio.""" async def _hassio_update(): """Check and run update of supervisor hassio.""" + await config.fetch_update_infos(websession) if config.last_hassio == supervisor.version: return @@ -43,12 +44,12 @@ def homeassistant_watchdog(loop, homeassistant): return _homeassistant_watchdog -async def homeassistant_setup(config, loop, homeassistant): +async def homeassistant_setup(config, loop, homeassistant, websession): """Install a homeassistant docker container.""" while True: # read homeassistant tag and install it if not config.last_homeassistant: - await config.fetch_update_infos() + await config.fetch_update_infos(websession) tag = config.last_homeassistant if tag and await homeassistant.install(tag): From 56a9f64730fa1f036d9bfc7269831af50520782d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 28 Jun 2017 23:06:06 +0200 Subject: [PATCH 08/13] Fix write options handling (#84) * Fix write options handling * Fix bug --- hassio/addons/addon.py | 8 +------- hassio/api/addons.py | 10 +++++++++- hassio/dock/addon.py | 6 +++++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 7b8da7299..c43b01558 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -235,7 +235,7 @@ class Addon(object): """Return path to this addon.""" return Path(self._mesh[ATTR_LOCATON]) - def write_addon_options(self): + def write_options(self): """Return True if addon options is written to data.""" schema = self.schema options = self.options @@ -313,9 +313,6 @@ class Addon(object): _LOGGER.error("Addon %s is not installed", self._id) return False - if not self.write_addon_options(): - return False - return await self.addon_docker.run() async def stop(self): @@ -350,9 +347,6 @@ class Addon(object): _LOGGER.error("Addon %s is not installed", self._id) return False - if not self.write_addon_options(): - return False - return await self.addon_docker.restart() async def logs(self): diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 68f625550..eb45e3f4d 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -3,6 +3,7 @@ import asyncio import logging import voluptuous as vol +from voluptuous.humanize import humanize_error from .util import api_process, api_process_raw, api_validate from ..const import ( @@ -92,13 +93,20 @@ class APIAddons(object): async def uninstall(self, request): """Uninstall addon.""" addon = self._extract_addon(request) - return await asyncio.shield(addon.uninstall(), loop=self.loop) @api_process async def start(self, request): """Start addon.""" addon = self._extract_addon(request) + + # check options + options = addon.options + try: + addon.schema(options) + except vol.Invalid as ex: + raise RuntimeError(humanize_error(options, ex)) from None + return await asyncio.shield(addon.start(), loop=self.loop) @api_process diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index 1f6b1a317..ffa0e9e21 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -94,11 +94,15 @@ class DockerAddon(DockerBase): Need run inside executor. """ if self._is_running(): - return + return True # cleanup self._stop() + # write config + if not self.addon.write_options(): + return False + try: self.dock.containers.run( self.image, From 6bb4f0e36986951172803cf0382383e37798028c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 29 Jun 2017 00:03:23 +0200 Subject: [PATCH 09/13] Update API.md --- API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API.md b/API.md index 93e5c7233..ddd0e95a4 100644 --- a/API.md +++ b/API.md @@ -244,7 +244,7 @@ Output the raw docker log "state": "none|started|stopped", "boot": "auto|manual", "build": "bool", - "options": "null|{}", + "options": "{}", } ``` From a33d76577683af7c9b6a95625c67a8f69361e845 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 29 Jun 2017 07:23:32 +0200 Subject: [PATCH 10/13] Update Hass.IO dev --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 58fa35175..21b6da62b 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "hassio": "0.37", + "hassio": "0.38", "homeassistant": "0.47.1", "resinos": "0.8", "resinhup": "0.1", From 0ed48a7741bb245559abe99e0126a4b10ad376c2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 29 Jun 2017 07:50:42 +0200 Subject: [PATCH 11/13] fix self update style on startup (#85) * fix self update style on startup * Check beta/dev upstream on update function --- hassio/core.py | 10 +++++----- hassio/tasks.py | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/hassio/core.py b/hassio/core.py index 93f820b3e..488966b66 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -1,4 +1,5 @@ """Main file for HassIO.""" +import asyncio import logging import aiohttp @@ -113,11 +114,10 @@ class HassIO(object): """Start HassIO orchestration.""" # on release channel, try update itself # on beta channel, only read new versions - if not self.config.upstream_beta: - await self.loop.create_task( - hassio_update(self.config, self.supervisor, self.websession)()) - else: - await self.config.fetch_update_infos(self.websession) + await asyncio.wait( + [hassio_update(self.config, self.supervisor, self.websession)()], + loop=self.loop + ) # start api await self.api.start() diff --git a/hassio/tasks.py b/hassio/tasks.py index 824a5dc1c..65d04a140 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -26,6 +26,11 @@ def hassio_update(config, supervisor, websession): if config.last_hassio == supervisor.version: return + # don't perform a update on beta/dev channel + if config.upstream_beta: + _LOGGER.warning("Ignore Hass.IO update on beta upstream!") + return + _LOGGER.info("Found new HassIO version %s.", config.last_hassio) await supervisor.update(config.last_hassio) From 90030d3a28ce88ba49afa1109f3b83d2e3e22fb0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 29 Jun 2017 07:57:04 +0200 Subject: [PATCH 12/13] Add websession for reload (#86) --- hassio/api/__init__.py | 6 ++++-- hassio/api/supervisor.py | 7 +++++-- hassio/core.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 97c38c389..856f8aabf 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -43,10 +43,12 @@ class RestAPI(object): self.webapp.router.add_get('/network/info', api_net.info) self.webapp.router.add_post('/network/options', api_net.options) - def register_supervisor(self, supervisor, addons, host_control): + def register_supervisor(self, supervisor, addons, host_control, + websession): """Register supervisor function.""" api_supervisor = APISupervisor( - self.config, self.loop, supervisor, addons, host_control) + self.config, self.loop, supervisor, addons, host_control, + websession) self.webapp.router.add_get('/supervisor/ping', api_supervisor.ping) self.webapp.router.add_get('/supervisor/info', api_supervisor.info) diff --git a/hassio/api/supervisor.py b/hassio/api/supervisor.py index 6ce483770..8bddfd9b7 100644 --- a/hassio/api/supervisor.py +++ b/hassio/api/supervisor.py @@ -30,13 +30,15 @@ SCHEMA_VERSION = vol.Schema({ class APISupervisor(object): """Handle rest api for supervisor functions.""" - def __init__(self, config, loop, supervisor, addons, host_control): + def __init__(self, config, loop, supervisor, addons, host_control, + websession): """Initialize supervisor rest api part.""" self.config = config self.loop = loop self.supervisor = supervisor self.addons = addons self.host_control = host_control + self.websession = websession def _addons_list(self, only_installed=False): """Return a list of addons.""" @@ -133,7 +135,8 @@ class APISupervisor(object): async def reload(self, request): """Reload addons, config ect.""" tasks = [ - self.addons.reload(), self.config.fetch_update_infos(), + self.addons.reload(), + self.config.fetch_update_infos(self.websession), self.host_control.load() ] results, _ = await asyncio.shield( diff --git a/hassio/core.py b/hassio/core.py index 488966b66..ffaa717b6 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -76,7 +76,7 @@ class HassIO(object): self.api.register_host(self.host_control) self.api.register_network(self.host_control) self.api.register_supervisor( - self.supervisor, self.addons, self.host_control) + self.supervisor, self.addons, self.host_control, self.websession) self.api.register_homeassistant(self.homeassistant) self.api.register_addons(self.addons) self.api.register_security() From ca303a62f2f658bff8a1aba4f79f480e9668756c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 29 Jun 2017 10:24:21 +0200 Subject: [PATCH 13/13] UI update for 0.38 (#87) * Update UI * Update frontend --- hassio/panel/hassio-main.html | 147 ++++++++++++++++++++++++++++++- hassio/panel/hassio-main.html.gz | Bin 21916 -> 23404 bytes home-assistant-polymer | 2 +- 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/hassio/panel/hassio-main.html b/hassio/panel/hassio-main.html index 788a3529b..4220cfd4b 100644 --- a/hassio/panel/hassio-main.html +++ b/hassio/panel/hassio-main.html @@ -1,4 +1,147 @@ - \ No newline at end of file +}); \ No newline at end of file diff --git a/hassio/panel/hassio-main.html.gz b/hassio/panel/hassio-main.html.gz index d4ba8133121839f9bf4a1a2f909f6e71ffd6fe1c..7f64acebd9db55b7932c49c9562b6c82fa264097 100644 GIT binary patch literal 23404 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 TASU?U=i&ba^wX%vcdY>c+WWrR literal 21916 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 diff --git a/home-assistant-polymer b/home-assistant-polymer index c5a5f41d3..d2a56655d 160000 --- a/home-assistant-polymer +++ b/home-assistant-polymer @@ -1 +1 @@ -Subproject commit c5a5f41d3c1f512266ab93a5ef6d0479608865f0 +Subproject commit d2a56655d086a040e712680e46e191d78949dfa3