diff --git a/API.md b/API.md index 899082608..bb38ffe0f 100644 --- a/API.md +++ b/API.md @@ -303,6 +303,7 @@ Output the raw docker log { "name": "xy bla", "description": "description", + "auto_update": "bool", "url": "null|url of addon", "detached": "bool", "repository": "12345678|null", @@ -312,6 +313,8 @@ Output the raw docker log "boot": "auto|manual", "build": "bool", "options": "{}", + "network": "{}|null", + "host_network": "bool" } ``` @@ -319,10 +322,16 @@ Output the raw docker log ```json { "boot": "auto|manual", + "auto_update": "bool", + "network": { + "CONTAINER": "port|[ip, port]" + }, "options": {}, } ``` +For reset custom network settings, set it `null`. + - POST `/addons/{addon}/start` - POST `/addons/{addon}/stop` diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 633ac674c..139b28e2a 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -12,15 +12,14 @@ import voluptuous as vol from voluptuous.humanize import humanize_error from .validate import ( - validate_options, SCHEMA_ADDON_USER, SCHEMA_ADDON_SYSTEM, - SCHEMA_ADDON_SNAPSHOT, MAP_VOLUME) + validate_options, SCHEMA_ADDON_SNAPSHOT, 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, ATTR_USER, ATTR_SYSTEM, - ATTR_STATE) + ATTR_STATE, ATTR_TIMEOUT, ATTR_AUTO_UPDATE, ATTR_NETWORK) from .util import check_installed from ..dock.addon import DockerAddon from ..tools import write_json_file, read_json_file @@ -45,21 +44,8 @@ class Addon(object): async def load(self): """Async initialize of object.""" if self.is_installed: - self._validate_system_user() await self.addon_docker.attach() - def _validate_system_user(self): - """Validate internal data they read from file.""" - for data, schema in ((self.data.system, SCHEMA_ADDON_SYSTEM), - (self.data.user, SCHEMA_ADDON_USER)): - try: - data[self._id] = schema(data[self._id]) - except vol.Invalid as err: - _LOGGER.warning("Can't validate addon load %s -> %s", self._id, - humanize_error(data[self._id], err)) - except KeyError: - pass - @property def slug(self): """Return slug/id of addon.""" @@ -141,11 +127,27 @@ class Addon(object): self.data.user[self._id][ATTR_BOOT] = value self.data.save() + @property + def auto_update(self): + """Return if auto update is enable.""" + return self.data.user[self._id][ATTR_AUTO_UPDATE] + + @auto_update.setter + def auto_update(self, value): + """Set auto update.""" + self.data.user[self._id][ATTR_AUTO_UPDATE] = value + self.data.save() + @property def name(self): """Return name of addon.""" return self._mesh[ATTR_NAME] + @property + def timeout(self): + """Return timeout of addon for docker stop.""" + return self._mesh[ATTR_TIMEOUT] + @property def description(self): """Return description of addon.""" @@ -171,7 +173,28 @@ class Addon(object): @property def ports(self): """Return ports of addon.""" - return self._mesh.get(ATTR_PORTS) + if self.network_mode != 'bridge' or ATTR_PORTS not in self._mesh: + return + + if not self.is_installed or \ + ATTR_NETWORK not in self.data.user[self._id]: + return self._mesh[ATTR_PORTS] + return self.data.user[self._id][ATTR_NETWORK] + + @ports.setter + def ports(self, value): + """Set custom ports of addon.""" + if value is None: + self.data.user[self._id].pop(ATTR_NETWORK, None) + else: + new_ports = {} + for container_port, host_port in value.items(): + if container_port in self._mesh.get(ATTR_PORTS, {}): + new_ports[container_port] = host_port + + self.data.user[self._id][ATTR_NETWORK] = new_ports + + self.data.save() @property def network_mode(self): diff --git a/hassio/addons/data.py b/hassio/addons/data.py index c6fac33e1..35c1cddce 100644 --- a/hassio/addons/data.py +++ b/hassio/addons/data.py @@ -10,7 +10,8 @@ from voluptuous.humanize import humanize_error from .util import extract_hash_from_path from .validate import ( - SCHEMA_ADDON, SCHEMA_REPOSITORY_CONFIG, MAP_VOLUME) + SCHEMA_ADDON_CONFIG, SCHEMA_ADDON_FILE, SCHEMA_REPOSITORY_CONFIG, + MAP_VOLUME) from ..const import ( FILE_HASSIO_ADDONS, ATTR_VERSION, ATTR_SLUG, ATTR_REPOSITORY, ATTR_LOCATON, REPOSITORY_CORE, REPOSITORY_LOCAL, ATTR_USER, ATTR_SYSTEM) @@ -40,13 +41,23 @@ class Data(object): _LOGGER.warning("Can't read %s", self._file) self._data = {} - # init data - if not self._data: - self._data[ATTR_USER] = {} - self._data[ATTR_SYSTEM] = {} + # validate + try: + self._data = SCHEMA_ADDON_FILE(self._data) + except vol.Invalid as ex: + _LOGGER.error("Can't parse addons.json -> %s", + humanize_error(self._data, ex)) def save(self): """Store data to config file.""" + # validate + try: + self._data = SCHEMA_ADDON_FILE(self._data) + except vol.Invalid as ex: + _LOGGER.error("Can't parse addons data -> %s", + humanize_error(self._data, ex)) + return False + if not write_json_file(self._file, self._data): _LOGGER.error("Can't store config in %s", self._file) return False @@ -127,7 +138,7 @@ class Data(object): addon_config = read_json_file(addon) # validate - addon_config = SCHEMA_ADDON(addon_config) + addon_config = SCHEMA_ADDON_CONFIG(addon_config) # Generate slug addon_slug = "{}_{}".format( diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index b94ad4e71..d71511bec 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -8,7 +8,9 @@ from ..const import ( ATTR_IMAGE, ATTR_URL, ATTR_MAINTAINER, ATTR_ARCH, ATTR_DEVICES, ATTR_ENVIRONMENT, ATTR_HOST_NETWORK, ARCH_ARMHF, ARCH_AARCH64, ARCH_AMD64, ARCH_I386, ATTR_TMPFS, ATTR_PRIVILEGED, ATTR_USER, ATTR_STATE, ATTR_SYSTEM, - STATE_STARTED, STATE_STOPPED, ATTR_LOCATON, ATTR_REPOSITORY) + STATE_STARTED, STATE_STOPPED, ATTR_LOCATON, ATTR_REPOSITORY, ATTR_TIMEOUT, + ATTR_NETWORK, ATTR_AUTO_UPDATE) +from ..validate import NETWORK_PORT, DOCKER_PORTS MAP_VOLUME = r"^(config|ssl|addons|backup|share)(?::(rw|:ro))?$" @@ -19,8 +21,9 @@ V_FLOAT = 'float' V_BOOL = 'bool' V_EMAIL = 'email' V_URL = 'url' +V_PORT = 'port' -ADDON_ELEMENT = vol.In([V_STR, V_INT, V_FLOAT, V_BOOL, V_EMAIL, V_URL]) +ADDON_ELEMENT = vol.In([V_STR, V_INT, V_FLOAT, V_BOOL, V_EMAIL, V_URL, V_PORT]) ARCH_ALL = [ ARCH_ARMHF, ARCH_AARCH64, ARCH_AMD64, ARCH_I386 @@ -31,16 +34,6 @@ PRIVILEGE_ALL = [ ] -def check_network(data): - """Validate network settings.""" - host_network = data[ATTR_HOST_NETWORK] - - if ATTR_PORTS in data and host_network: - raise vol.Invalid("Hostnetwork & ports are not allow!") - - return data - - # pylint: disable=no-value-for-parameter SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Required(ATTR_NAME): vol.Coerce(str), @@ -54,7 +47,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ STARTUP_INITIALIZE]), vol.Required(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), - vol.Optional(ATTR_PORTS): dict, + vol.Optional(ATTR_PORTS): DOCKER_PORTS, vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(), vol.Optional(ATTR_DEVICES): [vol.Match(r"^(.*):(.*):([rwm]{1,3})$")], vol.Optional(ATTR_TMPFS): @@ -69,8 +62,10 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ ]) }, False), vol.Optional(ATTR_IMAGE): vol.Match(r"\w*/\w*"), + vol.Optional(ATTR_TIMEOUT, default=10): + vol.All(vol.Coerce(int), vol.Range(min=10, max=120)) }, extra=vol.ALLOW_EXTRA) -SCHEMA_ADDON = vol.Schema(vol.All(SCHEMA_ADDON_CONFIG, check_network)) + # pylint: disable=no-value-for-parameter SCHEMA_REPOSITORY_CONFIG = vol.Schema({ @@ -80,11 +75,14 @@ SCHEMA_REPOSITORY_CONFIG = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +# pylint: disable=no-value-for-parameter SCHEMA_ADDON_USER = vol.Schema({ vol.Required(ATTR_VERSION): vol.Coerce(str), vol.Required(ATTR_OPTIONS): dict, + vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), + vol.Optional(ATTR_NETWORK): DOCKER_PORTS, }) @@ -94,6 +92,16 @@ SCHEMA_ADDON_SYSTEM = SCHEMA_ADDON_CONFIG.extend({ }) +SCHEMA_ADDON_FILE = vol.Schema({ + vol.Optional(ATTR_USER, default={}): { + vol.Coerce(str): SCHEMA_ADDON_USER, + }, + vol.Optional(ATTR_SYSTEM, default={}): { + vol.Coerce(str): SCHEMA_ADDON_SYSTEM, + } +}) + + SCHEMA_ADDON_SNAPSHOT = vol.Schema({ vol.Required(ATTR_USER): SCHEMA_ADDON_USER, vol.Required(ATTR_SYSTEM): SCHEMA_ADDON_SYSTEM, @@ -150,6 +158,8 @@ def _single_validate(typ, value, key): return vol.Email()(value) elif typ == V_URL: return vol.Url()(value) + elif typ == V_PORT: + return NETWORK_PORT(value) raise vol.Invalid("Fatal error for {} type {}.".format(key, typ)) except ValueError: diff --git a/hassio/api/addons.py b/hassio/api/addons.py index eb45e3f4d..1f9e96663 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -9,7 +9,9 @@ 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, BOOT_AUTO, BOOT_MANUAL) + ATTR_BUILD, ATTR_AUTO_UPDATE, ATTR_NETWORK, ATTR_HOST_NETWORK, + BOOT_AUTO, BOOT_MANUAL) +from ..validate import DOCKER_PORTS _LOGGER = logging.getLogger(__name__) @@ -17,8 +19,11 @@ SCHEMA_VERSION = vol.Schema({ vol.Optional(ATTR_VERSION): vol.Coerce(str), }) +# pylint: disable=no-value-for-parameter SCHEMA_OPTIONS = vol.Schema({ - vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]) + vol.Optional(ATTR_BOOT): vol.In([BOOT_AUTO, BOOT_MANUAL]), + vol.Optional(ATTR_NETWORK): vol.Any(None, DOCKER_PORTS), + vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), }) @@ -51,6 +56,7 @@ class APIAddons(object): ATTR_NAME: addon.name, ATTR_DESCRIPTON: addon.description, ATTR_VERSION: addon.version_installed, + ATTR_AUTO_UPDATE: addon.auto_update, ATTR_REPOSITORY: addon.repository, ATTR_LAST_VERSION: addon.last_version, ATTR_STATE: await addon.state(), @@ -59,6 +65,8 @@ class APIAddons(object): ATTR_URL: addon.url, ATTR_DETACHED: addon.is_detached, ATTR_BUILD: addon.need_build, + ATTR_NETWORK: addon.ports, + ATTR_HOST_NETWORK: addon.network_mode == 'host', } @api_process @@ -76,6 +84,10 @@ class APIAddons(object): addon.options = body[ATTR_OPTIONS] if ATTR_BOOT in body: addon.boot = body[ATTR_BOOT] + if ATTR_AUTO_UPDATE in body: + addon.auto_update = body[ATTR_AUTO_UPDATE] + if ATTR_NETWORK in body: + addon.ports = body[ATTR_NETWORK] return True diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index b492a8dfc..6ba15511a 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -6,12 +6,13 @@ import voluptuous as vol from .util import api_process, api_process_raw, api_validate from ..const import ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES +from ..validate import HASS_DEVICES _LOGGER = logging.getLogger(__name__) SCHEMA_OPTIONS = vol.Schema({ - vol.Optional(ATTR_DEVICES): [vol.Match(r"^[^/]*$")], + vol.Optional(ATTR_DEVICES): HASS_DEVICES, }) SCHEMA_VERSION = vol.Schema({ diff --git a/hassio/config.py b/hassio/config.py index 33e61d1f0..02fe0e3a4 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -11,6 +11,7 @@ from voluptuous.humanize import humanize_error from .const import FILE_HASSIO_CONFIG, HASSIO_DATA from .tools import ( fetch_last_versions, write_json_file, read_json_file, validate_timezone) +from .validate import HASS_DEVICES _LOGGER = logging.getLogger(__name__) @@ -49,7 +50,7 @@ SCHEMA_CONFIG = vol.Schema({ vol.Optional(API_ENDPOINT): vol.Coerce(str), vol.Optional(TIMEZONE, default='UTC'): validate_timezone, vol.Optional(HOMEASSISTANT_LAST): vol.Coerce(str), - vol.Optional(HOMEASSISTANT_DEVICES, default=[]): [vol.Coerce(str)], + vol.Optional(HOMEASSISTANT_DEVICES, default=[]): HASS_DEVICES, vol.Optional(HASSIO_LAST): vol.Coerce(str), vol.Optional(ADDONS_CUSTOM_LIST, default=[]): [vol.Url()], vol.Optional(SECURITY_INITIALIZE, default=False): vol.Boolean(), diff --git a/hassio/const.py b/hassio/const.py index 8f72ee50a..3f95e00b6 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -14,6 +14,7 @@ HASSIO_DATA = Path("/data") RUN_UPDATE_INFO_TASKS = 28800 RUN_UPDATE_SUPERVISOR_TASKS = 29100 +RUN_UPDATE_ADDONS_TASKS = 57600 RUN_RELOAD_ADDONS_TASKS = 28800 RUN_RELOAD_SNAPSHOTS_TASKS = 72000 RUN_WATCHDOG_HOMEASSISTANT = 15 @@ -81,6 +82,7 @@ ATTR_BUILD = 'build' ATTR_DEVICES = 'devices' ATTR_ENVIRONMENT = 'environment' ATTR_HOST_NETWORK = 'host_network' +ATTR_NETWORK = 'network' ATTR_TMPFS = 'tmpfs' ATTR_PRIVILEGED = 'privileged' ATTR_USER = 'user' @@ -90,6 +92,8 @@ ATTR_HOMEASSISTANT = 'homeassistant' ATTR_FOLDERS = 'folders' ATTR_SIZE = 'size' ATTR_TYPE = 'type' +ATTR_TIMEOUT = 'timeout' +ATTR_AUTO_UPDATE = 'auto_update' STARTUP_INITIALIZE = 'initialize' STARTUP_BEFORE = 'before' diff --git a/hassio/core.py b/hassio/core.py index c5f51e065..eaba036e9 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -12,14 +12,14 @@ from .const import ( SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, RUN_CLEANUP_API_SESSIONS, STARTUP_AFTER, STARTUP_BEFORE, - STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS) + STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS, RUN_UPDATE_ADDONS_TASKS) from .scheduler import Scheduler from .dock.homeassistant import DockerHomeAssistant from .dock.supervisor import DockerSupervisor from .snapshots import SnapshotsManager from .tasks import ( hassio_update, homeassistant_watchdog, homeassistant_setup, - api_sessions_cleanup) + api_sessions_cleanup, addons_update) from .tools import get_local_ip, fetch_timezone _LOGGER = logging.getLogger(__name__) @@ -108,6 +108,8 @@ class HassIO(object): # schedule addon update task self.scheduler.register_task( self.addons.reload, RUN_RELOAD_ADDONS_TASKS, now=True) + self.scheduler.register_task( + addons_update(self.loop, self.addons), RUN_UPDATE_ADDONS_TASKS) # schedule self update task self.scheduler.register_task( diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index 6cfd9d678..733bbce67 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -13,12 +13,13 @@ _LOGGER = logging.getLogger(__name__) class DockerBase(object): """Docker hassio wrapper.""" - def __init__(self, config, loop, dock, image=None): + def __init__(self, config, loop, dock, image=None, timeout=30): """Initialize docker base wrapper.""" self.config = config self.loop = loop self.dock = dock self.image = image + self.timeout = timeout self.version = None self.arch = None self._lock = asyncio.Lock(loop=loop) @@ -192,7 +193,7 @@ class DockerBase(object): if container.status == 'running': _LOGGER.info("Stop %s docker application", self.image) with suppress(docker.errors.DockerException): - container.stop(timeout=15) + container.stop(timeout=self.timeout) with suppress(docker.errors.DockerException): _LOGGER.info("Clean %s docker application", self.image) @@ -312,7 +313,7 @@ class DockerBase(object): _LOGGER.info("Restart %s", self.image) try: - container.restart(timeout=30) + container.restart(timeout=self.timeout) except docker.errors.DockerException as err: _LOGGER.warning("Can't restart %s -> %s", self.image, err) return False diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index 3db9f87b1..561e60dca 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -1,5 +1,4 @@ """Init file for HassIO addon docker object.""" -from contextlib import suppress import logging from pathlib import Path import shutil @@ -21,7 +20,7 @@ class DockerAddon(DockerBase): def __init__(self, config, loop, dock, addon): """Initialize docker homeassistant wrapper.""" super().__init__( - config, loop, dock, image=addon.image) + config, loop, dock, image=addon.image, timeout=addon.timeout) self.addon = addon @property @@ -249,17 +248,5 @@ class DockerAddon(DockerBase): 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 - - # for restart it need to run! - if container.status != 'running': - return False - - _LOGGER.info("Restart %s", self.image) - with suppress(docker.errors.DockerException): - container.stop(timeout=15) - + self._stop() return self._run() diff --git a/hassio/snapshots/validate.py b/hassio/snapshots/validate.py index 0efe2ee28..2259009d5 100644 --- a/hassio/snapshots/validate.py +++ b/hassio/snapshots/validate.py @@ -7,6 +7,7 @@ from ..const import ( ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_DEVICES, FOLDER_SHARE, FOLDER_HOMEASSISTANT, FOLDER_ADDONS, FOLDER_SSL, SNAPSHOT_FULL, SNAPSHOT_PARTIAL) +from ..validate import HASS_DEVICES ALL_FOLDERS = [FOLDER_HOMEASSISTANT, FOLDER_SHARE, FOLDER_ADDONS, FOLDER_SSL] @@ -18,7 +19,7 @@ SCHEMA_SNAPSHOT = vol.Schema({ vol.Required(ATTR_DATE): vol.Coerce(str), vol.Required(ATTR_HOMEASSISTANT): vol.Schema({ vol.Required(ATTR_VERSION): vol.Coerce(str), - vol.Optional(ATTR_DEVICES, default=[]): [vol.Match(r"^[^/]*$")], + vol.Optional(ATTR_DEVICES, default=[]): HASS_DEVICES, }), vol.Optional(ATTR_FOLDERS, default=[]): [vol.In(ALL_FOLDERS)], vol.Optional(ATTR_ADDONS, default=[]): [vol.Schema({ diff --git a/hassio/tasks.py b/hassio/tasks.py index 65d04a140..1521bab9e 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -18,6 +18,25 @@ def api_sessions_cleanup(config): return _api_sessions_cleanup +def addons_update(loop, addons): + """Create scheduler task for auto update addons.""" + async def _addons_update(): + """Check if a update is available of a addon and update it.""" + tasks = [] + for addon in addons.list_addons: + if not addon.is_installed: + continue + + if addon.version_installed != addon.version: + tasks.append(addon.update()) + + if tasks: + _LOGGER.info("Addon auto update process %d tasks", len(tasks)) + await asyncio.wait(tasks, loop=loop) + + return _addons_update + + def hassio_update(config, supervisor, websession): """Create scheduler task for update of supervisor hassio.""" async def _hassio_update(): diff --git a/hassio/validate.py b/hassio/validate.py new file mode 100644 index 000000000..4a97a54b7 --- /dev/null +++ b/hassio/validate.py @@ -0,0 +1,32 @@ +"""Validate functions.""" +import voluptuous as vol + +NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) +HASS_DEVICES = [vol.Match(r"^[^/]*$")] + + +def convert_to_docker_ports(data): + """Convert data into docker port list.""" + # dynamic ports + if data is None: + return + + # single port + if isinstance(data, int): + return NETWORK_PORT(data) + + # port list + if isinstance(data, list) and len(data) > 2: + return vol.Schema([NETWORK_PORT])(data) + + # ip port mapping + if isinstance(data, list) and len(data) == 2: + return (vol.Coerce(str)(data[0]), NETWORK_PORT(data[1])) + + raise vol.Invalid("Can't validate docker host settings") + + +DOCKER_PORTS = vol.Schema({ + vol.All(vol.Coerce(str), vol.Match(r"^\d+(?:/tcp|/udp)?$")): + convert_to_docker_ports, +})