diff --git a/API.md b/API.md index 65ffd3400..7ed976be2 100644 --- a/API.md +++ b/API.md @@ -69,7 +69,15 @@ On success "version": "INSTALL_VERSION", "current": "CURRENT_VERSION", "beta": "true|false", - "addons": {} + "addons": [ + { + "name": "xy bla", + "slug": "xy", + "version": "CURRENT_VERSION", + "installed": "none|INSTALL_VERSION", + "description": "description" + } + ] } ``` diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py new file mode 100644 index 000000000..d250de166 --- /dev/null +++ b/hassio/addons/__init__.py @@ -0,0 +1,159 @@ +"""Init file for HassIO addons.""" +import asyncio +import logging +import os +import shutil + +from .data import AddonsData +from .git import AddonsRepo +from ..const import STATE_STOPPED, STATE_STARTED +from ..dock.addon import DockerAddon + +_LOGGER = logging.getLogger(__name__) + + +class AddonManager(AddonsData): + """Manage addons inside HassIO.""" + + def __init__(self, config, loop, dock): + """Initialize docker base wrapper.""" + super().__init__(config) + + self.loop = loop + self.dock = dock + self.repo = AddonsRepo(config, loop) + self.dockers = {} + + async def prepare(self, arch): + """Startup addon management.""" + self.arch = arch + + # load addon repository + if await self.repo.load(): + self.read_addons_repo() + + # load installed addons + for addon in self.list_installed: + self.dockers[addon] = DockerAddon( + self.config, self.loop, self.dock, self, addon) + + async def relaod(self): + """Update addons from repo and reload list.""" + if not await self.repo.pull(): + return + self.read_addons_repo() + + # remove stalled addons + tasks = [] + for addon in self.list_removed: + _LOGGER.info("Old addon %s found") + tasks.append(self.loop.create_task(self.dockers[addon].remove())) + + if tasks: + await asyncio.wait(tasks, loop=self.loop) + + async def auto_boot(self, start_type): + """Boot addons with mode auto.""" + boot_list = self.list_startup(start_type) + tasks = [] + + for addon in boot_list: + tasks.append(self.loop.create_task(self.start(addon))) + + _LOGGER.info("Startup %s run %d addons", start_type, len(tasks)) + 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 + + if self.is_installed(addon): + _LOGGER.error("Addon %s is already installed", addon) + return False + + if not os.path.isdir(self.path_data(addon)): + _LOGGER.info("Create Home-Assistant addon data folder %s", + self.path_data(addon)) + os.mkdir(self.path_data(addon)) + + addon_docker = DockerAddon( + self.config, self.loop, self.dock, self, addon) + + version = version or self.get_version(addon) + if not await addon_docker.install(version): + return False + + self.dockers[addon] = addon_docker + self.set_install_addon(addon, version) + return True + + async def uninstall(self, addon): + """Remove a addon.""" + if not self.is_installed(addon): + _LOGGER.error("Addon %s is already uninstalled", addon) + return False + + if addon not in self.dockers: + _LOGGER.error("No docker found for addon %s", addon) + return False + + if not await self.dockers[addon].remove(): + return False + + if os.path.isdir(self.path_data(addon)): + _LOGGER.info("Remove Home-Assistant addon data folder %s", + self.path_data(addon)) + shutil.rmtree(self.path_data(addon)) + + self.dockers.pop(addon) + self.set_uninstall_addon(addon) + return True + + 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 + + if await self.dockers[addon].is_running(): + return STATE_STARTED + return STATE_STOPPED + + 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 + + if not self.write_addon_options(addon): + _LOGGER.error("Can't write options for addon %s", addon) + return False + + 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 not self.is_installed(addon): + _LOGGER.error("Addon %s is not installed", addon) + return False + + if addon not in self.dockers: + _LOGGER.error("No docker found for addon %s", addon) + return False + + version = version or self.get_version(addon) + if await self.dockers[addon].update(version): + self.set_version(addon, version) + return True + return False diff --git a/hassio/addons/data.py b/hassio/addons/data.py new file mode 100644 index 000000000..846d693ec --- /dev/null +++ b/hassio/addons/data.py @@ -0,0 +1,263 @@ +"""Init file for HassIO addons.""" +import logging +import glob + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from ..const import ( + FILE_HASSIO_ADDONS, ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, + ATTR_STARTUP, ATTR_BOOT, ATTR_MAP_SSL, ATTR_MAP_CONFIG, ATTR_OPTIONS, + ATTR_PORTS, STARTUP_ONCE, STARTUP_AFTER, STARTUP_BEFORE, BOOT_AUTO, + BOOT_MANUAL, DOCKER_REPO, ATTR_INSTALLED, ATTR_SCHEMA, ATTR_IMAGE) +from ..config import Config +from ..tools import read_json_file, write_json_file + +_LOGGER = logging.getLogger(__name__) + +ADDONS_REPO_PATTERN = "{}/*/config.json" + +V_STR = 'str' +V_INT = 'int' +V_FLOAT = 'float' +V_BOOL = 'bool' + + +# pylint: disable=no-value-for-parameter +SCHEMA_ADDON_CONFIG = vol.Schema({ + vol.Required(ATTR_NAME): vol.Coerce(str), + vol.Required(ATTR_VERSION): vol.Coerce(str), + vol.Required(ATTR_SLUG): vol.Coerce(str), + vol.Required(ATTR_DESCRIPTON): vol.Coerce(str), + vol.Required(ATTR_STARTUP): + vol.In([STARTUP_BEFORE, STARTUP_AFTER, STARTUP_ONCE]), + vol.Required(ATTR_BOOT): + vol.In([BOOT_AUTO, BOOT_MANUAL]), + vol.Optional(ATTR_PORTS): dict, + vol.Required(ATTR_MAP_CONFIG): vol.Boolean(), + vol.Required(ATTR_MAP_SSL): vol.Boolean(), + vol.Required(ATTR_OPTIONS): dict, + vol.Required(ATTR_SCHEMA): { + vol.Coerce(str): vol.In([V_STR, V_INT, V_FLOAT, V_BOOL]) + }, + vol.Optional(ATTR_IMAGE): vol.Match(r"\w*/\w*"), +}) + + +class AddonsData(Config): + """Hold data for addons inside HassIO.""" + + def __init__(self, config): + """Initialize data holder.""" + super().__init__(FILE_HASSIO_ADDONS) + self.config = config + self._addons_data = {} + self.arch = None + + def read_addons_repo(self): + """Read data from addons repository.""" + self._read_addons_folder(self.config.path_addons_repo) + self._read_addons_folder(self.config.path_addons_custom) + + def _read_addons_folder(self, folder): + """Read data from addons folder.""" + pattern = ADDONS_REPO_PATTERN.format(folder) + + for addon in glob.iglob(pattern): + try: + addon_config = read_json_file(addon) + + addon_config = SCHEMA_ADDON_CONFIG(addon_config) + self._addons_data[addon_config[ATTR_SLUG]] = addon_config + + except (OSError, KeyError): + _LOGGER.warning("Can't read %s", addon) + + except vol.Invalid as ex: + _LOGGER.warning("Can't read %s -> %s", addon, + humanize_error(addon_config, ex)) + + @property + def list_installed(self): + """Return a list of installed addons.""" + return set(self._data.keys()) + + @property + def list_all(self): + """Return a list of available addons.""" + return set(self._addons_data.keys()) + + @property + def list(self): + """Return a list of available addons.""" + data = [] + for addon, values in self._addons_data.items(): + data.append({ + ATTR_NAME: values[ATTR_NAME], + ATTR_SLUG: values[ATTR_SLUG], + ATTR_DESCRIPTON: values[ATTR_DESCRIPTON], + ATTR_VERSION: values[ATTR_VERSION], + ATTR_INSTALLED: self._data.get(addon, {}).get(ATTR_VERSION), + }) + + return data + + def list_startup(self, start_type): + """Get list of installed addon with need start by type.""" + addon_list = set() + for addon in self._data.keys(): + if self.get_boot(addon) != BOOT_AUTO: + continue + + try: + if self._addons_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_removed(self): + """Return local addons they not support from repo.""" + addon_list = set() + for addon in self._data.keys(): + if addon not in self._addons_data: + addon_list.add(addon) + + return addon_list + + def exists_addon(self, addon): + """Return True if a addon exists.""" + return addon in self._addons_data + + def is_installed(self, addon): + """Return True if a addon is installed.""" + return addon in self._data + + def version_installed(self, addon): + """Return installed version.""" + return self._data[addon][ATTR_VERSION] + + def set_install_addon(self, addon, version): + """Set addon as installed.""" + self._data[addon] = { + ATTR_VERSION: version, + ATTR_OPTIONS: {} + } + self.save() + + def set_uninstall_addon(self, addon): + """Set addon as uninstalled.""" + self._data.pop(addon, None) + self.save() + + def set_options(self, addon, options): + """Store user addon options.""" + self._data[addon][ATTR_OPTIONS] = options + self.save() + + def set_version(self, addon, version): + """Update version of addon.""" + self._data[addon][ATTR_VERSION] = version + self.save() + + def get_options(self, addon): + """Return options with local changes.""" + opt = self._addons_data[addon][ATTR_OPTIONS] + if addon in self._data: + opt.update(self._data[addon][ATTR_OPTIONS]) + return opt + + def get_boot(self, addon): + """Return boot config with prio local settings.""" + if ATTR_BOOT in self._data[addon]: + return self._data[addon][ATTR_BOOT] + + return self._addons_data[addon][ATTR_BOOT] + + def get_name(self, addon): + """Return name of addon.""" + return self._addons_data[addon][ATTR_NAME] + + def get_description(self, addon): + """Return description of addon.""" + return self._addons_data[addon][ATTR_DESCRIPTON] + + def get_version(self, addon): + """Return version of addon.""" + return self._addons_data[addon][ATTR_VERSION] + + def get_slug(self, addon): + """Return slug of addon.""" + return self._addons_data[addon][ATTR_SLUG] + + def get_ports(self, addon): + """Return ports of addon.""" + return self._addons_data[addon].get(ATTR_PORTS) + + def get_image(self, addon): + """Return image name of addon.""" + if ATTR_IMAGE not in self._addons_data[addon]: + return "{}/{}-addon-{}".format( + DOCKER_REPO, self.arch, self.get_slug(addon)) + + return self._addons_data[addon][ATTR_IMAGE] + + def need_config(self, addon): + """Return True if config map is needed.""" + return self._addons_data[addon][ATTR_MAP_CONFIG] + + def need_ssl(self, addon): + """Return True if ssl map is needed.""" + return self._addons_data[addon][ATTR_MAP_SSL] + + def path_data(self, addon): + """Return addon data path inside supervisor.""" + return "{}/{}".format( + self.config.path_addons_data, self._addons_data[addon][ATTR_SLUG]) + + def path_data_docker(self, addon): + """Return addon data path external for docker.""" + return "{}/{}".format(self.config.path_addons_data_docker, + self._addons_data[addon][ATTR_SLUG]) + + def path_addon_options(self, addon): + """Return path to addons options.""" + return "{}/options.json".format(self.path_data(addon)) + + def write_addon_options(self, addon): + """Return True if addon options is written to data.""" + return write_json_file( + self.path_addon_options(addon), self.get_options(addon)) + + def get_schema(self, addon): + """Create a schema for addon options.""" + raw_schema = self._addons_data[addon][ATTR_SCHEMA] + + def validate(struct): + """Validate schema.""" + options = {} + for key, value in struct.items(): + if key not in raw_schema: + raise vol.Invalid("Unknown options {}.".format(key)) + + typ = raw_schema[key] + try: + if typ == V_STR: + options[key] = str(value) + elif typ == V_INT: + options[key] = int(value) + elif typ == V_FLOAT: + options[key] = float(value) + elif typ == V_BOOL: + options[key] = vol.Boolean()(value) + except TypeError: + raise vol.Invalid( + "Type error for {}.".format(key)) from None + + return options + + schema = vol.Schema(vol.All(dict(), validate)) + return schema diff --git a/hassio/addons/git.py b/hassio/addons/git.py new file mode 100644 index 000000000..5b665e876 --- /dev/null +++ b/hassio/addons/git.py @@ -0,0 +1,71 @@ +"""Init file for HassIO addons git.""" +import asyncio +import logging +import os + +import git + +from ..const import URL_HASSIO_ADDONS + +_LOGGER = logging.getLogger(__name__) + + +class AddonsRepo(object): + """Manage addons git repo.""" + + def __init__(self, config, loop): + """Initialize docker base wrapper.""" + self.config = config + self.loop = loop + self.repo = None + self._lock = asyncio.Lock(loop=loop) + + async def load(self): + """Init git addon repo.""" + if not os.path.isdir(self.config.path_addons_repo): + return await self.clone() + + async with self._lock: + try: + _LOGGER.info("Load addons repository") + self.repo = await self.loop.run_in_executor( + None, git.Repo, self.config.path_addons_repo) + + except (git.InvalidGitRepositoryError, git.NoSuchPathError) as err: + _LOGGER.error("Can't load addons repo: %s.", err) + return False + + return True + + async def clone(self): + """Clone git addon repo.""" + async with self._lock: + try: + _LOGGER.info("Clone addons repository") + self.repo = await self.loop.run_in_executor( + None, git.Repo.clone_from, URL_HASSIO_ADDONS, + self.config.path_addons_repo) + + except (git.InvalidGitRepositoryError, git.NoSuchPathError) as err: + _LOGGER.error("Can't clone addons repo: %s.", err) + return False + + return True + + async def pull(self): + """Pull git addon repo.""" + if self._lock.locked(): + _LOGGER.warning("It is already a task in progress.") + return False + + async with self._lock: + try: + _LOGGER.info("Pull addons repository") + await self.loop.run_in_executor( + None, self.repo.remotes.origin.pull) + + except (git.InvalidGitRepositoryError, git.NoSuchPathError) as err: + _LOGGER.error("Can't pull addons repo: %s.", err) + return False + + return True diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index 1bd0d577a..84ef3573b 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -3,10 +3,11 @@ import logging from aiohttp import web +from .addons import APIAddons +from .homeassistant import APIHomeAssistant from .host import APIHost from .network import APINetwork from .supervisor import APISupervisor -from .homeassistant import APIHomeAssistant _LOGGER = logging.getLogger(__name__) @@ -40,9 +41,10 @@ class RestAPI(object): self.webapp.router.add_get('/network/info', api_net.info) self.webapp.router.add_get('/network/options', api_net.options) - def register_supervisor(self, host_controll): + def register_supervisor(self, host_controll, addons): """Register supervisor function.""" - api_supervisor = APISupervisor(self.config, self.loop, host_controll) + api_supervisor = APISupervisor( + self.config, self.loop, host_controll, addons) self.webapp.router.add_get('/supervisor/ping', api_supervisor.ping) self.webapp.router.add_get('/supervisor/info', api_supervisor.info) @@ -57,6 +59,21 @@ class RestAPI(object): self.webapp.router.add_get('/homeassistant/info', api_hass.info) self.webapp.router.add_get('/homeassistant/update', api_hass.update) + def register_addons(self, addons): + """Register homeassistant function.""" + api_addons = APIAddons(self.config, self.loop, addons) + + self.webapp.router.add_get('/addons/{addon}/info', api_addons.info) + self.webapp.router.add_get( + '/addons/{addon}/install', api_addons.install) + self.webapp.router.add_get( + '/addons/{addon}/uninstall', api_addons.uninstall) + self.webapp.router.add_get('/addons/{addon}/start', api_addons.start) + self.webapp.router.add_get('/addons/{addon}/stop', api_addons.stop) + self.webapp.router.add_get('/addons/{addon}/update', api_addons.update) + self.webapp.router.add_get( + '/addons/{addon}/options', api_addons.options) + async def start(self): """Run rest api webserver.""" self._handler = self.webapp.make_handler(loop=self.loop) diff --git a/hassio/api/addons.py b/hassio/api/addons.py new file mode 100644 index 000000000..74a5bee59 --- /dev/null +++ b/hassio/api/addons.py @@ -0,0 +1,117 @@ +"""Init file for HassIO homeassistant rest api.""" +import asyncio +import logging + +import voluptuous as vol + +from .util import api_process, api_validate +from ..const import ( + ATTR_VERSION, ATTR_CURRENT, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS, + STATE_STOPPED, STATE_STARTED) + +_LOGGER = logging.getLogger(__name__) + +SCHEMA_VERSION = vol.Schema({ + vol.Optional(ATTR_VERSION): vol.Coerce(str), +}) + + +class APIAddons(object): + """Handle rest api for addons functions.""" + + def __init__(self, config, loop, addons): + """Initialize homeassistant rest api part.""" + self.config = config + self.loop = loop + self.addons = addons + + def _extract_addon(self, request, check_installed=True): + """Return addon and if not exists trow a exception.""" + addon = request.match_info.get('addon') + + # check data + if not self.addons.exists_addon(addon): + raise RuntimeError("Addon not exists") + if check_installed and not self.addons.is_installed(addon): + raise RuntimeError("Addon is not installed") + + return addon + + @api_process + async def info(self, request): + """Return addon information.""" + addon = self._extract_addon(request) + + info = { + ATTR_VERSION: self.addons.version_installed(addon), + ATTR_CURRENT: self.addons.get_version(addon), + ATTR_STATE: await self.addons.state(addon), + ATTR_BOOT: self.addons.get_boot(addon), + ATTR_OPTIONS: self.addons.get_options(addon), + } + return info + + @api_process + async def options(self, request): + """Store user options for addon.""" + addon = self._extract_addon(request) + schema = self.addons.get_schema(addon) + + options = await api_validate(schema, request) + self.addons.set_options(addon, options) + return True + + @api_process + 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_version(addon)) + + return await asyncio.shield( + self.addons.install(addon, 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) + + @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") + + return await asyncio.shield( + self.addons.start(addon), 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) + + @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_version(addon)) + + if version == self.addons.version_installed(addon): + raise RuntimeError("Version is already in use") + + return await asyncio.shield( + self.addons.update(addon, version), loop=self.loop) diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index cd55a2453..6a81e66f6 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -40,9 +40,10 @@ class APIHomeAssistant(object): version = body.get(ATTR_VERSION, self.config.current_homeassistant) if self.dock_hass.in_progress: - raise RuntimeError("Other task is in progress.") + raise RuntimeError("Other task is in progress") if version == self.dock_hass.version: - raise RuntimeError("%s is already in use.", version) + raise RuntimeError("Version is already in use") - return await asyncio.shield(self.dock_hass.update(version)) + return await asyncio.shield( + self.dock_hass.update(version), loop=self.loop) diff --git a/hassio/api/host.py b/hassio/api/host.py index 23f0fb453..c799a42cf 100644 --- a/hassio/api/host.py +++ b/hassio/api/host.py @@ -56,6 +56,6 @@ class APIHost(object): version = body.get(ATTR_VERSION) if version == self.host_controll.version: - raise RuntimeError("%s is already in use.", version) + raise RuntimeError("Version is already in use") return await self.host_controll.host_update(version=version) diff --git a/hassio/api/supervisor.py b/hassio/api/supervisor.py index 73ef6ae5b..9ce407d71 100644 --- a/hassio/api/supervisor.py +++ b/hassio/api/supervisor.py @@ -4,7 +4,8 @@ import logging import voluptuous as vol from .util import api_process, api_process_hostcontroll, api_validate -from ..const import ATTR_VERSION, ATTR_CURRENT, ATTR_BETA, HASSIO_VERSION +from ..const import ( + ATTR_ADDONS, ATTR_VERSION, ATTR_CURRENT, ATTR_BETA, HASSIO_VERSION) _LOGGER = logging.getLogger(__name__) @@ -21,11 +22,12 @@ SCHEMA_VERSION = vol.Schema({ class APISupervisor(object): """Handle rest api for supervisor functions.""" - def __init__(self, config, loop, host_controll): + def __init__(self, config, loop, host_controll, addons): """Initialize supervisor rest api part.""" self.config = config self.loop = loop self.host_controll = host_controll + self.addons = addons @api_process async def ping(self, request): @@ -39,8 +41,8 @@ class APISupervisor(object): ATTR_VERSION: HASSIO_VERSION, ATTR_CURRENT: self.config.current_hassio, ATTR_BETA: self.config.upstream_beta, + ATTR_ADDONS: self.addons.list, } - return info @api_process @@ -60,6 +62,6 @@ class APISupervisor(object): version = body.get(ATTR_VERSION, self.config.current_hassio) if version == HASSIO_VERSION: - raise RuntimeError("%s is already in use.", version) + raise RuntimeError("Version is already in use") return await self.host_controll.supervisor_update(version=version) diff --git a/hassio/api/util.py b/hassio/api/util.py index fd75d5ec2..e435da684 100644 --- a/hassio/api/util.py +++ b/hassio/api/util.py @@ -82,7 +82,7 @@ async def api_validate(schema, request): """Validate request data with schema.""" data = await request.json(loads=json_loads) try: - schema(data) + data = schema(data) except vol.Invalid as ex: raise RuntimeError(humanize_error(data, ex)) from None diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index d4ce6844e..f5be8edea 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -26,6 +26,17 @@ def initialize_system_data(websession): _LOGGER.info("Create Home-Assistant ssl folder %s", config.path_ssl) os.mkdir(config.path_ssl) + # homeassistant addon data folder + if not os.path.isdir(config.path_addons_data): + _LOGGER.info("Create Home-Assistant addon data folder %s", + config.path_addons_data) + os.mkdir(config.path_addons_data) + + if not os.path.isdir(config.path_addons_custom): + _LOGGER.info("Create Home-Assistant addon custom folder %s", + config.path_addons_custom) + os.mkdir(config.path_addons_custom) + return config diff --git a/hassio/config.py b/hassio/config.py index d710581c5..8f47f6079 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -1,10 +1,10 @@ """Bootstrap HassIO.""" -import json import logging import os from .const import FILE_HASSIO_CONFIG, HASSIO_SHARE -from .tools import fetch_current_versions +from .tools import ( + fetch_current_versions, write_json_file, read_json_file) _LOGGER = logging.getLogger(__name__) @@ -14,26 +14,46 @@ HOMEASSISTANT_CURRENT = 'homeassistant_current' HASSIO_SSL = "{}/ssl" HASSIO_CURRENT = 'hassio_current' + +ADDONS_REPO = "{}/addons" +ADDONS_DATA = "{}/addons_data" +ADDONS_CUSTOM = "{}/addons_custom" + UPSTREAM_BETA = 'upstream_beta' -class CoreConfig(object): +class Config(object): """Hold all config data.""" - def __init__(self, websession, config_file=FILE_HASSIO_CONFIG): + def __init__(self, config_file): """Initialize config object.""" - self.websession = websession self._filename = config_file self._data = {} # init or load data if os.path.isfile(self._filename): try: - with open(self._filename, 'r') as cfile: - self._data = json.loads(cfile.read()) + self._data = read_json_file(self._filename) except OSError: _LOGGER.warning("Can't read %s", self._filename) + def save(self): + """Store data to config file.""" + if not write_json_file(self._filename, self._data): + _LOGGER.exception("Can't store config in %s", self._filename) + return False + return True + + +class CoreConfig(Config): + """Hold all core config data.""" + + def __init__(self, websession): + """Initialize config object.""" + self.websession = websession + + super().__init__(FILE_HASSIO_CONFIG) + # init data if not self._data: self._data.update({ @@ -42,17 +62,6 @@ class CoreConfig(object): }) self.save() - def save(self): - """Store data to config file.""" - try: - with open(self._filename, 'w') as conf_file: - conf_file.write(json.dumps(self._data)) - except OSError: - _LOGGER.exception("Can't store config in %s", self._filename) - return False - - return True - async def fetch_update_infos(self): """Read current versions from web.""" current = await fetch_current_versions( @@ -112,3 +121,23 @@ class CoreConfig(object): def path_ssl(self): """Return SSL path inside supervisor.""" return HASSIO_SSL.format(HASSIO_SHARE) + + @property + def path_addons_repo(self): + """Return git repo path for addons.""" + return ADDONS_REPO.format(HASSIO_SHARE) + + @property + def path_addons_custom(self): + """Return path for customs addons.""" + return ADDONS_CUSTOM.format(HASSIO_SHARE) + + @property + def path_addons_data(self): + """Return root addon data folder.""" + return ADDONS_DATA.format(HASSIO_SHARE) + + @property + def path_addons_data_docker(self): + """Return root addon data folder extern for docker.""" + return ADDONS_DATA.format(os.environ['SUPERVISOR_SHARE']) diff --git a/hassio/const.py b/hassio/const.py index e641ea88a..1a35682a3 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -1,16 +1,19 @@ """Const file for HassIO.""" -HASSIO_VERSION = '0.5' +HASSIO_VERSION = '0.6' URL_HASSIO_VERSION = \ 'https://raw.githubusercontent.com/pvizeli/hassio/master/version.json' URL_HASSIO_VERSION_BETA = \ 'https://raw.githubusercontent.com/pvizeli/hassio/master/version_beta.json' -URL_ADDONS_REPO = 'https://github.com/pvizeli/hassio-addons' +URL_HASSIO_ADDONS = 'https://github.com/pvizeli/hassio-addons' + +DOCKER_REPO = "pvizeli" HASSIO_SHARE = "/data" RUN_UPDATE_INFO_TASKS = 28800 +RUN_RELOAD_ADDONS_TASKS = 28800 FILE_HASSIO_ADDONS = "{}/addons.json".format(HASSIO_SHARE) FILE_HASSIO_CONFIG = "{}/config.json".format(HASSIO_SHARE) @@ -25,6 +28,28 @@ JSON_MESSAGE = 'message' RESULT_ERROR = 'error' RESULT_OK = 'ok' +ATTR_ADDONS = 'addons' ATTR_VERSION = 'version' ATTR_CURRENT = 'current' ATTR_BETA = 'beta' +ATTR_NAME = 'name' +ATTR_SLUG = 'slug' +ATTR_DESCRIPTON = 'description' +ATTR_STARTUP = 'startup' +ATTR_BOOT = 'boot' +ATTR_PORTS = 'ports' +ATTR_MAP_CONFIG = 'map_config' +ATTR_MAP_SSL = 'map_ssl' +ATTR_OPTIONS = 'options' +ATTR_INSTALLED = 'installed' +ATTR_STATE = 'state' +ATTR_SCHEMA = 'schema' +ATTR_IMAGE = 'image' + +STARTUP_BEFORE = 'before' +STARTUP_AFTER = 'after' +STARTUP_ONCE = 'once' +BOOT_AUTO = 'auto' +BOOT_MANUAL = 'manual' +STATE_STARTED = 'started' +STATE_STOPPED = 'stopped' diff --git a/hassio/core.py b/hassio/core.py index 0b19eff78..ea9db2a05 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -6,12 +6,16 @@ import aiohttp import docker from . import bootstrap +from .addons import AddonManager from .api import RestAPI from .host_controll import HostControll -from .const import SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS +from .const import ( + SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, + STARTUP_AFTER, STARTUP_BEFORE) from .scheduler import Scheduler from .dock.homeassistant import DockerHomeAssistant from .dock.supervisor import DockerSupervisor +from .tools import get_arch_from_image _LOGGER = logging.getLogger(__name__) @@ -38,6 +42,9 @@ class HassIO(object): # init HostControll self.host_controll = HostControll(self.loop) + # init addon system + self.addons = AddonManager(self.config, self.loop, self.dock) + async def setup(self): """Setup HassIO orchestration.""" # supervisor @@ -56,8 +63,9 @@ class HassIO(object): # rest api views self.api.register_host(self.host_controll) self.api.register_network(self.host_controll) - self.api.register_supervisor(self.host_controll) + self.api.register_supervisor(self.host_controll, self.addons) self.api.register_homeassistant(self.homeassistant) + self.api.register_addons(self.addons) # schedule update info tasks self.scheduler.register_task( @@ -69,14 +77,33 @@ class HassIO(object): _LOGGER.info("No HomeAssistant docker found.") await self._setup_homeassistant() + # Load addons + arch = get_arch_from_image(self.supervisor.image) + await self.addons.prepare(arch) + + # schedule addon update task + self.scheduler.register_task( + self.addons.relaod, RUN_RELOAD_ADDONS_TASKS, first_run=True) + async def start(self): """Start HassIO orchestration.""" # start api await self.api.start() + # HomeAssistant is already running / supervisor have only reboot + if await self.homeassistant.is_running(): + _LOGGER.info("HassIO reboot detected") + return + + # start addon mark as before + await self.addons.auto_boot(STARTUP_BEFORE) + # run HomeAssistant await self.homeassistant.run() + # start addon mark as after + await self.addons.auto_boot(STARTUP_AFTER) + async def stop(self): """Stop a running orchestration.""" tasks = [self.websession.close(), self.api.stop()] diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index 633a17870..e4401fb74 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -53,7 +53,7 @@ class DockerBase(object): image.tag(self.image, tag='latest') self.version = get_version_from_env(image.attrs['Config']['Env']) - _LOGGER.info("Tag image %s with version %s as latest.", + _LOGGER.info("Tag image %s with version %s as latest", self.image, self.version) except docker.errors.APIError as err: _LOGGER.error("Can't install %s:%s -> %s.", self.image, tag, err) @@ -122,7 +122,7 @@ class DockerBase(object): self.image = self.container.attrs['Config']['Image'] self.version = get_version_from_env( self.container.attrs['Config']['Env']) - _LOGGER.info("Attach to image %s with version %s.", + _LOGGER.info("Attach to image %s with version %s", self.image, self.version) except (docker.errors.DockerException, KeyError): _LOGGER.fatal( @@ -138,7 +138,7 @@ class DockerBase(object): return False async with self._lock: - _LOGGER.info("Run docker image %s with version %s.", + _LOGGER.info("Run docker image %s with version %s", self.image, self.version) return await self.loop.run_in_executor(None, self._run) @@ -177,6 +177,37 @@ class DockerBase(object): self.container = None + async def remove(self): + """Remove docker container.""" + if self._lock.locked(): + _LOGGER.error("Can't excute remove while a task is in progress") + return False + + async with self._lock: + return await self.loop.run_in_executor(None, self._remove) + + def _remove(self): + """remove docker container. + + Need run inside executor. + """ + if self._is_running(): + self._stop() + + _LOGGER.info("Remove docker %s with latest and %s", + self.image, self.version) + + try: + self.dock.images.remove( + image="{}:latest".format(self.image), force=True) + self.dock.images.remove( + image="{}:{}".format(self.image, self.version), force=True) + except docker.errors.DockerException as err: + _LOGGER.warning("Can't remove image %s -> %s", self.image, err) + return False + + return True + async def update(self, tag): """Update a docker image. @@ -194,10 +225,10 @@ class DockerBase(object): Need run inside executor. """ - old_image = "{}:{}".format(self.image, self.version) old_run = self._is_running() + old_image = "{}:{}".format(self.image, self.version) - _LOGGER.info("Update docker %s with %s:%s.", + _LOGGER.info("Update docker %s with %s:%s", old_image, self.image, tag) # update docker image @@ -208,7 +239,7 @@ class DockerBase(object): self.dock.images.remove(image=old_image, force=True) except docker.errors.DockerException as err: _LOGGER.warning( - "Can't remove old image %s -> %s.", old_image, err) + "Can't remove old image %s -> %s", old_image, err) # restore if old_run: self._run() diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py new file mode 100644 index 000000000..3afeb2889 --- /dev/null +++ b/hassio/dock/addon.py @@ -0,0 +1,76 @@ +"""Init file for HassIO addon docker object.""" +import logging + +import docker + +from . import DockerBase +from ..tools import get_version_from_env + +_LOGGER = logging.getLogger(__name__) + +HASS_DOCKER_NAME = 'homeassistant' + + +class DockerAddon(DockerBase): + """Docker hassio wrapper for HomeAssistant.""" + + def __init__(self, config, loop, dock, addons_data, addon): + """Initialize docker homeassistant wrapper.""" + super().__init__( + config, loop, dock, image=addons_data.get_image(addon)) + self.addon = addon + self.addons_data = addons_data + + @property + def docker_name(self): + """Return name of docker container.""" + return "addon_{}".format(self.addons_data.get_slug(self.addon)) + + def _run(self): + """Run docker image. + + Need run inside executor. + """ + if self._is_running(): + return + + # cleanup old container + self._stop() + + # volumes + volumes = { + self.addons_data.path_data_docker(self.addon): { + 'bind': '/data', 'mode': 'rw' + }} + if self.addons_data.need_config(self.addon): + volumes.update({ + self.config.path_config_docker: { + 'bind': '/config', 'mode': 'rw' + }}) + if self.addons_data.need_ssl(self.addon): + volumes.update({ + self.config.path_ssl_docker: { + 'bind': '/ssl', 'mode': 'rw' + }}) + + try: + self.container = self.dock.containers.run( + self.image, + name=self.docker_name, + detach=True, + network_mode='bridge', + ports=self.addons_data.get_ports(self.addon), + restart_policy={ + "Name": "on-failure", + "MaximumRetryCount": 10, + }, + volumes=volumes, + ) + + self.version = get_version_from_env( + self.container.attrs['Config']['Env']) + except docker.errors.DockerException as err: + _LOGGER.error("Can't run %s -> %s", self.image, err) + return False + + return True diff --git a/hassio/dock/homeassistant.py b/hassio/dock/homeassistant.py index 5bcbcac84..b9f0c829f 100644 --- a/hassio/dock/homeassistant.py +++ b/hassio/dock/homeassistant.py @@ -60,7 +60,7 @@ class DockerHomeAssistant(DockerBase): self.version = get_version_from_env( self.container.attrs['Config']['Env']) except docker.errors.DockerException as err: - _LOGGER.error("Can't run %s -> %s.", self.image, err) + _LOGGER.error("Can't run %s -> %s", self.image, err) return False return True diff --git a/hassio/dock/supervisor.py b/hassio/dock/supervisor.py index c932e9d04..ed3f1aeed 100644 --- a/hassio/dock/supervisor.py +++ b/hassio/dock/supervisor.py @@ -27,3 +27,7 @@ class DockerSupervisor(DockerBase): async def update(self, tag): """Update docker image.""" raise RuntimeError("Not support on supervisor docker container!") + + async def remove(self): + """Remove docker image.""" + raise RuntimeError("Not support on supervisor docker container!") diff --git a/hassio/tools.py b/hassio/tools.py index 3368b58a1..9b3b9c15f 100644 --- a/hassio/tools.py +++ b/hassio/tools.py @@ -1,5 +1,6 @@ """Tools file for HassIO.""" import asyncio +import json import logging import re import socket @@ -12,6 +13,7 @@ from .const import URL_HASSIO_VERSION, URL_HASSIO_VERSION_BETA _LOGGER = logging.getLogger(__name__) _RE_VERSION = re.compile(r"VERSION=(.*)") +_IMAGE_ARCH = re.compile(r".*/([a-z0-9]*)-hassio-supervisor") async def fetch_current_versions(websession, beta=False): @@ -25,9 +27,19 @@ async def fetch_current_versions(websession, beta=False): async with websession.get(url) as request: return await request.json(content_type=None) - except (ValueError, aiohttp.ClientError, asyncio.TimeoutError) as err: + except (aiohttp.ClientError, asyncio.TimeoutError, KeyError) as err: _LOGGER.warning("Can't fetch versions from %s! %s", url, err) + except json.JSONDecodeError as err: + _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_version_from_env(env_list): """Extract Version from ENV list.""" @@ -56,3 +68,20 @@ def get_local_ip(loop): return socket.gethostbyname(socket.gethostname()) finally: sock.close() + + +def write_json_file(jsonfile, data): + """Write a json file.""" + try: + with open(jsonfile, 'w') as conf_file: + conf_file.write(json.dumps(data)) + except OSError: + return False + + return True + + +def read_json_file(jsonfile): + """Read a json file and return a dict.""" + with open(jsonfile, 'r') as cfile: + return json.loads(cfile.read()) diff --git a/setup.py b/setup.py index 31a39092c..31f333ffe 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( keywords=['docker', 'home-assistant', 'api'], zip_safe=False, platforms='any', - packages=['hassio', 'hassio.dock', 'hassio.api'], + packages=['hassio', 'hassio.dock', 'hassio.api', 'hassio.addons'], include_package_data=True, install_requires=[ 'async_timeout', @@ -37,5 +37,6 @@ setup( 'docker', 'colorlog', 'voluptuous', + 'gitpython', ] ) diff --git a/version.json b/version.json index 8c44a5662..02eb23bdd 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "hassio_tag": "0.5", + "hassio_tag": "0.6", "homeassistant_tag": "0.42.3", "resinos_version": "0.3", "resinhup_version": "0.1"