diff --git a/Dockerfile b/Dockerfile index 4f4274405..846675b9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,12 +18,11 @@ RUN apk add --no-cache \ COPY requirements.txt /usr/src/ RUN export MAKEFLAGS="-j$(nproc)" \ && pip3 install --no-cache-dir --find-links https://wheels.hass.io/alpine-3.9/${BUILD_ARCH}/ \ - -r /usr/src/requirements.txt \ + -r /usr/src/requirements.txt \ && rm -f /usr/src/requirements.txt # Install HassIO COPY . /usr/src/hassio -RUN pip3 install --no-cache-dir /usr/src/hassio \ - && rm -rf /usr/src/hassio +RUN pip3 install --no-cache-dir -e /usr/src/hassio CMD [ "python3", "-m", "hassio" ] diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py index 4fb01969b..6879c4fcd 100644 --- a/hassio/addons/__init__.py +++ b/hassio/addons/__init__.py @@ -1,158 +1,251 @@ """Init file for Hass.io add-ons.""" import asyncio +from contextlib import suppress import logging +import tarfile +from typing import Dict, List, Optional, Union +from ..const import BOOT_AUTO, STATE_STARTED +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import ( + AddonsError, + AddonsNotSupportedError, + DockerAPIError, + HostAppArmorError, +) +from ..store.addon import AddonStore from .addon import Addon -from .repository import Repository from .data import AddonsData -from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL, BOOT_AUTO, STATE_STARTED -from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) -BUILTIN_REPOSITORIES = set((REPOSITORY_CORE, REPOSITORY_LOCAL)) +AnyAddon = Union[Addon, AddonStore] class AddonManager(CoreSysAttributes): """Manage add-ons inside Hass.io.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize Docker base wrapper.""" - self.coresys = coresys - self.data = AddonsData(coresys) - self.addons_obj = {} - self.repositories_obj = {} + self.coresys: CoreSys = coresys + self.data: AddonsData = AddonsData(coresys) + self.local: Dict[str, Addon] = {} + self.store: Dict[str, AddonStore] = {} @property - def list_addons(self): + def all(self) -> List[AnyAddon]: """Return a list of all add-ons.""" - return list(self.addons_obj.values()) + addons = {**self.store, **self.local} + return list(addons.values()) @property - def list_installed(self): - """Return a list of installed add-ons.""" - return [addon for addon in self.addons_obj.values() - if addon.is_installed] + def installed(self) -> List[Addon]: + """Return a list of all installed add-ons.""" + return list(self.local.values()) - @property - def list_repositories(self): - """Return list of add-on repositories.""" - return list(self.repositories_obj.values()) + def get(self, addon_slug: str) -> Optional[AnyAddon]: + """Return an add-on from slug. - def get(self, addon_slug): - """Return an add-on from slug.""" - return self.addons_obj.get(addon_slug) + Prio: + 1 - Local + 2 - Store + """ + if addon_slug in self.local: + return self.local[addon_slug] + return self.store.get(addon_slug) - def from_token(self, token): + def from_token(self, token: str) -> Optional[Addon]: """Return an add-on from Hass.io token.""" - for addon in self.list_addons: - if addon.is_installed and token == addon.hassio_token: + for addon in self.installed: + if token == addon.hassio_token: return addon return None - async def load(self): + async def load(self) -> None: """Start up add-on management.""" - self.data.reload() - - # Init Hass.io built-in repositories - repositories = \ - set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES - - # Init custom repositories and load add-ons - await self.load_repositories(repositories) - - async def reload(self): - """Update add-ons from repository and reload list.""" - tasks = [repository.update() for repository in - self.repositories_obj.values()] - if tasks: - await asyncio.wait(tasks) - - # read data from repositories - self.data.reload() - - # update addons - await self.load_addons() - - async def load_repositories(self, list_repositories): - """Add a new custom repository.""" - new_rep = set(list_repositories) - old_rep = set(self.repositories_obj) - - # add new repository - async def _add_repository(url): - """Helper function to async add repository.""" - repository = Repository(self.coresys, url) - if not await repository.load(): - _LOGGER.error("Can't load from repository %s", url) - return - self.repositories_obj[url] = repository - - # don't add built-in repository to config - if url not in BUILTIN_REPOSITORIES: - self.sys_config.add_addon_repository(url) - - tasks = [_add_repository(url) for url in new_rep - old_rep] - if tasks: - await asyncio.wait(tasks) - - # del new repository - for url in old_rep - new_rep - BUILTIN_REPOSITORIES: - self.repositories_obj.pop(url).remove() - self.sys_config.drop_addon_repository(url) - - # update data - self.data.reload() - await self.load_addons() - - async def load_addons(self): - """Update/add internal add-on store.""" - all_addons = set(self.data.system) | set(self.data.cache) - - # calc diff - add_addons = all_addons - set(self.addons_obj) - del_addons = set(self.addons_obj) - all_addons - - _LOGGER.info("Load add-ons: %d all - %d new - %d remove", - len(all_addons), len(add_addons), len(del_addons)) - - # new addons tasks = [] - for addon_slug in add_addons: - addon = Addon(self.coresys, addon_slug) - + for slug in self.data.system: + addon = self.local[slug] = Addon(self.coresys, slug) tasks.append(addon.load()) - self.addons_obj[addon_slug] = addon + # Run initial tasks + _LOGGER.info("Found %d installed add-ons", len(tasks)) if tasks: await asyncio.wait(tasks) - # remove - for addon_slug in del_addons: - self.addons_obj.pop(addon_slug) - - async def boot(self, stage): + async def boot(self, stage: str) -> None: """Boot add-ons with mode auto.""" tasks = [] - for addon in self.addons_obj.values(): - if addon.is_installed and addon.boot == BOOT_AUTO and \ - addon.startup == stage: - tasks.append(addon.start()) + for addon in self.installed: + if addon.boot != BOOT_AUTO or addon.startup != stage: + continue + tasks.append(addon.start()) - _LOGGER.info("Startup %s run %d add-ons", stage, len(tasks)) + _LOGGER.info("Phase '%s' start %d add-ons", stage, len(tasks)) if tasks: await asyncio.wait(tasks) await asyncio.sleep(self.sys_config.wait_boot) - async def shutdown(self, stage): + async def shutdown(self, stage: str) -> None: """Shutdown addons.""" tasks = [] - for addon in self.addons_obj.values(): - if addon.is_installed and \ - await addon.state() == STATE_STARTED and \ - addon.startup == stage: - tasks.append(addon.stop()) + for addon in self.installed: + if await addon.state() != STATE_STARTED or addon.startup != stage: + continue + tasks.append(addon.stop()) - _LOGGER.info("Shutdown %s stop %d add-ons", stage, len(tasks)) + _LOGGER.info("Phase '%s' stop %d add-ons", stage, len(tasks)) if tasks: await asyncio.wait(tasks) + + async def install(self, slug: str) -> None: + """Install an add-on.""" + if slug in self.local: + _LOGGER.warning("Add-on %s is already installed", slug) + return + store = self.store.get(slug) + + if not store: + _LOGGER.error("Add-on %s not exists", slug) + raise AddonsError() + + if not store.available: + _LOGGER.error( + "Add-on %s not supported on that platform", slug) + raise AddonsNotSupportedError() + + self.data.install(store) + addon = Addon(self.coresys, slug) + + if not addon.path_data.is_dir(): + _LOGGER.info( + "Create Home Assistant add-on data folder %s", addon.path_data) + addon.path_data.mkdir() + + # Setup/Fix AppArmor profile + await addon.install_apparmor() + + try: + await addon.instance.install(store.version, store.image) + except DockerAPIError: + self.data.uninstall(addon) + raise AddonsError() from None + else: + self.local[slug] = addon + + async def uninstall(self, slug: str) -> None: + """Remove an add-on.""" + if slug not in self.local: + _LOGGER.warning("Add-on %s is not installed", slug) + return + addon = self.local.get(slug) + + try: + await addon.instance.remove() + except DockerAPIError: + raise AddonsError() from None + + await addon.remove_data() + + # Cleanup audio settings + if addon.path_asound.exists(): + with suppress(OSError): + addon.path_asound.unlink() + + # Cleanup AppArmor profile + with suppress(HostAppArmorError): + await addon.uninstall_apparmor() + + # Cleanup internal data + addon.remove_discovery() + self.data.uninstall(addon) + self.local.pop(slug) + + async def update(self, slug: str) -> None: + """Update add-on.""" + if slug not in self.local: + _LOGGER.error("Add-on %s is not installed", slug) + raise AddonsError() + addon = self.local.get(slug) + + if addon.is_detached: + _LOGGER.error("Add-on %s is not available inside store", slug) + raise AddonsError() + store = self.store.get(slug) + + if addon.version == store.version: + _LOGGER.warning("No update available for add-on %s", slug) + return + + # Check if available, Maybe something have changed + if not store.available: + _LOGGER.error( + "Add-on %s not supported on that platform", slug) + raise AddonsNotSupportedError() + + # Update instance + last_state = await addon.state() + try: + await addon.instance.update(store.version, store.image) + except DockerAPIError: + raise AddonsError() from None + self.data.update(store) + + # Setup/Fix AppArmor profile + await addon.install_apparmor() + + # restore state + if last_state == STATE_STARTED: + await addon.start() + + async def rebuild(self, slug: str) -> None: + """Perform a rebuild of local build add-on.""" + if slug not in self.local: + _LOGGER.error("Add-on %s is not installed", slug) + raise AddonsError() + addon = self.local.get(slug) + + if addon.is_detached: + _LOGGER.error("Add-on %s is not available inside store", slug) + raise AddonsError() + store = self.store.get(slug) + + # Check if a rebuild is possible now + if addon.version != store.version: + _LOGGER.error("Version changed, use Update instead Rebuild") + raise AddonsError() + if not addon.need_build: + _LOGGER.error("Can't rebuild a image based add-on") + raise AddonsNotSupportedError() + + # remove docker container but not addon config + last_state = await addon.state() + try: + await addon.instance.remove() + await addon.instance.install(addon.version) + except DockerAPIError: + raise AddonsError() from None + else: + self.data.update(store) + + # restore state + if last_state == STATE_STARTED: + await addon.start() + + async def restore(self, slug: str, tar_file: tarfile.TarFile) -> None: + """Restore state of an add-on.""" + if slug not in self.local: + _LOGGER.debug("Add-on %s is not local available for restore") + addon = Addon(self.coresys, slug) + else: + _LOGGER.debug("Add-on %s is local available for restore") + addon = self.local[slug] + + await addon.restore(tar_file) + + # Check if new + if slug in self.local: + return + + _LOGGER.info("Detect new Add-on after restore %s", slug) + self.local[slug] = addon diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 82f1e8ef3..394f58c7e 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -1,7 +1,6 @@ """Init file for Hass.io add-ons.""" from contextlib import suppress from copy import deepcopy -from distutils.version import StrictVersion from ipaddress import IPv4Address, ip_address import logging from pathlib import Path, PurePath @@ -17,75 +16,30 @@ from voluptuous.humanize import humanize_error from ..const import ( ATTR_ACCESS_TOKEN, - ATTR_APPARMOR, - ATTR_ARCH, - ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, - ATTR_AUTH_API, - ATTR_AUTO_UART, ATTR_AUTO_UPDATE, ATTR_BOOT, - ATTR_DESCRIPTON, - ATTR_DEVICES, - ATTR_DEVICETREE, - ATTR_DISCOVERY, - ATTR_DOCKER_API, - ATTR_ENVIRONMENT, - ATTR_FULL_ACCESS, - ATTR_GPIO, - ATTR_HASSIO_API, - ATTR_HASSIO_ROLE, - ATTR_HOMEASSISTANT, - ATTR_HOMEASSISTANT_API, - ATTR_HOST_DBUS, - ATTR_HOST_IPC, - ATTR_HOST_NETWORK, - ATTR_HOST_PID, ATTR_IMAGE, - ATTR_INGRESS, ATTR_INGRESS_ENTRY, + ATTR_INGRESS_PANEL, ATTR_INGRESS_PORT, ATTR_INGRESS_TOKEN, - ATTR_INGRESS_PANEL, - ATTR_PANEL_ADMIN, - ATTR_PANEL_ICON, - ATTR_PANEL_TITLE, - ATTR_KERNEL_MODULES, - ATTR_LEGACY, - ATTR_LOCATON, - ATTR_MACHINE, - ATTR_MAP, - ATTR_NAME, ATTR_NETWORK, ATTR_OPTIONS, ATTR_PORTS, - ATTR_PORTS_DESCRIPTION, - ATTR_PRIVILEGED, ATTR_PROTECTED, - ATTR_REPOSITORY, ATTR_SCHEMA, - ATTR_SERVICES, - ATTR_SLUG, - ATTR_STARTUP, ATTR_STATE, - ATTR_STDIN, ATTR_SYSTEM, - ATTR_TIMEOUT, - ATTR_TMPFS, - ATTR_URL, ATTR_USER, ATTR_UUID, ATTR_VERSION, - ATTR_WEBUI, - SECURITY_DEFAULT, - SECURITY_DISABLE, - SECURITY_PROFILE, STATE_NONE, STATE_STARTED, STATE_STOPPED, ) -from ..coresys import CoreSys, CoreSysAttributes +from ..coresys import CoreSys from ..docker.addon import DockerAddon from ..docker.stats import DockerStats from ..exceptions import ( @@ -97,14 +51,9 @@ from ..exceptions import ( ) from ..utils.apparmor import adjust_profile from ..utils.json import read_json_file, write_json_file -from .utils import check_installed, remove_data -from .validate import ( - MACHINE_ALL, - RE_SERVICE, - RE_VOLUME, - SCHEMA_ADDON_SNAPSHOT, - validate_options, -) +from .model import AddonModel, Data +from .utils import remove_data +from .validate import SCHEMA_ADDON_SNAPSHOT, validate_options _LOGGER = logging.getLogger(__name__) @@ -113,19 +62,17 @@ RE_WEBUI = re.compile( r":\/\/\[HOST\]:\[PORT:(?P\d+)\](?P.*)$") -class Addon(CoreSysAttributes): +class Addon(AddonModel): """Hold data for add-on inside Hass.io.""" def __init__(self, coresys: CoreSys, slug: str): """Initialize data holder.""" self.coresys: CoreSys = coresys - self.instance: DockerAddon = DockerAddon(coresys, slug) - self._id: str = slug + self.instance: DockerAddon = DockerAddon(coresys, self) + self.slug: str = slug async def load(self) -> None: """Async initialize of object.""" - if not self.is_installed: - return with suppress(DockerAPIError): await self.instance.attach() @@ -137,286 +84,151 @@ class Addon(CoreSysAttributes): return self.instance.ip_address @property - def slug(self) -> str: - """Return slug/id of add-on.""" - return self._id + def data(self) -> Data: + """Return add-on data/config.""" + return self.sys_addons.data.system[self.slug] @property - def _mesh(self): - """Return add-on data from system or cache.""" - return self._data.system.get(self._id, self._data.cache.get(self._id)) + def data_store(self) -> Data: + """Return add-on data from store.""" + return self.sys_store.data.addons.get(self.slug, self.data) @property - def _data(self): - """Return add-ons data storage.""" - return self.sys_addons.data + def persist(self) -> Data: + """Return add-on data/config.""" + return self.sys_addons.data.user[self.slug] @property def is_installed(self) -> bool: """Return True if an add-on is installed.""" - return self._id in self._data.system + return True @property def is_detached(self) -> bool: """Return True if add-on is detached.""" - return self._id not in self._data.cache + return self.slug not in self.sys_store.data.addons @property def available(self) -> bool: """Return True if this add-on is available on this platform.""" - if self.is_detached: - addon_data = self._data.system.get(self._id) - else: - addon_data = self._data.cache.get(self._id) - - # Architecture - if not self.sys_arch.is_supported(addon_data[ATTR_ARCH]): - return False - - # Machine / Hardware - machine = addon_data.get(ATTR_MACHINE) or MACHINE_ALL - if self.sys_machine not in machine: - return False - - # Home Assistant - version = addon_data.get(ATTR_HOMEASSISTANT) or self.sys_homeassistant.version - if StrictVersion(self.sys_homeassistant.version) < StrictVersion(version): - return False - - return True + return self._available(self.data_store) @property - def version_installed(self) -> Optional[str]: + def version(self) -> Optional[str]: """Return installed version.""" - return self._data.user.get(self._id, {}).get(ATTR_VERSION) - - def _set_install(self, image: str, version: str) -> None: - """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, - ATTR_IMAGE: image, - } - self.save_data() - - def _set_uninstall(self) -> None: - """Set add-on as uninstalled.""" - self._data.system.pop(self._id, None) - self._data.user.pop(self._id, None) - self.save_data() - - def _set_update(self, image: str, version: str) -> None: - """Update version of add-on.""" - self._data.system[self._id] = deepcopy(self._data.cache[self._id]) - self._data.user[self._id].update({ - ATTR_VERSION: version, - ATTR_IMAGE: image, - }) - self.save_data() - - def _restore_data(self, user: Dict[str, Any], system: Dict[str, Any], image: str) -> None: - """Restore data to add-on.""" - self._data.user[self._id] = deepcopy(user) - self._data.system[self._id] = deepcopy(system) - - self._data.user[self._id][ATTR_IMAGE] = image - self.save_data() + return self.persist[ATTR_VERSION] @property - def options(self): + def options(self) -> Dict[str, Any]: """Return options with local changes.""" - if self.is_installed: - return { - **self._data.system[self._id][ATTR_OPTIONS], - **self._data.user[self._id][ATTR_OPTIONS] - } - return self._data.cache[self._id][ATTR_OPTIONS] + return { + **self.data[ATTR_OPTIONS], + **self.persist[ATTR_OPTIONS] + } @options.setter - def options(self, value): + def options(self, value: Optional[Dict[str, Any]]): """Store user add-on options.""" if value is None: - self._data.user[self._id][ATTR_OPTIONS] = {} + self.persist[ATTR_OPTIONS] = {} else: - self._data.user[self._id][ATTR_OPTIONS] = deepcopy(value) + self.persist[ATTR_OPTIONS] = deepcopy(value) @property - def boot(self): + def boot(self) -> bool: """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] + return self.persist.get(ATTR_BOOT, super().boot) @boot.setter - def boot(self, value): + def boot(self, value: bool): """Store user boot options.""" - self._data.user[self._id][ATTR_BOOT] = value + self.persist[ATTR_BOOT] = value @property - def auto_update(self): + def auto_update(self) -> bool: """Return if auto update is enable.""" - if ATTR_AUTO_UPDATE in self._data.user.get(self._id, {}): - return self._data.user[self._id][ATTR_AUTO_UPDATE] - return None + return self.persist.get(ATTR_AUTO_UPDATE, super().auto_update) @auto_update.setter - def auto_update(self, value): + def auto_update(self, value: bool): """Set auto update.""" - self._data.user[self._id][ATTR_AUTO_UPDATE] = value + self.persist[ATTR_AUTO_UPDATE] = value @property - def name(self): - """Return name of add-on.""" - return self._mesh[ATTR_NAME] - - @property - def timeout(self): - """Return timeout of addon for docker stop.""" - return self._mesh[ATTR_TIMEOUT] - - @property - def uuid(self): + def uuid(self) -> str: """Return an API token for this add-on.""" - if self.is_installed: - return self._data.user[self._id][ATTR_UUID] - return None + return self.persist[ATTR_UUID] @property - def hassio_token(self): + def hassio_token(self) -> Optional[str]: """Return access token for Hass.io API.""" - if self.is_installed: - return self._data.user[self._id].get(ATTR_ACCESS_TOKEN) - return None + return self.persist.get(ATTR_ACCESS_TOKEN) @property - def ingress_token(self): + def ingress_token(self) -> Optional[str]: """Return access token for Hass.io API.""" - if self.is_installed: - return self._data.user[self._id].get(ATTR_INGRESS_TOKEN) - return None + return self.persist.get(ATTR_INGRESS_TOKEN) @property - def ingress_entry(self): + def ingress_entry(self) -> Optional[str]: """Return ingress external URL.""" - if self.is_installed and self.with_ingress: + if self.with_ingress: return f"/api/hassio_ingress/{self.ingress_token}" return None @property - def description(self): - """Return description of add-on.""" - return self._mesh[ATTR_DESCRIPTON] - - @property - def long_description(self): - """Return README.md as long_description.""" - readme = Path(self.path_location, 'README.md') - - # If readme not exists - if not readme.exists(): - return None - - # Return data - with readme.open('r') as readme_file: - return readme_file.read() - - @property - def repository(self): - """Return repository of add-on.""" - return self._mesh[ATTR_REPOSITORY] - - @property - def latest_version(self): + def latest_version(self) -> str: """Return version of add-on.""" - if self._id in self._data.cache: - return self._data.cache[self._id][ATTR_VERSION] - return self.version_installed + return self.data_store[ATTR_VERSION] @property - def protected(self): + def protected(self) -> bool: """Return if add-on is in protected mode.""" - if self.is_installed: - return self._data.user[self._id][ATTR_PROTECTED] - return True + return self.persist[ATTR_PROTECTED] @protected.setter - def protected(self, value): + def protected(self, value: bool): """Set add-on in protected mode.""" - self._data.user[self._id][ATTR_PROTECTED] = value + self.persist[ATTR_PROTECTED] = value @property - def startup(self): - """Return startup type of add-on.""" - return self._mesh.get(ATTR_STARTUP) - - @property - def services_role(self): - """Return dict of services with rights.""" - raw_services = self._mesh.get(ATTR_SERVICES) - if not raw_services: - return {} - - services = {} - for data in raw_services: - service = RE_SERVICE.match(data) - services[service.group('service')] = service.group('rights') - - return services - - @property - def discovery(self): - """Return list of discoverable components/platforms.""" - return self._mesh.get(ATTR_DISCOVERY, []) - - @property - def ports_description(self): - """Return descriptions of ports.""" - return self._mesh.get(ATTR_PORTS_DESCRIPTION) - - @property - def ports(self): + def ports(self) -> Optional[Dict[str, Optional[int]]]: """Return ports of add-on.""" - if ATTR_PORTS not in self._mesh: - return None - - 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] + return self.persist.get(ATTR_NETWORK, super().ports) @ports.setter - def ports(self, value): + def ports(self, value: Optional[Dict[str, Optional[int]]]): """Set custom ports of add-on.""" if value is None: - self._data.user[self._id].pop(ATTR_NETWORK, None) + self.persist.pop(ATTR_NETWORK, None) return # Secure map ports to value new_ports = {} for container_port, host_port in value.items(): - if container_port in self._mesh.get(ATTR_PORTS, {}): + if container_port in self.data.get(ATTR_PORTS, {}): new_ports[container_port] = host_port - self._data.user[self._id][ATTR_NETWORK] = new_ports + self.persist[ATTR_NETWORK] = new_ports @property - def ingress_url(self): + def ingress_url(self) -> Optional[str]: """Return URL to ingress url.""" - if not self.is_installed or not self.with_ingress: + if not self.with_ingress: return None - webui = f"/api/hassio_ingress/{self.ingress_token}/" - if ATTR_INGRESS_ENTRY in self._mesh: - return f"{webui}{self._mesh[ATTR_INGRESS_ENTRY]}" - return webui + url = f"/api/hassio_ingress/{self.ingress_token}/" + if ATTR_INGRESS_ENTRY in self.data: + return f"{url}{self.data[ATTR_INGRESS_ENTRY]}" + return url @property - def webui(self): + def webui(self) -> Optional[str]: """Return URL to webui or None.""" - if ATTR_WEBUI not in self._mesh: + url = super().webui + if not url: return None - webui = RE_WEBUI.match(self._mesh[ATTR_WEBUI]) + webui = RE_WEBUI.match(url) # extract arguments t_port = webui.group('t_port') @@ -443,319 +255,81 @@ class Addon(CoreSysAttributes): return f"{proto}://[HOST]:{port}{s_suffix}" @property - def ingress_port(self): + def ingress_port(self) -> Optional[int]: """Return Ingress port.""" - if not self.is_installed or not self.with_ingress: + if not self.with_ingress: return None - port = self._mesh[ATTR_INGRESS_PORT] + port = self.data[ATTR_INGRESS_PORT] if port == 0: return self.sys_ingress.get_dynamic_port(self.slug) return port - @property - def panel_icon(self) -> str: - """Return panel icon for Ingress frame.""" - return self._mesh[ATTR_PANEL_ICON] - - @property - def panel_title(self) -> str: - """Return panel icon for Ingress frame.""" - return self._mesh.get(ATTR_PANEL_TITLE, self.name) - - @property - def panel_admin(self) -> str: - """Return panel icon for Ingress frame.""" - return self._mesh[ATTR_PANEL_ADMIN] - - @property - def host_network(self): - """Return True if add-on run on host network.""" - return self._mesh[ATTR_HOST_NETWORK] - - @property - def host_pid(self): - """Return True if add-on run on host PID namespace.""" - return self._mesh[ATTR_HOST_PID] - - @property - def host_ipc(self): - """Return True if add-on run on host IPC namespace.""" - return self._mesh[ATTR_HOST_IPC] - - @property - def host_dbus(self): - """Return True if add-on run on host D-BUS.""" - return self._mesh[ATTR_HOST_DBUS] - - @property - def devices(self): - """Return devices of add-on.""" - return self._mesh.get(ATTR_DEVICES) - - @property - def auto_uart(self): - """Return True if we should map all UART device.""" - return self._mesh.get(ATTR_AUTO_UART) - - @property - def tmpfs(self): - """Return tmpfs of add-on.""" - return self._mesh.get(ATTR_TMPFS) - - @property - def environment(self): - """Return environment of add-on.""" - return self._mesh.get(ATTR_ENVIRONMENT) - - @property - def privileged(self): - """Return list of privilege.""" - return self._mesh.get(ATTR_PRIVILEGED, []) - - @property - def apparmor(self): - """Return True if AppArmor is enabled.""" - if not self._mesh.get(ATTR_APPARMOR): - return SECURITY_DISABLE - elif self.sys_host.apparmor.exists(self.slug): - return SECURITY_PROFILE - return SECURITY_DEFAULT - - @property - def legacy(self): - """Return if the add-on don't support Home Assistant labels.""" - return self._mesh.get(ATTR_LEGACY) - - @property - def access_docker_api(self): - """Return if the add-on need read-only Docker API access.""" - return self._mesh.get(ATTR_DOCKER_API) - - @property - def access_hassio_api(self): - """Return True if the add-on access to Hass.io REASTful API.""" - return self._mesh[ATTR_HASSIO_API] - - @property - def access_homeassistant_api(self): - """Return True if the add-on access to Home Assistant API proxy.""" - return self._mesh[ATTR_HOMEASSISTANT_API] - - @property - def hassio_role(self): - """Return Hass.io role for API.""" - return self._mesh[ATTR_HASSIO_ROLE] - - @property - def with_stdin(self): - """Return True if the add-on access use stdin input.""" - return self._mesh[ATTR_STDIN] - - @property - def with_ingress(self): - """Return True if the add-on access support ingress.""" - return self._mesh[ATTR_INGRESS] - @property def ingress_panel(self) -> Optional[bool]: """Return True if the add-on access support ingress.""" - if self.is_installed: - return self._data.user[self._id][ATTR_INGRESS_PANEL] - return None + return self.persist[ATTR_INGRESS_PANEL] @ingress_panel.setter def ingress_panel(self, value: bool): """Return True if the add-on access support ingress.""" - self._data.user[self._id][ATTR_INGRESS_PANEL] = value + self.persist[ATTR_INGRESS_PANEL] = value @property - def with_gpio(self): - """Return True if the add-on access to GPIO interface.""" - return self._mesh[ATTR_GPIO] - - @property - def with_kernel_modules(self): - """Return True if the add-on access to kernel modules.""" - return self._mesh[ATTR_KERNEL_MODULES] - - @property - def with_full_access(self): - """Return True if the add-on want full access to hardware.""" - return self._mesh[ATTR_FULL_ACCESS] - - @property - def with_devicetree(self): - """Return True if the add-on read access to devicetree.""" - return self._mesh[ATTR_DEVICETREE] - - @property - def access_auth_api(self): - """Return True if the add-on access to login/auth backend.""" - return self._mesh[ATTR_AUTH_API] - - @property - def with_audio(self): - """Return True if the add-on access to audio.""" - return self._mesh[ATTR_AUDIO] - - @property - def homeassistant_version(self) -> Optional[str]: - """Return min Home Assistant version they needed by Add-on.""" - return self._mesh.get(ATTR_HOMEASSISTANT) - - @property - def audio_output(self): + def audio_output(self) -> Optional[str]: """Return ALSA config for output or None.""" if not self.with_audio: return None - - if self.is_installed and \ - ATTR_AUDIO_OUTPUT in self._data.user[self._id]: - return self._data.user[self._id][ATTR_AUDIO_OUTPUT] - return self.sys_host.alsa.default.output + return self.persist.get(ATTR_AUDIO_OUTPUT, self.sys_host.alsa.default.output) @audio_output.setter - def audio_output(self, value): + def audio_output(self, value: Optional[str]): """Set/reset audio output settings.""" if value is None: - self._data.user[self._id].pop(ATTR_AUDIO_OUTPUT, None) + self.persist.pop(ATTR_AUDIO_OUTPUT, None) else: - self._data.user[self._id][ATTR_AUDIO_OUTPUT] = value + self.persist[ATTR_AUDIO_OUTPUT] = value @property - def audio_input(self): + def audio_input(self) -> Optional[str]: """Return ALSA config for input or None.""" if not self.with_audio: return None - - if self.is_installed and ATTR_AUDIO_INPUT in self._data.user[self._id]: - return self._data.user[self._id][ATTR_AUDIO_INPUT] - return self.sys_host.alsa.default.input + return self.persist.get(ATTR_AUDIO_INPUT, self.sys_host.alsa.default.input) @audio_input.setter - def audio_input(self, value): + def audio_input(self, value: Optional[str]): """Set/reset audio input settings.""" if value is None: - self._data.user[self._id].pop(ATTR_AUDIO_INPUT, None) + self.persist.pop(ATTR_AUDIO_INPUT, None) else: - self._data.user[self._id][ATTR_AUDIO_INPUT] = value - - @property - def url(self): - """Return URL of add-on.""" - return self._mesh.get(ATTR_URL) - - @property - def with_icon(self): - """Return True if an icon exists.""" - return self.path_icon.exists() - - @property - def with_logo(self): - """Return True if a logo exists.""" - return self.path_logo.exists() - - @property - def with_changelog(self): - """Return True if a changelog exists.""" - return self.path_changelog.exists() - - @property - def supported_arch(self): - """Return list of supported arch.""" - return self._mesh[ATTR_ARCH] - - @property - def supported_machine(self): - """Return list of supported machine.""" - return self._mesh.get(ATTR_MACHINE) or MACHINE_ALL + self.persist[ATTR_AUDIO_INPUT] = value @property def image(self): """Return image name of add-on.""" - if self.is_installed: - return self._data.user[self._id].get(ATTR_IMAGE) - return self.image_next - - @property - def image_next(self): - """Return image name for install/update.""" - if self.is_detached: - addon_data = self._data.system.get(self._id) - else: - addon_data = self._data.cache.get(self._id) - return self._get_image(addon_data) - - def _get_image(self, addon_data) -> str: - """Generate image name from data.""" - # Repository with Dockerhub images - if ATTR_IMAGE in addon_data: - arch = self.sys_arch.match(addon_data[ATTR_ARCH]) - return addon_data[ATTR_IMAGE].format(arch=arch) - - # local build - return (f"{addon_data[ATTR_REPOSITORY]}/" - f"{self.sys_arch.default}-" - f"addon-{addon_data[ATTR_SLUG]}") + return self.persist.get(ATTR_IMAGE) @property def need_build(self): """Return True if this add-on need a local build.""" - if self.is_detached: - return ATTR_IMAGE not in self._data.system.get(self._id) - return ATTR_IMAGE not in self._data.cache.get(self._id) - - @property - def map_volumes(self): - """Return a dict of {volume: policy} from add-on.""" - volumes = {} - for volume in self._mesh[ATTR_MAP]: - result = RE_VOLUME.match(volume) - volumes[result.group(1)] = result.group(2) or 'ro' - - return volumes + return ATTR_IMAGE not in self.data @property def path_data(self): """Return add-on data path inside Supervisor.""" - return Path(self.sys_config.path_addons_data, self._id) + return Path(self.sys_config.path_addons_data, self.slug) @property def path_extern_data(self): """Return add-on data path external for Docker.""" - return PurePath(self.sys_config.path_extern_addons_data, self._id) + return PurePath(self.sys_config.path_extern_addons_data, self.slug) @property def path_options(self): """Return path to add-on options.""" return Path(self.path_data, "options.json") - @property - def path_location(self): - """Return path to this add-on.""" - return Path(self._mesh[ATTR_LOCATON]) - - @property - def path_icon(self): - """Return path to add-on icon.""" - return Path(self.path_location, 'icon.png') - - @property - def path_logo(self): - """Return path to add-on logo.""" - return Path(self.path_location, 'logo.png') - - @property - def path_changelog(self): - """Return path to add-on changelog.""" - return Path(self.path_location, 'CHANGELOG.md') - - @property - def path_apparmor(self): - """Return path to custom AppArmor profile.""" - return Path(self.path_location, 'apparmor.txt') - @property def path_asound(self): """Return path to asound config.""" @@ -766,7 +340,7 @@ class Addon(CoreSysAttributes): """Return path to asound config for Docker.""" return Path(self.sys_config.path_extern_tmp, f"{self.slug}_asound") - def save_data(self): + def save_persist(self): """Save data of add-on.""" self.sys_addons.data.save_data() @@ -779,14 +353,15 @@ class Addon(CoreSysAttributes): schema(options) write_json_file(self.path_options, options) except vol.Invalid as ex: - _LOGGER.error("Add-on %s have wrong options: %s", self._id, + _LOGGER.error("Add-on %s have wrong options: %s", self.slug, humanize_error(options, ex)) except JsonFileError: - _LOGGER.error("Add-on %s can't write options", self._id) + _LOGGER.error("Add-on %s can't write options", self.slug) else: - return True + _LOGGER.debug("Add-on %s write options: %s", self.slug, options) + return - return False + raise AddonsError() def remove_discovery(self): """Remove all discovery message from add-on.""" @@ -795,6 +370,14 @@ class Addon(CoreSysAttributes): continue self.sys_discovery.remove(message) + async def remove_data(self): + """Remove add-on data.""" + if not self.path_data.is_dir(): + return + + _LOGGER.info("Remove add-on data folder %s", self.path_data) + await remove_data(self.path_data) + def write_asound(self): """Write asound config to file and return True on success.""" asound_config = self.sys_host.alsa.asound( @@ -804,12 +387,12 @@ class Addon(CoreSysAttributes): with self.path_asound.open('w') as config_file: config_file.write(asound_config) except OSError as err: - _LOGGER.error("Add-on %s can't write asound: %s", self._id, err) - return False + _LOGGER.error("Add-on %s can't write asound: %s", self.slug, err) + raise AddonsError() - return True + _LOGGER.debug("Add-on %s write asound: %s", self.slug, self.path_asound) - async def _install_apparmor(self) -> None: + async def install_apparmor(self) -> None: """Install or Update AppArmor profile for Add-on.""" exists_local = self.sys_host.apparmor.exists(self.slug) exists_addon = self.path_apparmor.exists() @@ -830,23 +413,17 @@ class Addon(CoreSysAttributes): adjust_profile(self.slug, self.path_apparmor, profile_file) await self.sys_host.apparmor.load_profile(self.slug, profile_file) - @property - def schema(self) -> vol.Schema: - """Create a schema for add-on 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 uninstall_apparmor(self) -> None: + """Remove AppArmor profile for Add-on.""" + if not self.sys_host.apparmor.exists(self.slug): + return + await self.sys_host.apparmor.remove_profile(self.slug) def test_update_schema(self) -> bool: """Check if the existing configuration is valid after update.""" - if not self.is_installed or self.is_detached: - return True - # load next schema - new_raw_schema = self._data.cache[self._id][ATTR_SCHEMA] - default_options = self._data.cache[self._id][ATTR_OPTIONS] + new_raw_schema = self.data_store[ATTR_SCHEMA] + default_options = self.data_store[ATTR_OPTIONS] # if disabled if isinstance(new_raw_schema, bool): @@ -854,7 +431,7 @@ class Addon(CoreSysAttributes): # merge options options = { - **self._data.user[self._id][ATTR_OPTIONS], + **self.persist[ATTR_OPTIONS], **default_options, } @@ -866,63 +443,10 @@ class Addon(CoreSysAttributes): try: new_schema(options) except vol.Invalid: + _LOGGER.warning("Add-on %s new schema is not compatible", self.slug) return False return True - async def install(self) -> None: - """Install an add-on.""" - if not self.available: - _LOGGER.error( - "Add-on %s not supported on %s with %s architecture", - self._id, self.sys_machine, self.sys_arch.supported) - raise AddonsNotSupportedError() - - if self.is_installed: - _LOGGER.warning("Add-on %s is already installed", self._id) - return - - if not self.path_data.is_dir(): - _LOGGER.info( - "Create Home Assistant add-on data folder %s", self.path_data) - self.path_data.mkdir() - - # Setup/Fix AppArmor profile - await self._install_apparmor() - - try: - await self.instance.install(self.latest_version, self.image_next) - except DockerAPIError: - raise AddonsError() from None - else: - self._set_install(self.image_next, self.latest_version) - - @check_installed - async def uninstall(self) -> None: - """Remove an add-on.""" - try: - await self.instance.remove() - except DockerAPIError: - raise AddonsError() from None - - if self.path_data.is_dir(): - _LOGGER.info( - "Remove Home Assistant add-on data folder %s", self.path_data) - await remove_data(self.path_data) - - # Cleanup audio settings - if self.path_asound.exists(): - with suppress(OSError): - self.path_asound.unlink() - - # Cleanup AppArmor profile - if self.sys_host.apparmor.exists(self.slug): - with suppress(HostAppArmorError): - await self.sys_host.apparmor.remove_profile(self.slug) - - # Cleanup internal data - self.remove_discovery() - self._set_uninstall() - async def state(self) -> str: """Return running state of add-on.""" if not self.is_installed: @@ -932,7 +456,6 @@ class Addon(CoreSysAttributes): return STATE_STARTED return STATE_STOPPED - @check_installed async def start(self) -> None: """Set options and start add-on.""" if await self.instance.is_running(): @@ -940,23 +463,21 @@ class Addon(CoreSysAttributes): return # Access Token - self._data.user[self._id][ATTR_ACCESS_TOKEN] = secrets.token_hex(56) - self.save_data() + self.persist[ATTR_ACCESS_TOKEN] = secrets.token_hex(56) + self.save_persist() # Options - if not self.write_options(): - raise AddonsError() + self.write_options() # Sound - if self.with_audio and not self.write_asound(): - raise AddonsError() + if self.with_audio: + self.write_asound() try: await self.instance.run() except DockerAPIError: raise AddonsError() from None - @check_installed async def stop(self) -> None: """Stop add-on.""" try: @@ -964,43 +485,12 @@ class Addon(CoreSysAttributes): except DockerAPIError: raise AddonsError() from None - @check_installed - async def update(self) -> None: - """Update add-on.""" - if self.latest_version == self.version_installed: - _LOGGER.warning("No update available for add-on %s", self._id) - return - - # Check if available, Maybe something have changed - if not self.available: - _LOGGER.error( - "Add-on %s not supported on %s with %s architecture", - self._id, self.sys_machine, self.sys_arch.supported) - raise AddonsNotSupportedError() - - # Update instance - last_state = await self.state() - try: - await self.instance.update(self.latest_version, self.image_next) - except DockerAPIError: - raise AddonsError() from None - self._set_update(self.image_next, self.latest_version) - - # Setup/Fix AppArmor profile - await self._install_apparmor() - - # restore state - if last_state == STATE_STARTED: - await self.start() - - @check_installed async def restart(self) -> None: """Restart add-on.""" with suppress(AddonsError): await self.stop() await self.start() - @check_installed def logs(self) -> Awaitable[bytes]: """Return add-ons log output. @@ -1008,7 +498,6 @@ class Addon(CoreSysAttributes): """ return self.instance.logs() - @check_installed async def stats(self) -> DockerStats: """Return stats of container.""" try: @@ -1016,32 +505,6 @@ class Addon(CoreSysAttributes): except DockerAPIError: raise AddonsError() from None - @check_installed - async def rebuild(self) -> None: - """Perform a rebuild of local build add-on.""" - last_state = await self.state() - - if not self.need_build: - _LOGGER.error("Can't rebuild a image based add-on") - raise AddonsNotSupportedError() - if self.version_installed != self.latest_version: - _LOGGER.error("Version changed, use Update instead Rebuild") - raise AddonsError() - - # remove docker container but not addon config - try: - await self.instance.remove() - await self.instance.install(self.version_installed) - except DockerAPIError: - raise AddonsError() from None - else: - self._set_update(self.image, self.version_installed) - - # restore state - if last_state == STATE_STARTED: - await self.start() - - @check_installed async def write_stdin(self, data): """Write data to add-on stdin. @@ -1056,7 +519,6 @@ class Addon(CoreSysAttributes): except DockerAPIError: raise AddonsError() from None - @check_installed async def snapshot(self, tar_file: tarfile.TarFile) -> None: """Snapshot state of an add-on.""" with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp: @@ -1068,9 +530,9 @@ class Addon(CoreSysAttributes): raise AddonsError() from None data = { - ATTR_USER: self._data.user.get(self._id, {}), - ATTR_SYSTEM: self._data.system.get(self._id, {}), - ATTR_VERSION: self.version_installed, + ATTR_USER: self.persist, + ATTR_SYSTEM: self.data, + ATTR_VERSION: self.version, ATTR_STATE: await self.state(), } @@ -1078,7 +540,7 @@ class Addon(CoreSysAttributes): try: write_json_file(Path(temp, 'addon.json'), data) except JsonFileError: - _LOGGER.error("Can't save meta for %s", self._id) + _LOGGER.error("Can't save meta for %s", self.slug) raise AddonsError() from None # Store AppArmor Profile @@ -1098,13 +560,13 @@ class Addon(CoreSysAttributes): snapshot.add(self.path_data, arcname="data") try: - _LOGGER.info("Build snapshot for add-on %s", self._id) + _LOGGER.info("Build snapshot for add-on %s", self.slug) await self.sys_run_in_executor(_write_tarfile) except (tarfile.TarError, OSError) as err: _LOGGER.error("Can't write tarfile %s: %s", tar_file, err) raise AddonsError() from None - _LOGGER.info("Finish snapshot for addon %s", self._id) + _LOGGER.info("Finish snapshot for addon %s", self.slug) async def restore(self, tar_file: tarfile.TarFile) -> None: """Restore state of an add-on.""" @@ -1132,18 +594,23 @@ class Addon(CoreSysAttributes): data = SCHEMA_ADDON_SNAPSHOT(data) except vol.Invalid as err: _LOGGER.error("Can't validate %s, snapshot data: %s", - self._id, humanize_error(data, err)) + self.slug, humanize_error(data, err)) raise AddonsError() from None + # If available + if not self._available(data[ATTR_SYSTEM]): + _LOGGER.error("Add-on %s is not available for this Platform", self.slug) + raise AddonsNotSupportedError() + # Restore local add-on informations - _LOGGER.info("Restore config for addon %s", self._id) - restore_image = self._get_image(data[ATTR_SYSTEM]) - self._restore_data(data[ATTR_USER], data[ATTR_SYSTEM], restore_image) + _LOGGER.info("Restore config for addon %s", self.slug) + restore_image = self._image(data[ATTR_SYSTEM]) + self.sys_addons.data.restore(self.slug, data[ATTR_USER], data[ATTR_SYSTEM], restore_image) # Check version / restore image version = data[ATTR_VERSION] if not await self.instance.exists(): - _LOGGER.info("Restore/Install image for addon %s", self._id) + _LOGGER.info("Restore/Install image for addon %s", self.slug) image_file = Path(temp, 'image.tar') if image_file.is_file(): @@ -1154,7 +621,7 @@ class Addon(CoreSysAttributes): await self.instance.install(version, restore_image) await self.instance.cleanup() elif self.instance.version != version or self.legacy: - _LOGGER.info("Restore/Update image for addon %s", self._id) + _LOGGER.info("Restore/Update image for addon %s", self.slug) with suppress(DockerAPIError): await self.instance.update(version, restore_image) else: @@ -1166,7 +633,7 @@ class Addon(CoreSysAttributes): """Restore data.""" shutil.copytree(str(Path(temp, "data")), str(self.path_data)) - _LOGGER.info("Restore data for addon %s", self._id) + _LOGGER.info("Restore data for addon %s", self.slug) if self.path_data.is_dir(): await remove_data(self.path_data) try: @@ -1189,4 +656,4 @@ class Addon(CoreSysAttributes): if data[ATTR_STATE] == STATE_STARTED: return await self.start() - _LOGGER.info("Finish restore for add-on %s", self._id) + _LOGGER.info("Finish restore for add-on %s", self.slug) diff --git a/hassio/addons/build.py b/hassio/addons/build.py index fc80755b4..5eb492548 100644 --- a/hassio/addons/build.py +++ b/hassio/addons/build.py @@ -9,27 +9,23 @@ from ..utils.json import JsonConfig from .validate import SCHEMA_BUILD_CONFIG if TYPE_CHECKING: - from .addon import Addon + from . import AnyAddon class AddonBuild(JsonConfig, CoreSysAttributes): """Handle build options for add-ons.""" - def __init__(self, coresys: CoreSys, slug: str) -> None: + def __init__(self, coresys: CoreSys, addon: AnyAddon) -> None: """Initialize Hass.io add-on builder.""" self.coresys: CoreSys = coresys - self._id: str = slug + self.addon = addon super().__init__( Path(self.addon.path_location, 'build.json'), SCHEMA_BUILD_CONFIG) def save_data(self): """Ignore save function.""" - - @property - def addon(self) -> Addon: - """Return add-on of build data.""" - return self.sys_addons.get(self._id) + raise RuntimeError() @property def base_image(self) -> str: diff --git a/hassio/addons/data.py b/hassio/addons/data.py index 881493b0a..0874ae63e 100644 --- a/hassio/addons/data.py +++ b/hassio/addons/data.py @@ -1,38 +1,34 @@ """Init file for Hass.io add-on data.""" +from copy import deepcopy import logging -from pathlib import Path - -import voluptuous as vol -from voluptuous.humanize import humanize_error +from typing import Any, Dict from ..const import ( - ATTR_LOCATON, - ATTR_REPOSITORY, - ATTR_SLUG, + ATTR_IMAGE, + ATTR_OPTIONS, ATTR_SYSTEM, ATTR_USER, + ATTR_VERSION, FILE_HASSIO_ADDONS, - REPOSITORY_CORE, - REPOSITORY_LOCAL, ) -from ..coresys import CoreSysAttributes -from ..exceptions import JsonFileError -from ..utils.json import JsonConfig, read_json_file -from .utils import extract_hash_from_path -from .validate import SCHEMA_ADDON_CONFIG, SCHEMA_ADDONS_FILE, SCHEMA_REPOSITORY_CONFIG +from ..coresys import CoreSys, CoreSysAttributes +from ..utils.json import JsonConfig +from ..store.addon import AddonStore +from .addon import Addon +from .validate import SCHEMA_ADDONS_FILE _LOGGER = logging.getLogger(__name__) +Config = Dict[str, Any] + class AddonsData(JsonConfig, CoreSysAttributes): - """Hold data for Add-ons inside Hass.io.""" + """Hold data for installed Add-ons inside Hass.io.""" - def __init__(self, coresys): + def __init__(self, coresys: CoreSys): """Initialize data holder.""" super().__init__(FILE_HASSIO_ADDONS, SCHEMA_ADDONS_FILE) - self.coresys = coresys - self._repositories = {} - self._cache = {} + self.coresys: CoreSys = coresys @property def user(self): @@ -44,93 +40,35 @@ class AddonsData(JsonConfig, CoreSysAttributes): """Return local add-on data.""" return self._data[ATTR_SYSTEM] - @property - def cache(self): - """Return add-on data from cache/repositories.""" - return self._cache + def install(self, addon: AddonStore) -> None: + """Set addon as installed.""" + self.system[addon.slug] = deepcopy(addon.data) + self.user[addon.slug] = { + ATTR_OPTIONS: {}, + ATTR_VERSION: addon.version, + ATTR_IMAGE: addon.image, + } + self.save_data() - @property - def repositories(self): - """Return add-on data from repositories.""" - return self._repositories + def uninstall(self, addon: Addon) -> None: + """Set add-on as uninstalled.""" + self.system.pop(addon.slug, None) + self.user.pop(addon.slug, None) + self.save_data() - def reload(self): - """Read data from add-on repository.""" - self._cache = {} - self._repositories = {} + def update(self, addon: AddonStore) -> None: + """Update version of add-on.""" + self.system[addon.slug] = deepcopy(addon.data) + self.user[addon.slug].update({ + ATTR_VERSION: addon.version, + ATTR_IMAGE: addon.image, + }) + self.save_data() - # read core repository - self._read_addons_folder(self.sys_config.path_addons_core, REPOSITORY_CORE) + def restore(self, slug: str, user: Config, system: Config, image: str) -> None: + """Restore data to add-on.""" + self.user[slug] = deepcopy(user) + self.system[slug] = deepcopy(system) - # read local repository - self._read_addons_folder(self.sys_config.path_addons_local, REPOSITORY_LOCAL) - - # add built-in repositories information - self._set_builtin_repositories() - - # read custom git repositories - for repository_element in self.sys_config.path_addons_git.iterdir(): - if repository_element.is_dir(): - self._read_git_repository(repository_element) - - def _read_git_repository(self, path): - """Process a custom repository folder.""" - slug = extract_hash_from_path(path) - - # exists repository json - repository_file = Path(path, "repository.json") - try: - repository_info = SCHEMA_REPOSITORY_CONFIG(read_json_file(repository_file)) - except JsonFileError: - _LOGGER.warning( - "Can't read repository information from %s", repository_file - ) - return - except vol.Invalid: - _LOGGER.warning("Repository parse error %s", repository_file) - return - - # process data - self._repositories[slug] = repository_info - self._read_addons_folder(path, slug) - - def _read_addons_folder(self, path, repository): - """Read data from add-ons folder.""" - for addon in path.glob("**/config.json"): - try: - addon_config = read_json_file(addon) - except JsonFileError: - _LOGGER.warning("Can't read %s from repository %s", addon, repository) - continue - - # validate - try: - addon_config = SCHEMA_ADDON_CONFIG(addon_config) - except vol.Invalid as ex: - _LOGGER.warning( - "Can't read %s: %s", addon, humanize_error(addon_config, ex) - ) - continue - - # Generate slug - addon_slug = "{}_{}".format(repository, addon_config[ATTR_SLUG]) - - # store - addon_config[ATTR_REPOSITORY] = repository - addon_config[ATTR_LOCATON] = str(addon.parent) - self._cache[addon_slug] = addon_config - - def _set_builtin_repositories(self): - """Add local built-in repository into dataset.""" - try: - builtin_file = Path(__file__).parent.joinpath("built-in.json") - builtin_data = read_json_file(builtin_file) - except JsonFileError: - _LOGGER.warning("Can't read built-in json") - return - - # core repository - self._repositories[REPOSITORY_CORE] = builtin_data[REPOSITORY_CORE] - - # local repository - self._repositories[REPOSITORY_LOCAL] = builtin_data[REPOSITORY_LOCAL] + self.user[slug][ATTR_IMAGE] = image + self.save_data() diff --git a/hassio/addons/model.py b/hassio/addons/model.py new file mode 100644 index 000000000..b8313de93 --- /dev/null +++ b/hassio/addons/model.py @@ -0,0 +1,498 @@ +"""Init file for Hass.io add-ons.""" +from distutils.version import StrictVersion +from pathlib import Path +from typing import Any, Awaitable, Dict, List, Optional + +import voluptuous as vol + +from ..const import ( + ATTR_APPARMOR, + ATTR_ARCH, + ATTR_AUDIO, + ATTR_AUTH_API, + ATTR_AUTO_UART, + ATTR_BOOT, + ATTR_DESCRIPTON, + ATTR_DEVICES, + ATTR_DEVICETREE, + ATTR_DISCOVERY, + ATTR_DOCKER_API, + ATTR_ENVIRONMENT, + ATTR_FULL_ACCESS, + ATTR_GPIO, + ATTR_HASSIO_API, + ATTR_HASSIO_ROLE, + ATTR_HOMEASSISTANT, + ATTR_HOMEASSISTANT_API, + ATTR_HOST_DBUS, + ATTR_HOST_IPC, + ATTR_HOST_NETWORK, + ATTR_HOST_PID, + ATTR_IMAGE, + ATTR_INGRESS, + ATTR_KERNEL_MODULES, + ATTR_LEGACY, + ATTR_LOCATON, + ATTR_MACHINE, + ATTR_MAP, + ATTR_NAME, + ATTR_OPTIONS, + ATTR_PANEL_ADMIN, + ATTR_PANEL_ICON, + ATTR_PANEL_TITLE, + ATTR_PORTS, + ATTR_PORTS_DESCRIPTION, + ATTR_PRIVILEGED, + ATTR_REPOSITORY, + ATTR_SCHEMA, + ATTR_SERVICES, + ATTR_SLUG, + ATTR_STARTUP, + ATTR_STDIN, + ATTR_TIMEOUT, + ATTR_TMPFS, + ATTR_URL, + ATTR_VERSION, + ATTR_WEBUI, + SECURITY_DEFAULT, + SECURITY_DISABLE, + SECURITY_PROFILE, +) +from ..coresys import CoreSysAttributes +from .validate import MACHINE_ALL, RE_SERVICE, RE_VOLUME, validate_options + +Data = Dict[str, Any] + + +class AddonModel(CoreSysAttributes): + """Add-on Data layout.""" + + slug: str = None + + @property + def data(self) -> Data: + """Return Add-on config/data.""" + raise NotImplementedError() + + @property + def is_installed(self) -> bool: + """Return True if an add-on is installed.""" + raise NotImplementedError() + + @property + def is_detached(self) -> bool: + """Return True if add-on is detached.""" + raise NotImplementedError() + + @property + def available(self) -> bool: + """Return True if this add-on is available on this platform.""" + return self._available(self.data) + + @property + def options(self) -> Dict[str, Any]: + """Return options with local changes.""" + return self.data[ATTR_OPTIONS] + + @property + def boot(self) -> bool: + """Return boot config with prio local settings.""" + return self.data[ATTR_BOOT] + + @property + def auto_update(self) -> Optional[bool]: + """Return if auto update is enable.""" + return None + + @property + def name(self) -> str: + """Return name of add-on.""" + return self.data[ATTR_NAME] + + @property + def timeout(self) -> int: + """Return timeout of addon for docker stop.""" + return self.data[ATTR_TIMEOUT] + + @property + def uuid(self) -> Optional[str]: + """Return an API token for this add-on.""" + return None + + @property + def hassio_token(self) -> Optional[str]: + """Return access token for Hass.io API.""" + return None + + @property + def ingress_token(self) -> Optional[str]: + """Return access token for Hass.io API.""" + return None + + @property + def ingress_entry(self) -> Optional[str]: + """Return ingress external URL.""" + return None + + @property + def description(self) -> str: + """Return description of add-on.""" + return self.data[ATTR_DESCRIPTON] + + @property + def long_description(self) -> Optional[str]: + """Return README.md as long_description.""" + readme = Path(self.path_location, 'README.md') + + # If readme not exists + if not readme.exists(): + return None + + # Return data + with readme.open('r') as readme_file: + return readme_file.read() + + @property + def repository(self) -> str: + """Return repository of add-on.""" + return self.data[ATTR_REPOSITORY] + + @property + def latest_version(self) -> str: + """Return latest version of add-on.""" + return self.data[ATTR_VERSION] + + @property + def version(self) -> str: + """Return version of add-on.""" + return self.data[ATTR_VERSION] + + @property + def protected(self) -> bool: + """Return if add-on is in protected mode.""" + return True + + @property + def startup(self) -> Optional[str]: + """Return startup type of add-on.""" + return self.data.get(ATTR_STARTUP) + + @property + def services_role(self) -> Dict[str, str]: + """Return dict of services with rights.""" + services_list = self.data.get(ATTR_SERVICES, []) + + services = {} + for data in services_list: + service = RE_SERVICE.match(data) + services[service.group('service')] = service.group('rights') + + return services + + @property + def discovery(self) -> List[str]: + """Return list of discoverable components/platforms.""" + return self.data.get(ATTR_DISCOVERY, []) + + @property + def ports_description(self) -> Optional[Dict[str, str]]: + """Return descriptions of ports.""" + return self.data.get(ATTR_PORTS_DESCRIPTION) + + @property + def ports(self) -> Optional[Dict[str, Optional[int]]]: + """Return ports of add-on.""" + return self.data.get(ATTR_PORTS) + + @property + def ingress_url(self) -> Optional[str]: + """Return URL to ingress url.""" + return None + + @property + def webui(self) -> Optional[str]: + """Return URL to webui or None.""" + return self.data.get(ATTR_WEBUI) + + @property + def ingress_port(self) -> Optional[int]: + """Return Ingress port.""" + return None + + @property + def panel_icon(self) -> str: + """Return panel icon for Ingress frame.""" + return self.data[ATTR_PANEL_ICON] + + @property + def panel_title(self) -> str: + """Return panel icon for Ingress frame.""" + return self.data.get(ATTR_PANEL_TITLE, self.name) + + @property + def panel_admin(self) -> str: + """Return panel icon for Ingress frame.""" + return self.data[ATTR_PANEL_ADMIN] + + @property + def host_network(self) -> bool: + """Return True if add-on run on host network.""" + return self.data[ATTR_HOST_NETWORK] + + @property + def host_pid(self) -> bool: + """Return True if add-on run on host PID namespace.""" + return self.data[ATTR_HOST_PID] + + @property + def host_ipc(self) -> bool: + """Return True if add-on run on host IPC namespace.""" + return self.data[ATTR_HOST_IPC] + + @property + def host_dbus(self) -> bool: + """Return True if add-on run on host D-BUS.""" + return self.data[ATTR_HOST_DBUS] + + @property + def devices(self) -> Optional[List[str]]: + """Return devices of add-on.""" + return self.data.get(ATTR_DEVICES) + + @property + def auto_uart(self) -> bool: + """Return True if we should map all UART device.""" + return self.data[ATTR_AUTO_UART] + + @property + def tmpfs(self) -> Optional[str]: + """Return tmpfs of add-on.""" + return self.data.get(ATTR_TMPFS) + + @property + def environment(self) -> Optional[Dict[str, str]]: + """Return environment of add-on.""" + return self.data.get(ATTR_ENVIRONMENT) + + @property + def privileged(self) -> List[str]: + """Return list of privilege.""" + return self.data.get(ATTR_PRIVILEGED, []) + + @property + def apparmor(self) -> str: + """Return True if AppArmor is enabled.""" + if not self.data.get(ATTR_APPARMOR): + return SECURITY_DISABLE + elif self.sys_host.apparmor.exists(self.slug): + return SECURITY_PROFILE + return SECURITY_DEFAULT + + @property + def legacy(self) -> bool: + """Return if the add-on don't support Home Assistant labels.""" + return self.data[ATTR_LEGACY] + + @property + def access_docker_api(self) -> bool: + """Return if the add-on need read-only Docker API access.""" + return self.data[ATTR_DOCKER_API] + + @property + def access_hassio_api(self) -> bool: + """Return True if the add-on access to Hass.io REASTful API.""" + return self.data[ATTR_HASSIO_API] + + @property + def access_homeassistant_api(self) -> bool: + """Return True if the add-on access to Home Assistant API proxy.""" + return self.data[ATTR_HOMEASSISTANT_API] + + @property + def hassio_role(self) -> str: + """Return Hass.io role for API.""" + return self.data[ATTR_HASSIO_ROLE] + + @property + def with_stdin(self) -> bool: + """Return True if the add-on access use stdin input.""" + return self.data[ATTR_STDIN] + + @property + def with_ingress(self) -> bool: + """Return True if the add-on access support ingress.""" + return self.data[ATTR_INGRESS] + + @property + def ingress_panel(self) -> Optional[bool]: + """Return True if the add-on access support ingress.""" + return None + + @property + def with_gpio(self) -> bool: + """Return True if the add-on access to GPIO interface.""" + return self.data[ATTR_GPIO] + + @property + def with_kernel_modules(self) -> bool: + """Return True if the add-on access to kernel modules.""" + return self.data[ATTR_KERNEL_MODULES] + + @property + def with_full_access(self) -> bool: + """Return True if the add-on want full access to hardware.""" + return self.data[ATTR_FULL_ACCESS] + + @property + def with_devicetree(self) -> bool: + """Return True if the add-on read access to devicetree.""" + return self.data[ATTR_DEVICETREE] + + @property + def access_auth_api(self) -> bool: + """Return True if the add-on access to login/auth backend.""" + return self.data[ATTR_AUTH_API] + + @property + def with_audio(self) -> bool: + """Return True if the add-on access to audio.""" + return self.data[ATTR_AUDIO] + + @property + def homeassistant_version(self) -> Optional[str]: + """Return min Home Assistant version they needed by Add-on.""" + return self.data.get(ATTR_HOMEASSISTANT) + + @property + def url(self) -> Optional[str]: + """Return URL of add-on.""" + return self.data.get(ATTR_URL) + + @property + def with_icon(self) -> bool: + """Return True if an icon exists.""" + return self.path_icon.exists() + + @property + def with_logo(self) -> bool: + """Return True if a logo exists.""" + return self.path_logo.exists() + + @property + def with_changelog(self) -> bool: + """Return True if a changelog exists.""" + return self.path_changelog.exists() + + @property + def supported_arch(self) -> List[str]: + """Return list of supported arch.""" + return self.data[ATTR_ARCH] + + @property + def supported_machine(self) -> List[str]: + """Return list of supported machine.""" + return self.data.get(ATTR_MACHINE, MACHINE_ALL) + + @property + def image(self) -> str: + """Generate image name from data.""" + return self._image(self.data) + + @property + def need_build(self) -> bool: + """Return True if this add-on need a local build.""" + return ATTR_IMAGE not in self.data + + @property + def map_volumes(self) -> Dict[str, str]: + """Return a dict of {volume: policy} from add-on.""" + volumes = {} + for volume in self.data[ATTR_MAP]: + result = RE_VOLUME.match(volume) + volumes[result.group(1)] = result.group(2) or 'ro' + + return volumes + + @property + def path_location(self) -> Path: + """Return path to this add-on.""" + return Path(self.data[ATTR_LOCATON]) + + @property + def path_icon(self) -> Path: + """Return path to add-on icon.""" + return Path(self.path_location, 'icon.png') + + @property + def path_logo(self) -> Path: + """Return path to add-on logo.""" + return Path(self.path_location, 'logo.png') + + @property + def path_changelog(self) -> Path: + """Return path to add-on changelog.""" + return Path(self.path_location, 'CHANGELOG.md') + + @property + def path_apparmor(self) -> Path: + """Return path to custom AppArmor profile.""" + return Path(self.path_location, 'apparmor.txt') + + @property + def schema(self) -> vol.Schema: + """Create a schema for add-on options.""" + raw_schema = self.data[ATTR_SCHEMA] + + if isinstance(raw_schema, bool): + return vol.Schema(dict) + return vol.Schema(vol.All(dict, validate_options(raw_schema))) + + def __eq__(self, other): + """Compaired add-on objects.""" + if self.slug == other.slug: + return True + return False + + def _available(self, config) -> bool: + """Return True if this add-on is available on this platform.""" + # Architecture + if not self.sys_arch.is_supported(config[ATTR_ARCH]): + return False + + # Machine / Hardware + machine = config.get(ATTR_MACHINE) or MACHINE_ALL + if self.sys_machine not in machine: + return False + + # Home Assistant + version = config.get(ATTR_HOMEASSISTANT) or self.sys_homeassistant.version + if StrictVersion(self.sys_homeassistant.version) < StrictVersion(version): + return False + + return True + + def _image(self, config) -> str: + """Generate image name from data.""" + # Repository with Dockerhub images + if ATTR_IMAGE in config: + arch = self.sys_arch.match(config[ATTR_ARCH]) + return config[ATTR_IMAGE].format(arch=arch) + + # local build + return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}" + + def install(self) -> Awaitable[None]: + """Install this add-on.""" + return self.sys_addons.install(self.slug) + + def uninstall(self) -> Awaitable[None]: + """Uninstall this add-on.""" + return self.sys_addons.uninstall(self.slug) + + def update(self) -> Awaitable[None]: + """Update this add-on.""" + return self.sys_addons.update(self.slug) + + def rebuild(self) -> Awaitable[None]: + """Rebuild this add-on.""" + return self.sys_addons.rebuild(self.slug) diff --git a/hassio/addons/utils.py b/hassio/addons/utils.py index 3d59e585a..562399222 100644 --- a/hassio/addons/utils.py +++ b/hassio/addons/utils.py @@ -2,10 +2,8 @@ from __future__ import annotations import asyncio -import hashlib import logging from pathlib import Path -import re from typing import TYPE_CHECKING from ..const import ( @@ -20,16 +18,14 @@ from ..const import ( SECURITY_DISABLE, SECURITY_PROFILE, ) -from ..exceptions import AddonsNotSupportedError if TYPE_CHECKING: - from .addon import Addon + from .model import AddonModel -RE_SHA1 = re.compile(r"[a-f0-9]{8}") _LOGGER = logging.getLogger(__name__) -def rating_security(addon: Addon) -> int: +def rating_security(addon: AddonModel) -> int: """Return 1-6 for security rating. 1 = not secure @@ -86,34 +82,6 @@ def rating_security(addon: Addon) -> int: return max(min(6, rating), 1) -def get_hash_from_repository(name: str) -> str: - """Generate a hash from repository.""" - key = name.lower().encode() - return hashlib.sha1(key).hexdigest()[:8] - - -def extract_hash_from_path(path: Path) -> str: - """Extract repo id from path.""" - repository_dir = path.parts[-1] - - if not RE_SHA1.match(repository_dir): - return get_hash_from_repository(repository_dir) - return repository_dir - - -def check_installed(method): - """Wrap function with check if add-on is installed.""" - - async def wrap_check(addon, *args, **kwargs): - """Return False if not installed or the function.""" - if not addon.is_installed: - _LOGGER.error("Addon %s is not installed", addon.slug) - raise AddonsNotSupportedError() - return await method(addon, *args, **kwargs) - - return wrap_check - - async def remove_data(folder: Path) -> None: """Remove folder and reset privileged.""" try: diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index ac2c2efe6..030eaa7c3 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -49,7 +49,6 @@ from ..const import ( ATTR_LEGACY, ATTR_LOCATON, ATTR_MACHINE, - ATTR_MAINTAINER, ATTR_MAP, ATTR_NAME, ATTR_NETWORK, @@ -211,14 +210,6 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ }, extra=vol.REMOVE_EXTRA) -# pylint: disable=no-value-for-parameter -SCHEMA_REPOSITORY_CONFIG = vol.Schema({ - vol.Required(ATTR_NAME): vol.Coerce(str), - vol.Optional(ATTR_URL): vol.Url(), - vol.Optional(ATTR_MAINTAINER): vol.Coerce(str), -}, extra=vol.REMOVE_EXTRA) - - # pylint: disable=no-value-for-parameter SCHEMA_BUILD_CONFIG = vol.Schema({ vol.Optional(ATTR_BUILD_FROM, default=dict): vol.Schema({ diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 13475539f..1bf72f1e3 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -7,7 +7,7 @@ from aiohttp import web import voluptuous as vol from voluptuous.humanize import humanize_error -from ..addons.addon import Addon +from ..addons import AnyAddon from ..addons.utils import rating_security from ..const import ( ATTR_ADDONS, @@ -44,9 +44,9 @@ from ..const import ( ATTR_ICON, ATTR_INGRESS, ATTR_INGRESS_ENTRY, + ATTR_INGRESS_PANEL, ATTR_INGRESS_PORT, ATTR_INGRESS_URL, - ATTR_INGRESS_PANEL, ATTR_INSTALLED, ATTR_IP_ADDRESS, ATTR_KERNEL_MODULES, @@ -82,6 +82,7 @@ from ..const import ( CONTENT_TYPE_PNG, CONTENT_TYPE_TEXT, REQUEST_FROM, + STATE_NONE, ) from ..coresys import CoreSysAttributes from ..exceptions import APIError @@ -113,7 +114,7 @@ SCHEMA_SECURITY = vol.Schema({ class APIAddons(CoreSysAttributes): """Handle RESTful API for add-on functions.""" - def _extract_addon(self, request: web.Request, check_installed: bool = True) -> Addon: + def _extract_addon(self, request: web.Request, check_installed: bool = True) -> AnyAddon: """Return addon, throw an exception it it doesn't exist.""" addon_slug = request.match_info.get('addon') @@ -134,13 +135,13 @@ class APIAddons(CoreSysAttributes): async def list(self, request: web.Request) -> Dict[str, Any]: """Return all add-ons or repositories.""" data_addons = [] - for addon in self.sys_addons.list_addons: + for addon in self.sys_addons.all: data_addons.append({ ATTR_NAME: addon.name, ATTR_SLUG: addon.slug, ATTR_DESCRIPTON: addon.description, ATTR_VERSION: addon.latest_version, - ATTR_INSTALLED: addon.version_installed, + ATTR_INSTALLED: addon.version if addon.is_installed else None, ATTR_AVAILABLE: addon.available, ATTR_DETACHED: addon.is_detached, ATTR_REPOSITORY: addon.repository, @@ -151,7 +152,7 @@ class APIAddons(CoreSysAttributes): }) data_repositories = [] - for repository in self.sys_addons.list_repositories: + for repository in self.sys_store.all: data_repositories.append({ ATTR_SLUG: repository.slug, ATTR_NAME: repository.name, @@ -167,24 +168,23 @@ class APIAddons(CoreSysAttributes): @api_process async def reload(self, request: web.Request) -> None: - """Reload all add-on data.""" - await asyncio.shield(self.sys_addons.reload()) + """Reload all add-on data from store.""" + await asyncio.shield(self.sys_store.reload()) @api_process async def info(self, request: web.Request) -> Dict[str, Any]: """Return add-on information.""" addon = self._extract_addon(request, check_installed=False) - return { + data = { ATTR_NAME: addon.name, ATTR_SLUG: addon.slug, ATTR_DESCRIPTON: addon.description, ATTR_LONG_DESCRIPTION: addon.long_description, - ATTR_VERSION: addon.version_installed, - ATTR_AUTO_UPDATE: addon.auto_update, + ATTR_AUTO_UPDATE: None, ATTR_REPOSITORY: addon.repository, + ATTR_VERSION: None, ATTR_LAST_VERSION: addon.latest_version, - ATTR_STATE: await addon.state(), ATTR_PROTECTED: addon.protected, ATTR_RATING: rating_security(addon), ATTR_BOOT: addon.boot, @@ -193,6 +193,7 @@ class APIAddons(CoreSysAttributes): ATTR_MACHINE: addon.supported_machine, ATTR_HOMEASSISTANT: addon.homeassistant_version, ATTR_URL: addon.url, + ATTR_STATE: STATE_NONE, ATTR_DETACHED: addon.is_detached, ATTR_AVAILABLE: addon.available, ATTR_BUILD: addon.need_build, @@ -209,8 +210,8 @@ class APIAddons(CoreSysAttributes): ATTR_ICON: addon.with_icon, ATTR_LOGO: addon.with_logo, ATTR_CHANGELOG: addon.with_changelog, - ATTR_WEBUI: addon.webui, ATTR_STDIN: addon.with_stdin, + ATTR_WEBUI: None, ATTR_HASSIO_API: addon.access_hassio_api, ATTR_HASSIO_ROLE: addon.hassio_role, ATTR_AUTH_API: addon.access_auth_api, @@ -220,18 +221,35 @@ class APIAddons(CoreSysAttributes): ATTR_DEVICETREE: addon.with_devicetree, ATTR_DOCKER_API: addon.access_docker_api, ATTR_AUDIO: addon.with_audio, - ATTR_AUDIO_INPUT: addon.audio_input, - ATTR_AUDIO_OUTPUT: addon.audio_output, + ATTR_AUDIO_INPUT: None, + ATTR_AUDIO_OUTPUT: None, ATTR_SERVICES: _pretty_services(addon), ATTR_DISCOVERY: addon.discovery, - ATTR_IP_ADDRESS: str(addon.ip_address), + ATTR_IP_ADDRESS: None, ATTR_INGRESS: addon.with_ingress, - ATTR_INGRESS_ENTRY: addon.ingress_entry, - ATTR_INGRESS_URL: addon.ingress_url, - ATTR_INGRESS_PORT: addon.ingress_port, - ATTR_INGRESS_PANEL: addon.ingress_panel, + ATTR_INGRESS_ENTRY: None, + ATTR_INGRESS_URL: None, + ATTR_INGRESS_PORT: None, + ATTR_INGRESS_PANEL: None, } + if addon.is_installed: + data.update({ + ATTR_STATE: await addon.state(), + ATTR_WEBUI: addon.webui, + ATTR_INGRESS_ENTRY: addon.ingress_entry, + ATTR_INGRESS_URL: addon.ingress_url, + ATTR_INGRESS_PORT: addon.ingress_port, + ATTR_INGRESS_PANEL: addon.ingress_panel, + ATTR_AUDIO_INPUT: addon.audio_input, + ATTR_AUDIO_OUTPUT: addon.audio_output, + ATTR_AUTO_UPDATE: addon.auto_update, + ATTR_IP_ADDRESS: str(addon.ip_address), + ATTR_VERSION: addon.version, + }) + + return data + @api_process async def options(self, request: web.Request) -> None: """Store user options for add-on.""" @@ -258,7 +276,7 @@ class APIAddons(CoreSysAttributes): addon.ingress_panel = body[ATTR_INGRESS_PANEL] await self.sys_ingress.update_hass_panel(addon) - addon.save_data() + addon.save_persist() @api_process async def security(self, request: web.Request) -> None: @@ -325,7 +343,7 @@ class APIAddons(CoreSysAttributes): """Update add-on.""" addon = self._extract_addon(request) - if addon.latest_version == addon.version_installed: + if addon.latest_version == addon.version: raise APIError("No update available!") return asyncio.shield(addon.update()) @@ -392,7 +410,7 @@ class APIAddons(CoreSysAttributes): await asyncio.shield(addon.write_stdin(data)) -def _pretty_devices(addon: Addon) -> List[str]: +def _pretty_devices(addon: AnyAddon) -> List[str]: """Return a simplified device list.""" dev_list = addon.devices if not dev_list: @@ -400,7 +418,7 @@ def _pretty_devices(addon: Addon) -> List[str]: return [row.split(':')[0] for row in dev_list] -def _pretty_services(addon: Addon) -> List[str]: +def _pretty_services(addon: AnyAddon) -> List[str]: """Return a simplified services role list.""" services = [] for name, access in addon.services_role.items(): diff --git a/hassio/api/supervisor.py b/hassio/api/supervisor.py index ec883a315..46b4cb3fb 100644 --- a/hassio/api/supervisor.py +++ b/hassio/api/supervisor.py @@ -73,21 +73,20 @@ class APISupervisor(CoreSysAttributes): async def info(self, request: web.Request) -> Dict[str, Any]: """Return host information.""" list_addons = [] - for addon in self.sys_addons.list_addons: - if addon.is_installed: - list_addons.append( - { - ATTR_NAME: addon.name, - ATTR_SLUG: addon.slug, - ATTR_DESCRIPTON: addon.description, - ATTR_STATE: await addon.state(), - ATTR_VERSION: addon.latest_version, - ATTR_INSTALLED: addon.version_installed, - ATTR_REPOSITORY: addon.repository, - ATTR_ICON: addon.with_icon, - ATTR_LOGO: addon.with_logo, - } - ) + for addon in self.sys_addons.installed: + list_addons.append( + { + ATTR_NAME: addon.name, + ATTR_SLUG: addon.slug, + ATTR_DESCRIPTON: addon.description, + ATTR_STATE: await addon.state(), + ATTR_VERSION: addon.latest_version, + ATTR_INSTALLED: addon.version, + ATTR_REPOSITORY: addon.repository, + ATTR_ICON: addon.with_icon, + ATTR_LOGO: addon.with_logo, + } + ) return { ATTR_VERSION: HASSIO_VERSION, @@ -127,7 +126,7 @@ class APISupervisor(CoreSysAttributes): if ATTR_ADDONS_REPOSITORIES in body: new = set(body[ATTR_ADDONS_REPOSITORIES]) - await asyncio.shield(self.sys_addons.load_repositories(new)) + await asyncio.shield(self.sys_store.update_repositories(new)) self.sys_updater.save_data() self.sys_config.save_data() diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index 726ce5484..99f9422d8 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -23,6 +23,7 @@ from .ingress import Ingress from .services import ServiceManager from .snapshots import SnapshotManager from .supervisor import Supervisor +from .store import StoreManager from .tasks import Tasks from .updater import Updater @@ -53,6 +54,7 @@ async def initialize_coresys(): coresys.ingress = Ingress(coresys) coresys.tasks = Tasks(coresys) coresys.services = ServiceManager(coresys) + coresys.store = StoreManager(coresys) coresys.discovery = Discovery(coresys) coresys.dbus = DBusManager(coresys) coresys.hassos = HassOS(coresys) diff --git a/hassio/core.py b/hassio/core.py index 6e3e18936..3a3a38911 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -44,6 +44,9 @@ class HassIO(CoreSysAttributes): # Load HassOS await self.sys_hassos.load() + # Load Stores + await self.sys_store.load() + # Load Add-ons await self.sys_addons.load() diff --git a/hassio/coresys.py b/hassio/coresys.py index 0512ea5d3..73da44f66 100644 --- a/hassio/coresys.py +++ b/hassio/coresys.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from .services import ServiceManager from .snapshots import SnapshotManager from .supervisor import Supervisor + from .store import StoreManager from .tasks import Tasks from .updater import Updater @@ -68,6 +69,7 @@ class CoreSys: self._dbus: DBusManager = None self._hassos: HassOS = None self._services: ServiceManager = None + self._store: StoreManager = None self._discovery: Discovery = None @property @@ -223,6 +225,18 @@ class CoreSys: raise RuntimeError("AddonManager already set!") self._addons = value + @property + def store(self) -> StoreManager: + """Return StoreManager object.""" + return self._store + + @store.setter + def store(self, value: StoreManager): + """Set a StoreManager object.""" + if self._store: + raise RuntimeError("StoreManager already set!") + self._store = value + @property def snapshots(self) -> SnapshotManager: """Return SnapshotManager object.""" @@ -425,6 +439,11 @@ class CoreSysAttributes: """Return AddonManager object.""" return self.coresys.addons + @property + def sys_store(self) -> StoreManager: + """Return StoreManager object.""" + return self.coresys.store + @property def sys_snapshots(self) -> SnapshotManager: """Return SnapshotManager object.""" diff --git a/hassio/docker/addon.py b/hassio/docker/addon.py index fbda3ce60..4aa74d1ac 100644 --- a/hassio/docker/addon.py +++ b/hassio/docker/addon.py @@ -31,6 +31,7 @@ from .interface import DockerInterface if TYPE_CHECKING: from ..addons.addon import Addon + _LOGGER = logging.getLogger(__name__) AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm" @@ -39,15 +40,10 @@ AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm" class DockerAddon(DockerInterface): """Docker Hass.io wrapper for Home Assistant.""" - def __init__(self, coresys: CoreSys, slug: str): + def __init__(self, coresys: CoreSys, addon: Addon): """Initialize Docker Home Assistant wrapper.""" super().__init__(coresys) - self._id: str = slug - - @property - def addon(self) -> Addon: - """Return add-on of Docker image.""" - return self.sys_addons.get(self._id) + self.addon = addon @property def image(self) -> str: @@ -76,7 +72,7 @@ class DockerAddon(DockerInterface): def version(self) -> str: """Return version of Docker image.""" if self.addon.legacy: - return self.addon.version_installed + return self.addon.version return super().version @property @@ -363,7 +359,7 @@ class DockerAddon(DockerInterface): Need run inside executor. """ - build_env = AddonBuild(self.coresys, self._id) + build_env = AddonBuild(self.coresys, self.addon) _LOGGER.info("Start build %s:%s", self.image, tag) try: diff --git a/hassio/ingress.py b/hassio/ingress.py index ef44f4b6f..2ca97943f 100644 --- a/hassio/ingress.py +++ b/hassio/ingress.py @@ -44,7 +44,7 @@ class Ingress(JsonConfig, CoreSysAttributes): def addons(self) -> List[Addon]: """Return list of ingress Add-ons.""" addons = [] - for addon in self.sys_addons.list_installed: + for addon in self.sys_addons.installed: if not addon.with_ingress: continue addons.append(addon) diff --git a/hassio/services/interface.py b/hassio/services/interface.py index 976f640a1..c138ceaa6 100644 --- a/hassio/services/interface.py +++ b/hassio/services/interface.py @@ -34,7 +34,7 @@ class ServiceInterface(CoreSysAttributes): def providers(self) -> List[str]: """Return name of service providers addon.""" addons = [] - for addon in self.sys_addons.list_installed: + for addon in self.sys_addons.installed: if addon.services_role.get(self.slug) == PROVIDE_SERVICE: addons.append(addon.slug) return addons diff --git a/hassio/snapshots/__init__.py b/hassio/snapshots/__init__.py index 8440e2164..10ad40385 100644 --- a/hassio/snapshots/__init__.py +++ b/hassio/snapshots/__init__.py @@ -309,18 +309,9 @@ class SnapshotManager(CoreSysAttributes): self.sys_homeassistant.update( snapshot.homeassistant_version)) - # Process Add-ons - addon_list = [] - for slug in addons: - addon = self.sys_addons.get(slug) - if addon: - addon_list.append(addon) - continue - _LOGGER.warning("Can't restore addon %s", snapshot.slug) - - if addon_list: + if addons: _LOGGER.info("Restore %s old add-ons", snapshot.slug) - await snapshot.restore_addons(addon_list) + await snapshot.restore_addons(addons) # Make sure homeassistant run agen if task_hass: diff --git a/hassio/snapshots/snapshot.py b/hassio/snapshots/snapshot.py index 377358ba5..a65b73801 100644 --- a/hassio/snapshots/snapshot.py +++ b/hassio/snapshots/snapshot.py @@ -281,7 +281,7 @@ class Snapshot(CoreSysAttributes): async def store_addons(self, addon_list=None): """Add a list of add-ons into snapshot.""" - addon_list = addon_list or self.sys_addons.list_installed + addon_list = addon_list or self.sys_addons.installed async def _addon_save(addon): """Task to store an add-on into snapshot.""" @@ -300,7 +300,7 @@ class Snapshot(CoreSysAttributes): self._data[ATTR_ADDONS].append({ ATTR_SLUG: addon.slug, ATTR_NAME: addon.name, - ATTR_VERSION: addon.version_installed, + ATTR_VERSION: addon.version, ATTR_SIZE: addon_file.size, }) @@ -311,32 +311,26 @@ class Snapshot(CoreSysAttributes): async def restore_addons(self, addon_list=None): """Restore a list add-on from snapshot.""" - if not addon_list: - addon_list = [] - for addon_slug in self.addon_list: - addon = self.sys_addons.get(addon_slug) - if addon: - addon_list.append(addon) + addon_list = addon_list or self.addon_list - async def _addon_restore(addon): + async def _addon_restore(addon_slug): """Task to restore an add-on into snapshot.""" addon_file = SecureTarFile( - Path(self._tmp.name, f"{addon.slug}.tar.gz"), - 'r', key=self._key) + Path(self._tmp.name, f"{addon_slug}.tar.gz"), 'r', key=self._key) # If exists inside snapshot if not addon_file.path.exists(): - _LOGGER.error("Can't find snapshot for %s", addon.slug) + _LOGGER.error("Can't find snapshot for %s", addon_slug) return # Perform a restore try: - await addon.restore(addon_file) + await self.sys_addons.restore(addon_slug, addon_file) except AddonsError: - _LOGGER.error("Can't restore snapshot for %s", addon.slug) + _LOGGER.error("Can't restore snapshot for %s", addon_slug) # Run tasks - tasks = [_addon_restore(addon) for addon in addon_list] + tasks = [_addon_restore(slug) for slug in addon_list] if tasks: await asyncio.wait(tasks) @@ -459,4 +453,4 @@ class Snapshot(CoreSysAttributes): Return a coroutine. """ - return self.sys_addons.load_repositories(self.repositories) + return self.sys_store.update_repositories(self.repositories) diff --git a/hassio/store/__init__.py b/hassio/store/__init__.py new file mode 100644 index 000000000..64fd54f92 --- /dev/null +++ b/hassio/store/__init__.py @@ -0,0 +1,101 @@ +"""Add-on Store handler.""" +import asyncio +import logging +from typing import Dict, List + +from ..coresys import CoreSys, CoreSysAttributes +from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL +from .addon import AddonStore +from .data import StoreData +from .repository import Repository + +_LOGGER = logging.getLogger(__name__) + +BUILTIN_REPOSITORIES = set((REPOSITORY_CORE, REPOSITORY_LOCAL)) + + +class StoreManager(CoreSysAttributes): + """Manage add-ons inside Hass.io.""" + + def __init__(self, coresys: CoreSys): + """Initialize Docker base wrapper.""" + self.coresys: CoreSys = coresys + self.data = StoreData(coresys) + self.repositories: Dict[str, Repository] = {} + + @property + def all(self) -> List[Repository]: + """Return list of add-on repositories.""" + return list(self.repositories.values()) + + async def load(self) -> None: + """Start up add-on management.""" + self.data.update() + + # Init Hass.io built-in repositories + repositories = \ + set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES + + # Init custom repositories and load add-ons + await self.update_repositories(repositories) + + async def reload(self) -> None: + """Update add-ons from repository and reload list.""" + tasks = [repository.update() for repository in + self.repositories.values()] + if tasks: + await asyncio.wait(tasks) + + # read data from repositories + self.data.update() + self._read_addons() + + async def update_repositories(self, list_repositories): + """Add a new custom repository.""" + new_rep = set(list_repositories) + old_rep = set(self.repositories) + + # add new repository + async def _add_repository(url): + """Helper function to async add repository.""" + repository = Repository(self.coresys, url) + if not await repository.load(): + _LOGGER.error("Can't load from repository %s", url) + return + self.repositories[url] = repository + + # don't add built-in repository to config + if url not in BUILTIN_REPOSITORIES: + self.sys_config.add_addon_repository(url) + + tasks = [_add_repository(url) for url in new_rep - old_rep] + if tasks: + await asyncio.wait(tasks) + + # del new repository + for url in old_rep - new_rep - BUILTIN_REPOSITORIES: + self.repositories.pop(url).remove() + self.sys_config.drop_addon_repository(url) + + # update data + self.data.update() + self._read_addons() + + def _read_addons(self) -> None: + """Reload add-ons inside store.""" + all_addons = set(self.data.addons) + + # calc diff + add_addons = all_addons - set(self.sys_addons.store) + del_addons = set(self.sys_addons.store) - all_addons + + _LOGGER.info("Load add-ons from store: %d all - %d new - %d remove", + len(all_addons), len(add_addons), len(del_addons)) + + # new addons + for slug in add_addons: + self.sys_addons.store[slug] = AddonStore(self.coresys, slug) + + # remove + for slug in del_addons: + self.sys_addons.store.pop(slug) diff --git a/hassio/store/addon.py b/hassio/store/addon.py new file mode 100644 index 000000000..edf5335dd --- /dev/null +++ b/hassio/store/addon.py @@ -0,0 +1,31 @@ +"""Init file for Hass.io add-ons.""" +import logging + +from ..coresys import CoreSys +from ..addons.model import AddonModel, Data + +_LOGGER = logging.getLogger(__name__) + + +class AddonStore(AddonModel): + """Hold data for add-on inside Hass.io.""" + + def __init__(self, coresys: CoreSys, slug: str): + """Initialize data holder.""" + self.coresys: CoreSys = coresys + self.slug: str = slug + + @property + def data(self) -> Data: + """Return add-on data/config.""" + return self.sys_store.data.addons[self.slug] + + @property + def is_installed(self) -> bool: + """Return True if an add-on is installed.""" + return False + + @property + def is_detached(self) -> bool: + """Return True if add-on is detached.""" + return False diff --git a/hassio/addons/built-in.json b/hassio/store/built-in.json similarity index 100% rename from hassio/addons/built-in.json rename to hassio/store/built-in.json diff --git a/hassio/store/data.py b/hassio/store/data.py new file mode 100644 index 000000000..e3f45bd62 --- /dev/null +++ b/hassio/store/data.py @@ -0,0 +1,114 @@ +"""Init file for Hass.io add-on data.""" +import logging +from pathlib import Path +from typing import Any, Dict + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from ..addons.validate import SCHEMA_ADDON_CONFIG +from ..const import ( + ATTR_LOCATON, + ATTR_REPOSITORY, + ATTR_SLUG, + REPOSITORY_CORE, + REPOSITORY_LOCAL, +) +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import JsonFileError +from ..utils.json import read_json_file +from .utils import extract_hash_from_path +from .validate import SCHEMA_REPOSITORY_CONFIG + +_LOGGER = logging.getLogger(__name__) + + +class StoreData(CoreSysAttributes): + """Hold data for Add-ons inside Hass.io.""" + + def __init__(self, coresys: CoreSys): + """Initialize data holder.""" + self.coresys: CoreSys = coresys + self.repositories: Dict[str, Any] = {} + self.addons: Dict[str, Any] = {} + + def update(self): + """Read data from add-on repository.""" + self.repositories.clear() + self.addons.clear() + + # read core repository + self._read_addons_folder(self.sys_config.path_addons_core, REPOSITORY_CORE) + + # read local repository + self._read_addons_folder(self.sys_config.path_addons_local, REPOSITORY_LOCAL) + + # add built-in repositories information + self._set_builtin_repositories() + + # read custom git repositories + for repository_element in self.sys_config.path_addons_git.iterdir(): + if repository_element.is_dir(): + self._read_git_repository(repository_element) + + def _read_git_repository(self, path): + """Process a custom repository folder.""" + slug = extract_hash_from_path(path) + + # exists repository json + repository_file = Path(path, "repository.json") + try: + repository_info = SCHEMA_REPOSITORY_CONFIG(read_json_file(repository_file)) + except JsonFileError: + _LOGGER.warning( + "Can't read repository information from %s", repository_file + ) + return + except vol.Invalid: + _LOGGER.warning("Repository parse error %s", repository_file) + return + + # process data + self.repositories[slug] = repository_info + self._read_addons_folder(path, slug) + + def _read_addons_folder(self, path, repository): + """Read data from add-ons folder.""" + for addon in path.glob("**/config.json"): + try: + addon_config = read_json_file(addon) + except JsonFileError: + _LOGGER.warning("Can't read %s from repository %s", addon, repository) + continue + + # validate + try: + addon_config = SCHEMA_ADDON_CONFIG(addon_config) + except vol.Invalid as ex: + _LOGGER.warning( + "Can't read %s: %s", addon, humanize_error(addon_config, ex) + ) + continue + + # Generate slug + addon_slug = "{}_{}".format(repository, addon_config[ATTR_SLUG]) + + # store + addon_config[ATTR_REPOSITORY] = repository + addon_config[ATTR_LOCATON] = str(addon.parent) + self.addons[addon_slug] = addon_config + + def _set_builtin_repositories(self): + """Add local built-in repository into dataset.""" + try: + builtin_file = Path(__file__).parent.joinpath("built-in.json") + builtin_data = read_json_file(builtin_file) + except JsonFileError: + _LOGGER.warning("Can't read built-in json") + return + + # core repository + self.repositories[REPOSITORY_CORE] = builtin_data[REPOSITORY_CORE] + + # local repository + self.repositories[REPOSITORY_LOCAL] = builtin_data[REPOSITORY_LOCAL] diff --git a/hassio/addons/git.py b/hassio/store/git.py similarity index 97% rename from hassio/addons/git.py rename to hassio/store/git.py index 4fb16dc51..bb97ff3cd 100644 --- a/hassio/addons/git.py +++ b/hassio/store/git.py @@ -25,17 +25,17 @@ class GitRepo(CoreSysAttributes): self.path = path self.lock = asyncio.Lock(loop=coresys.loop) - self._data = RE_REPOSITORY.match(url).groupdict() + self.data = RE_REPOSITORY.match(url).groupdict() @property def url(self): """Return repository URL.""" - return self._data[ATTR_URL] + return self.data[ATTR_URL] @property def branch(self): """Return repository branch.""" - return self._data[ATTR_BRANCH] + return self.data[ATTR_BRANCH] async def load(self): """Init Git add-on repository.""" diff --git a/hassio/addons/repository.py b/hassio/store/repository.py similarity index 74% rename from hassio/addons/repository.py rename to hassio/store/repository.py index 89273c48a..cb0ef80ca 100644 --- a/hassio/addons/repository.py +++ b/hassio/store/repository.py @@ -12,6 +12,8 @@ UNKNOWN = 'unknown' class Repository(CoreSysAttributes): """Repository in Hass.io.""" + slug: str = None + def __init__(self, coresys, repository): """Initialize repository object.""" self.coresys = coresys @@ -19,39 +21,34 @@ class Repository(CoreSysAttributes): self.git = None if repository == REPOSITORY_LOCAL: - self._id = repository + self.slug = repository elif repository == REPOSITORY_CORE: - self._id = repository + self.slug = repository self.git = GitRepoHassIO(coresys) else: - self._id = get_hash_from_repository(repository) + self.slug = get_hash_from_repository(repository) self.git = GitRepoCustom(coresys, repository) self.source = repository @property - def _mesh(self): + def data(self): """Return data struct repository.""" - return self.sys_addons.data.repositories.get(self._id, {}) - - @property - def slug(self): - """Return slug of repository.""" - return self._id + return self.sys_store.data.repositories[self.slug] @property def name(self): """Return name of repository.""" - return self._mesh.get(ATTR_NAME, UNKNOWN) + return self.data.get(ATTR_NAME, UNKNOWN) @property def url(self): """Return URL of repository.""" - return self._mesh.get(ATTR_URL, self.source) + return self.data.get(ATTR_URL, self.source) @property def maintainer(self): """Return url of repository.""" - return self._mesh.get(ATTR_MAINTAINER, UNKNOWN) + return self.data.get(ATTR_MAINTAINER, UNKNOWN) async def load(self): """Load addon repository.""" @@ -67,7 +64,7 @@ class Repository(CoreSysAttributes): def remove(self): """Remove add-on repository.""" - if self._id in (REPOSITORY_CORE, REPOSITORY_LOCAL): + if self.slug in (REPOSITORY_CORE, REPOSITORY_LOCAL): raise APIError("Can't remove built-in repositories!") self.git.remove() diff --git a/hassio/store/utils.py b/hassio/store/utils.py new file mode 100644 index 000000000..f0923f99e --- /dev/null +++ b/hassio/store/utils.py @@ -0,0 +1,23 @@ +"""Util add-ons functions.""" +import hashlib +import logging +from pathlib import Path +import re + +RE_SHA1 = re.compile(r"[a-f0-9]{8}") +_LOGGER = logging.getLogger(__name__) + + +def get_hash_from_repository(name: str) -> str: + """Generate a hash from repository.""" + key = name.lower().encode() + return hashlib.sha1(key).hexdigest()[:8] + + +def extract_hash_from_path(path: Path) -> str: + """Extract repo id from path.""" + repository_dir = path.parts[-1] + + if not RE_SHA1.match(repository_dir): + return get_hash_from_repository(repository_dir) + return repository_dir diff --git a/hassio/store/validate.py b/hassio/store/validate.py new file mode 100644 index 000000000..3f54e9c79 --- /dev/null +++ b/hassio/store/validate.py @@ -0,0 +1,13 @@ +"""Validate add-ons options schema.""" + +import voluptuous as vol + +from ..const import ATTR_NAME, ATTR_URL, ATTR_MAINTAINER + + +# pylint: disable=no-value-for-parameter +SCHEMA_REPOSITORY_CONFIG = vol.Schema({ + vol.Required(ATTR_NAME): vol.Coerce(str), + vol.Optional(ATTR_URL): vol.Url(), + vol.Optional(ATTR_MAINTAINER): vol.Coerce(str), +}, extra=vol.REMOVE_EXTRA) diff --git a/hassio/tasks.py b/hassio/tasks.py index b71a95d0e..58f330617 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -51,7 +51,7 @@ class Tasks(CoreSysAttributes): # Reload self.jobs.add( - self.sys_scheduler.register_task(self.sys_addons.reload, RUN_RELOAD_ADDONS) + self.sys_scheduler.register_task(self.sys_store.reload, RUN_RELOAD_ADDONS) ) self.jobs.add( self.sys_scheduler.register_task( @@ -93,7 +93,7 @@ class Tasks(CoreSysAttributes): if not addon.is_installed or not addon.auto_update: continue - if addon.version_installed == addon.latest_version: + if addon.version == addon.latest_version: continue if addon.test_update_schema():