WIP: Split add-on store logic (#1067)

* Split add-on store logic

* finish data model

* Cleanup models

* Cleanup imports

* split up store addons

* More cleanup

* Go to stable

* Fix layout

* Cleanup interface

* Fix restore/snapshot

* Fix algo

* Fix reload task

* Fix typing / remove indirect add-on references

* Fix version

* Fix repository data

* Fix addon repo

* Fix api check

* Fix API return

* Fix model

* Temp fix available

* Fix lint

* Fix install

* Fix partial restore

* Fix store restore

* Fix ingress port

* Fix API

* Fix style
This commit is contained in:
Pascal Vizeli 2019-05-07 17:27:00 +02:00 committed by GitHub
parent 4e94043bca
commit 9ce9e10dfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1290 additions and 1039 deletions

View File

@ -23,7 +23,6 @@ RUN export MAKEFLAGS="-j$(nproc)" \
# 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" ]

View File

@ -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:
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:
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

File diff suppressed because it is too large Load Diff

View File

@ -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:

View File

@ -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()

498
hassio/addons/model.py Normal file
View File

@ -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)

View File

@ -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:

View File

@ -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({

View File

@ -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,17 +221,34 @@ 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: 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:
@ -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():

View File

@ -73,8 +73,7 @@ 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:
for addon in self.sys_addons.installed:
list_addons.append(
{
ATTR_NAME: addon.name,
@ -82,7 +81,7 @@ class APISupervisor(CoreSysAttributes):
ATTR_DESCRIPTON: addon.description,
ATTR_STATE: await addon.state(),
ATTR_VERSION: addon.latest_version,
ATTR_INSTALLED: addon.version_installed,
ATTR_INSTALLED: addon.version,
ATTR_REPOSITORY: addon.repository,
ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo,
@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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."""

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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)

101
hassio/store/__init__.py Normal file
View File

@ -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)

31
hassio/store/addon.py Normal file
View File

@ -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

114
hassio/store/data.py Normal file
View File

@ -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]

View File

@ -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."""

View File

@ -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()

23
hassio/store/utils.py Normal file
View File

@ -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

13
hassio/store/validate.py Normal file
View File

@ -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)

View File

@ -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():