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

@ -18,12 +18,11 @@ RUN apk add --no-cache \
COPY requirements.txt /usr/src/ COPY requirements.txt /usr/src/
RUN export MAKEFLAGS="-j$(nproc)" \ RUN export MAKEFLAGS="-j$(nproc)" \
&& pip3 install --no-cache-dir --find-links https://wheels.hass.io/alpine-3.9/${BUILD_ARCH}/ \ && 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 && rm -f /usr/src/requirements.txt
# Install HassIO # Install HassIO
COPY . /usr/src/hassio COPY . /usr/src/hassio
RUN pip3 install --no-cache-dir /usr/src/hassio \ RUN pip3 install --no-cache-dir -e /usr/src/hassio
&& rm -rf /usr/src/hassio
CMD [ "python3", "-m", "hassio" ] CMD [ "python3", "-m", "hassio" ]

View File

@ -1,158 +1,251 @@
"""Init file for Hass.io add-ons.""" """Init file for Hass.io add-ons."""
import asyncio import asyncio
from contextlib import suppress
import logging 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 .addon import Addon
from .repository import Repository
from .data import AddonsData from .data import AddonsData
from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL, BOOT_AUTO, STATE_STARTED
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
BUILTIN_REPOSITORIES = set((REPOSITORY_CORE, REPOSITORY_LOCAL)) AnyAddon = Union[Addon, AddonStore]
class AddonManager(CoreSysAttributes): class AddonManager(CoreSysAttributes):
"""Manage add-ons inside Hass.io.""" """Manage add-ons inside Hass.io."""
def __init__(self, coresys): def __init__(self, coresys: CoreSys):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
self.coresys = coresys self.coresys: CoreSys = coresys
self.data = AddonsData(coresys) self.data: AddonsData = AddonsData(coresys)
self.addons_obj = {} self.local: Dict[str, Addon] = {}
self.repositories_obj = {} self.store: Dict[str, AddonStore] = {}
@property @property
def list_addons(self): def all(self) -> List[AnyAddon]:
"""Return a list of all add-ons.""" """Return a list of all add-ons."""
return list(self.addons_obj.values()) addons = {**self.store, **self.local}
return list(addons.values())
@property @property
def list_installed(self): def installed(self) -> List[Addon]:
"""Return a list of installed add-ons.""" """Return a list of all installed add-ons."""
return [addon for addon in self.addons_obj.values() return list(self.local.values())
if addon.is_installed]
@property def get(self, addon_slug: str) -> Optional[AnyAddon]:
def list_repositories(self): """Return an add-on from slug.
"""Return list of add-on repositories."""
return list(self.repositories_obj.values())
def get(self, addon_slug): Prio:
"""Return an add-on from slug.""" 1 - Local
return self.addons_obj.get(addon_slug) 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.""" """Return an add-on from Hass.io token."""
for addon in self.list_addons: for addon in self.installed:
if addon.is_installed and token == addon.hassio_token: if token == addon.hassio_token:
return addon return addon
return None return None
async def load(self): async def load(self) -> None:
"""Start up add-on management.""" """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 = [] tasks = []
for addon_slug in add_addons: for slug in self.data.system:
addon = Addon(self.coresys, addon_slug) addon = self.local[slug] = Addon(self.coresys, slug)
tasks.append(addon.load()) tasks.append(addon.load())
self.addons_obj[addon_slug] = addon
# Run initial tasks
_LOGGER.info("Found %d installed add-ons", len(tasks))
if tasks: if tasks:
await asyncio.wait(tasks) await asyncio.wait(tasks)
# remove async def boot(self, stage: str) -> None:
for addon_slug in del_addons:
self.addons_obj.pop(addon_slug)
async def boot(self, stage):
"""Boot add-ons with mode auto.""" """Boot add-ons with mode auto."""
tasks = [] tasks = []
for addon in self.addons_obj.values(): for addon in self.installed:
if addon.is_installed and addon.boot == BOOT_AUTO and \ if addon.boot != BOOT_AUTO or addon.startup != stage:
addon.startup == stage: continue
tasks.append(addon.start()) 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: if tasks:
await asyncio.wait(tasks) await asyncio.wait(tasks)
await asyncio.sleep(self.sys_config.wait_boot) await asyncio.sleep(self.sys_config.wait_boot)
async def shutdown(self, stage): async def shutdown(self, stage: str) -> None:
"""Shutdown addons.""" """Shutdown addons."""
tasks = [] tasks = []
for addon in self.addons_obj.values(): for addon in self.installed:
if addon.is_installed and \ if await addon.state() != STATE_STARTED or addon.startup != stage:
await addon.state() == STATE_STARTED and \ continue
addon.startup == stage: tasks.append(addon.stop())
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: if tasks:
await asyncio.wait(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 from .validate import SCHEMA_BUILD_CONFIG
if TYPE_CHECKING: if TYPE_CHECKING:
from .addon import Addon from . import AnyAddon
class AddonBuild(JsonConfig, CoreSysAttributes): class AddonBuild(JsonConfig, CoreSysAttributes):
"""Handle build options for add-ons.""" """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.""" """Initialize Hass.io add-on builder."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self._id: str = slug self.addon = addon
super().__init__( super().__init__(
Path(self.addon.path_location, 'build.json'), SCHEMA_BUILD_CONFIG) Path(self.addon.path_location, 'build.json'), SCHEMA_BUILD_CONFIG)
def save_data(self): def save_data(self):
"""Ignore save function.""" """Ignore save function."""
raise RuntimeError()
@property
def addon(self) -> Addon:
"""Return add-on of build data."""
return self.sys_addons.get(self._id)
@property @property
def base_image(self) -> str: def base_image(self) -> str:

View File

@ -1,38 +1,34 @@
"""Init file for Hass.io add-on data.""" """Init file for Hass.io add-on data."""
from copy import deepcopy
import logging import logging
from pathlib import Path from typing import Any, Dict
import voluptuous as vol
from voluptuous.humanize import humanize_error
from ..const import ( from ..const import (
ATTR_LOCATON, ATTR_IMAGE,
ATTR_REPOSITORY, ATTR_OPTIONS,
ATTR_SLUG,
ATTR_SYSTEM, ATTR_SYSTEM,
ATTR_USER, ATTR_USER,
ATTR_VERSION,
FILE_HASSIO_ADDONS, FILE_HASSIO_ADDONS,
REPOSITORY_CORE,
REPOSITORY_LOCAL,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import JsonFileError from ..utils.json import JsonConfig
from ..utils.json import JsonConfig, read_json_file from ..store.addon import AddonStore
from .utils import extract_hash_from_path from .addon import Addon
from .validate import SCHEMA_ADDON_CONFIG, SCHEMA_ADDONS_FILE, SCHEMA_REPOSITORY_CONFIG from .validate import SCHEMA_ADDONS_FILE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
Config = Dict[str, Any]
class AddonsData(JsonConfig, CoreSysAttributes): 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.""" """Initialize data holder."""
super().__init__(FILE_HASSIO_ADDONS, SCHEMA_ADDONS_FILE) super().__init__(FILE_HASSIO_ADDONS, SCHEMA_ADDONS_FILE)
self.coresys = coresys self.coresys: CoreSys = coresys
self._repositories = {}
self._cache = {}
@property @property
def user(self): def user(self):
@ -44,93 +40,35 @@ class AddonsData(JsonConfig, CoreSysAttributes):
"""Return local add-on data.""" """Return local add-on data."""
return self._data[ATTR_SYSTEM] return self._data[ATTR_SYSTEM]
@property def install(self, addon: AddonStore) -> None:
def cache(self): """Set addon as installed."""
"""Return add-on data from cache/repositories.""" self.system[addon.slug] = deepcopy(addon.data)
return self._cache self.user[addon.slug] = {
ATTR_OPTIONS: {},
ATTR_VERSION: addon.version,
ATTR_IMAGE: addon.image,
}
self.save_data()
@property def uninstall(self, addon: Addon) -> None:
def repositories(self): """Set add-on as uninstalled."""
"""Return add-on data from repositories.""" self.system.pop(addon.slug, None)
return self._repositories self.user.pop(addon.slug, None)
self.save_data()
def reload(self): def update(self, addon: AddonStore) -> None:
"""Read data from add-on repository.""" """Update version of add-on."""
self._cache = {} self.system[addon.slug] = deepcopy(addon.data)
self._repositories = {} self.user[addon.slug].update({
ATTR_VERSION: addon.version,
ATTR_IMAGE: addon.image,
})
self.save_data()
# read core repository def restore(self, slug: str, user: Config, system: Config, image: str) -> None:
self._read_addons_folder(self.sys_config.path_addons_core, REPOSITORY_CORE) """Restore data to add-on."""
self.user[slug] = deepcopy(user)
self.system[slug] = deepcopy(system)
# read local repository self.user[slug][ATTR_IMAGE] = image
self._read_addons_folder(self.sys_config.path_addons_local, REPOSITORY_LOCAL) self.save_data()
# 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]

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 from __future__ import annotations
import asyncio import asyncio
import hashlib
import logging import logging
from pathlib import Path from pathlib import Path
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..const import ( from ..const import (
@ -20,16 +18,14 @@ from ..const import (
SECURITY_DISABLE, SECURITY_DISABLE,
SECURITY_PROFILE, SECURITY_PROFILE,
) )
from ..exceptions import AddonsNotSupportedError
if TYPE_CHECKING: if TYPE_CHECKING:
from .addon import Addon from .model import AddonModel
RE_SHA1 = re.compile(r"[a-f0-9]{8}")
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def rating_security(addon: Addon) -> int: def rating_security(addon: AddonModel) -> int:
"""Return 1-6 for security rating. """Return 1-6 for security rating.
1 = not secure 1 = not secure
@ -86,34 +82,6 @@ def rating_security(addon: Addon) -> int:
return max(min(6, rating), 1) 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: async def remove_data(folder: Path) -> None:
"""Remove folder and reset privileged.""" """Remove folder and reset privileged."""
try: try:

View File

@ -49,7 +49,6 @@ from ..const import (
ATTR_LEGACY, ATTR_LEGACY,
ATTR_LOCATON, ATTR_LOCATON,
ATTR_MACHINE, ATTR_MACHINE,
ATTR_MAINTAINER,
ATTR_MAP, ATTR_MAP,
ATTR_NAME, ATTR_NAME,
ATTR_NETWORK, ATTR_NETWORK,
@ -211,14 +210,6 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
}, extra=vol.REMOVE_EXTRA) }, 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 # pylint: disable=no-value-for-parameter
SCHEMA_BUILD_CONFIG = vol.Schema({ SCHEMA_BUILD_CONFIG = vol.Schema({
vol.Optional(ATTR_BUILD_FROM, default=dict): 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 import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
from ..addons.addon import Addon from ..addons import AnyAddon
from ..addons.utils import rating_security from ..addons.utils import rating_security
from ..const import ( from ..const import (
ATTR_ADDONS, ATTR_ADDONS,
@ -44,9 +44,9 @@ from ..const import (
ATTR_ICON, ATTR_ICON,
ATTR_INGRESS, ATTR_INGRESS,
ATTR_INGRESS_ENTRY, ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PANEL,
ATTR_INGRESS_PORT, ATTR_INGRESS_PORT,
ATTR_INGRESS_URL, ATTR_INGRESS_URL,
ATTR_INGRESS_PANEL,
ATTR_INSTALLED, ATTR_INSTALLED,
ATTR_IP_ADDRESS, ATTR_IP_ADDRESS,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
@ -82,6 +82,7 @@ from ..const import (
CONTENT_TYPE_PNG, CONTENT_TYPE_PNG,
CONTENT_TYPE_TEXT, CONTENT_TYPE_TEXT,
REQUEST_FROM, REQUEST_FROM,
STATE_NONE,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import APIError from ..exceptions import APIError
@ -113,7 +114,7 @@ SCHEMA_SECURITY = vol.Schema({
class APIAddons(CoreSysAttributes): class APIAddons(CoreSysAttributes):
"""Handle RESTful API for add-on functions.""" """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.""" """Return addon, throw an exception it it doesn't exist."""
addon_slug = request.match_info.get('addon') addon_slug = request.match_info.get('addon')
@ -134,13 +135,13 @@ class APIAddons(CoreSysAttributes):
async def list(self, request: web.Request) -> Dict[str, Any]: async def list(self, request: web.Request) -> Dict[str, Any]:
"""Return all add-ons or repositories.""" """Return all add-ons or repositories."""
data_addons = [] data_addons = []
for addon in self.sys_addons.list_addons: for addon in self.sys_addons.all:
data_addons.append({ data_addons.append({
ATTR_NAME: addon.name, ATTR_NAME: addon.name,
ATTR_SLUG: addon.slug, ATTR_SLUG: addon.slug,
ATTR_DESCRIPTON: addon.description, ATTR_DESCRIPTON: addon.description,
ATTR_VERSION: addon.latest_version, 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_AVAILABLE: addon.available,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_REPOSITORY: addon.repository, ATTR_REPOSITORY: addon.repository,
@ -151,7 +152,7 @@ class APIAddons(CoreSysAttributes):
}) })
data_repositories = [] data_repositories = []
for repository in self.sys_addons.list_repositories: for repository in self.sys_store.all:
data_repositories.append({ data_repositories.append({
ATTR_SLUG: repository.slug, ATTR_SLUG: repository.slug,
ATTR_NAME: repository.name, ATTR_NAME: repository.name,
@ -167,24 +168,23 @@ class APIAddons(CoreSysAttributes):
@api_process @api_process
async def reload(self, request: web.Request) -> None: async def reload(self, request: web.Request) -> None:
"""Reload all add-on data.""" """Reload all add-on data from store."""
await asyncio.shield(self.sys_addons.reload()) await asyncio.shield(self.sys_store.reload())
@api_process @api_process
async def info(self, request: web.Request) -> Dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return add-on information.""" """Return add-on information."""
addon = self._extract_addon(request, check_installed=False) addon = self._extract_addon(request, check_installed=False)
return { data = {
ATTR_NAME: addon.name, ATTR_NAME: addon.name,
ATTR_SLUG: addon.slug, ATTR_SLUG: addon.slug,
ATTR_DESCRIPTON: addon.description, ATTR_DESCRIPTON: addon.description,
ATTR_LONG_DESCRIPTION: addon.long_description, ATTR_LONG_DESCRIPTION: addon.long_description,
ATTR_VERSION: addon.version_installed, ATTR_AUTO_UPDATE: None,
ATTR_AUTO_UPDATE: addon.auto_update,
ATTR_REPOSITORY: addon.repository, ATTR_REPOSITORY: addon.repository,
ATTR_VERSION: None,
ATTR_LAST_VERSION: addon.latest_version, ATTR_LAST_VERSION: addon.latest_version,
ATTR_STATE: await addon.state(),
ATTR_PROTECTED: addon.protected, ATTR_PROTECTED: addon.protected,
ATTR_RATING: rating_security(addon), ATTR_RATING: rating_security(addon),
ATTR_BOOT: addon.boot, ATTR_BOOT: addon.boot,
@ -193,6 +193,7 @@ class APIAddons(CoreSysAttributes):
ATTR_MACHINE: addon.supported_machine, ATTR_MACHINE: addon.supported_machine,
ATTR_HOMEASSISTANT: addon.homeassistant_version, ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_URL: addon.url, ATTR_URL: addon.url,
ATTR_STATE: STATE_NONE,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_AVAILABLE: addon.available, ATTR_AVAILABLE: addon.available,
ATTR_BUILD: addon.need_build, ATTR_BUILD: addon.need_build,
@ -209,8 +210,8 @@ class APIAddons(CoreSysAttributes):
ATTR_ICON: addon.with_icon, ATTR_ICON: addon.with_icon,
ATTR_LOGO: addon.with_logo, ATTR_LOGO: addon.with_logo,
ATTR_CHANGELOG: addon.with_changelog, ATTR_CHANGELOG: addon.with_changelog,
ATTR_WEBUI: addon.webui,
ATTR_STDIN: addon.with_stdin, ATTR_STDIN: addon.with_stdin,
ATTR_WEBUI: None,
ATTR_HASSIO_API: addon.access_hassio_api, ATTR_HASSIO_API: addon.access_hassio_api,
ATTR_HASSIO_ROLE: addon.hassio_role, ATTR_HASSIO_ROLE: addon.hassio_role,
ATTR_AUTH_API: addon.access_auth_api, ATTR_AUTH_API: addon.access_auth_api,
@ -220,18 +221,35 @@ class APIAddons(CoreSysAttributes):
ATTR_DEVICETREE: addon.with_devicetree, ATTR_DEVICETREE: addon.with_devicetree,
ATTR_DOCKER_API: addon.access_docker_api, ATTR_DOCKER_API: addon.access_docker_api,
ATTR_AUDIO: addon.with_audio, ATTR_AUDIO: addon.with_audio,
ATTR_AUDIO_INPUT: addon.audio_input, ATTR_AUDIO_INPUT: None,
ATTR_AUDIO_OUTPUT: addon.audio_output, ATTR_AUDIO_OUTPUT: None,
ATTR_SERVICES: _pretty_services(addon), ATTR_SERVICES: _pretty_services(addon),
ATTR_DISCOVERY: addon.discovery, ATTR_DISCOVERY: addon.discovery,
ATTR_IP_ADDRESS: str(addon.ip_address), ATTR_IP_ADDRESS: None,
ATTR_INGRESS: addon.with_ingress, ATTR_INGRESS: addon.with_ingress,
ATTR_INGRESS_ENTRY: addon.ingress_entry, ATTR_INGRESS_ENTRY: None,
ATTR_INGRESS_URL: addon.ingress_url, ATTR_INGRESS_URL: None,
ATTR_INGRESS_PORT: addon.ingress_port, ATTR_INGRESS_PORT: None,
ATTR_INGRESS_PANEL: addon.ingress_panel, 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 @api_process
async def options(self, request: web.Request) -> None: async def options(self, request: web.Request) -> None:
"""Store user options for add-on.""" """Store user options for add-on."""
@ -258,7 +276,7 @@ class APIAddons(CoreSysAttributes):
addon.ingress_panel = body[ATTR_INGRESS_PANEL] addon.ingress_panel = body[ATTR_INGRESS_PANEL]
await self.sys_ingress.update_hass_panel(addon) await self.sys_ingress.update_hass_panel(addon)
addon.save_data() addon.save_persist()
@api_process @api_process
async def security(self, request: web.Request) -> None: async def security(self, request: web.Request) -> None:
@ -325,7 +343,7 @@ class APIAddons(CoreSysAttributes):
"""Update add-on.""" """Update add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
if addon.latest_version == addon.version_installed: if addon.latest_version == addon.version:
raise APIError("No update available!") raise APIError("No update available!")
return asyncio.shield(addon.update()) return asyncio.shield(addon.update())
@ -392,7 +410,7 @@ class APIAddons(CoreSysAttributes):
await asyncio.shield(addon.write_stdin(data)) 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.""" """Return a simplified device list."""
dev_list = addon.devices dev_list = addon.devices
if not dev_list: if not dev_list:
@ -400,7 +418,7 @@ def _pretty_devices(addon: Addon) -> List[str]:
return [row.split(':')[0] for row in dev_list] 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.""" """Return a simplified services role list."""
services = [] services = []
for name, access in addon.services_role.items(): for name, access in addon.services_role.items():

View File

@ -73,21 +73,20 @@ class APISupervisor(CoreSysAttributes):
async def info(self, request: web.Request) -> Dict[str, Any]: async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return host information.""" """Return host information."""
list_addons = [] list_addons = []
for addon in self.sys_addons.list_addons: for addon in self.sys_addons.installed:
if addon.is_installed: list_addons.append(
list_addons.append( {
{ ATTR_NAME: addon.name,
ATTR_NAME: addon.name, ATTR_SLUG: addon.slug,
ATTR_SLUG: addon.slug, ATTR_DESCRIPTON: addon.description,
ATTR_DESCRIPTON: addon.description, ATTR_STATE: await addon.state(),
ATTR_STATE: await addon.state(), ATTR_VERSION: addon.latest_version,
ATTR_VERSION: addon.latest_version, ATTR_INSTALLED: addon.version,
ATTR_INSTALLED: addon.version_installed, ATTR_REPOSITORY: addon.repository,
ATTR_REPOSITORY: addon.repository, ATTR_ICON: addon.with_icon,
ATTR_ICON: addon.with_icon, ATTR_LOGO: addon.with_logo,
ATTR_LOGO: addon.with_logo, }
} )
)
return { return {
ATTR_VERSION: HASSIO_VERSION, ATTR_VERSION: HASSIO_VERSION,
@ -127,7 +126,7 @@ class APISupervisor(CoreSysAttributes):
if ATTR_ADDONS_REPOSITORIES in body: if ATTR_ADDONS_REPOSITORIES in body:
new = set(body[ATTR_ADDONS_REPOSITORIES]) 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_updater.save_data()
self.sys_config.save_data() self.sys_config.save_data()

View File

@ -23,6 +23,7 @@ from .ingress import Ingress
from .services import ServiceManager from .services import ServiceManager
from .snapshots import SnapshotManager from .snapshots import SnapshotManager
from .supervisor import Supervisor from .supervisor import Supervisor
from .store import StoreManager
from .tasks import Tasks from .tasks import Tasks
from .updater import Updater from .updater import Updater
@ -53,6 +54,7 @@ async def initialize_coresys():
coresys.ingress = Ingress(coresys) coresys.ingress = Ingress(coresys)
coresys.tasks = Tasks(coresys) coresys.tasks = Tasks(coresys)
coresys.services = ServiceManager(coresys) coresys.services = ServiceManager(coresys)
coresys.store = StoreManager(coresys)
coresys.discovery = Discovery(coresys) coresys.discovery = Discovery(coresys)
coresys.dbus = DBusManager(coresys) coresys.dbus = DBusManager(coresys)
coresys.hassos = HassOS(coresys) coresys.hassos = HassOS(coresys)

View File

@ -44,6 +44,9 @@ class HassIO(CoreSysAttributes):
# Load HassOS # Load HassOS
await self.sys_hassos.load() await self.sys_hassos.load()
# Load Stores
await self.sys_store.load()
# Load Add-ons # Load Add-ons
await self.sys_addons.load() await self.sys_addons.load()

View File

@ -27,6 +27,7 @@ if TYPE_CHECKING:
from .services import ServiceManager from .services import ServiceManager
from .snapshots import SnapshotManager from .snapshots import SnapshotManager
from .supervisor import Supervisor from .supervisor import Supervisor
from .store import StoreManager
from .tasks import Tasks from .tasks import Tasks
from .updater import Updater from .updater import Updater
@ -68,6 +69,7 @@ class CoreSys:
self._dbus: DBusManager = None self._dbus: DBusManager = None
self._hassos: HassOS = None self._hassos: HassOS = None
self._services: ServiceManager = None self._services: ServiceManager = None
self._store: StoreManager = None
self._discovery: Discovery = None self._discovery: Discovery = None
@property @property
@ -223,6 +225,18 @@ class CoreSys:
raise RuntimeError("AddonManager already set!") raise RuntimeError("AddonManager already set!")
self._addons = value 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 @property
def snapshots(self) -> SnapshotManager: def snapshots(self) -> SnapshotManager:
"""Return SnapshotManager object.""" """Return SnapshotManager object."""
@ -425,6 +439,11 @@ class CoreSysAttributes:
"""Return AddonManager object.""" """Return AddonManager object."""
return self.coresys.addons return self.coresys.addons
@property
def sys_store(self) -> StoreManager:
"""Return StoreManager object."""
return self.coresys.store
@property @property
def sys_snapshots(self) -> SnapshotManager: def sys_snapshots(self) -> SnapshotManager:
"""Return SnapshotManager object.""" """Return SnapshotManager object."""

View File

@ -31,6 +31,7 @@ from .interface import DockerInterface
if TYPE_CHECKING: if TYPE_CHECKING:
from ..addons.addon import Addon from ..addons.addon import Addon
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm" AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm"
@ -39,15 +40,10 @@ AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm"
class DockerAddon(DockerInterface): class DockerAddon(DockerInterface):
"""Docker Hass.io wrapper for Home Assistant.""" """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.""" """Initialize Docker Home Assistant wrapper."""
super().__init__(coresys) super().__init__(coresys)
self._id: str = slug self.addon = addon
@property
def addon(self) -> Addon:
"""Return add-on of Docker image."""
return self.sys_addons.get(self._id)
@property @property
def image(self) -> str: def image(self) -> str:
@ -76,7 +72,7 @@ class DockerAddon(DockerInterface):
def version(self) -> str: def version(self) -> str:
"""Return version of Docker image.""" """Return version of Docker image."""
if self.addon.legacy: if self.addon.legacy:
return self.addon.version_installed return self.addon.version
return super().version return super().version
@property @property
@ -363,7 +359,7 @@ class DockerAddon(DockerInterface):
Need run inside executor. 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) _LOGGER.info("Start build %s:%s", self.image, tag)
try: try:

View File

@ -44,7 +44,7 @@ class Ingress(JsonConfig, CoreSysAttributes):
def addons(self) -> List[Addon]: def addons(self) -> List[Addon]:
"""Return list of ingress Add-ons.""" """Return list of ingress Add-ons."""
addons = [] addons = []
for addon in self.sys_addons.list_installed: for addon in self.sys_addons.installed:
if not addon.with_ingress: if not addon.with_ingress:
continue continue
addons.append(addon) addons.append(addon)

View File

@ -34,7 +34,7 @@ class ServiceInterface(CoreSysAttributes):
def providers(self) -> List[str]: def providers(self) -> List[str]:
"""Return name of service providers addon.""" """Return name of service providers addon."""
addons = [] 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: if addon.services_role.get(self.slug) == PROVIDE_SERVICE:
addons.append(addon.slug) addons.append(addon.slug)
return addons return addons

View File

@ -309,18 +309,9 @@ class SnapshotManager(CoreSysAttributes):
self.sys_homeassistant.update( self.sys_homeassistant.update(
snapshot.homeassistant_version)) snapshot.homeassistant_version))
# Process Add-ons if addons:
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:
_LOGGER.info("Restore %s old add-ons", snapshot.slug) _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 # Make sure homeassistant run agen
if task_hass: if task_hass:

View File

@ -281,7 +281,7 @@ class Snapshot(CoreSysAttributes):
async def store_addons(self, addon_list=None): async def store_addons(self, addon_list=None):
"""Add a list of add-ons into snapshot.""" """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): async def _addon_save(addon):
"""Task to store an add-on into snapshot.""" """Task to store an add-on into snapshot."""
@ -300,7 +300,7 @@ class Snapshot(CoreSysAttributes):
self._data[ATTR_ADDONS].append({ self._data[ATTR_ADDONS].append({
ATTR_SLUG: addon.slug, ATTR_SLUG: addon.slug,
ATTR_NAME: addon.name, ATTR_NAME: addon.name,
ATTR_VERSION: addon.version_installed, ATTR_VERSION: addon.version,
ATTR_SIZE: addon_file.size, ATTR_SIZE: addon_file.size,
}) })
@ -311,32 +311,26 @@ class Snapshot(CoreSysAttributes):
async def restore_addons(self, addon_list=None): async def restore_addons(self, addon_list=None):
"""Restore a list add-on from snapshot.""" """Restore a list add-on from snapshot."""
if not addon_list: addon_list = addon_list or self.addon_list
addon_list = []
for addon_slug in self.addon_list:
addon = self.sys_addons.get(addon_slug)
if addon:
addon_list.append(addon)
async def _addon_restore(addon): async def _addon_restore(addon_slug):
"""Task to restore an add-on into snapshot.""" """Task to restore an add-on into snapshot."""
addon_file = SecureTarFile( addon_file = SecureTarFile(
Path(self._tmp.name, f"{addon.slug}.tar.gz"), Path(self._tmp.name, f"{addon_slug}.tar.gz"), 'r', key=self._key)
'r', key=self._key)
# If exists inside snapshot # If exists inside snapshot
if not addon_file.path.exists(): 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 return
# Perform a restore # Perform a restore
try: try:
await addon.restore(addon_file) await self.sys_addons.restore(addon_slug, addon_file)
except AddonsError: except AddonsError:
_LOGGER.error("Can't restore snapshot for %s", addon.slug) _LOGGER.error("Can't restore snapshot for %s", addon_slug)
# Run tasks # Run tasks
tasks = [_addon_restore(addon) for addon in addon_list] tasks = [_addon_restore(slug) for slug in addon_list]
if tasks: if tasks:
await asyncio.wait(tasks) await asyncio.wait(tasks)
@ -459,4 +453,4 @@ class Snapshot(CoreSysAttributes):
Return a coroutine. 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.path = path
self.lock = asyncio.Lock(loop=coresys.loop) self.lock = asyncio.Lock(loop=coresys.loop)
self._data = RE_REPOSITORY.match(url).groupdict() self.data = RE_REPOSITORY.match(url).groupdict()
@property @property
def url(self): def url(self):
"""Return repository URL.""" """Return repository URL."""
return self._data[ATTR_URL] return self.data[ATTR_URL]
@property @property
def branch(self): def branch(self):
"""Return repository branch.""" """Return repository branch."""
return self._data[ATTR_BRANCH] return self.data[ATTR_BRANCH]
async def load(self): async def load(self):
"""Init Git add-on repository.""" """Init Git add-on repository."""

View File

@ -12,6 +12,8 @@ UNKNOWN = 'unknown'
class Repository(CoreSysAttributes): class Repository(CoreSysAttributes):
"""Repository in Hass.io.""" """Repository in Hass.io."""
slug: str = None
def __init__(self, coresys, repository): def __init__(self, coresys, repository):
"""Initialize repository object.""" """Initialize repository object."""
self.coresys = coresys self.coresys = coresys
@ -19,39 +21,34 @@ class Repository(CoreSysAttributes):
self.git = None self.git = None
if repository == REPOSITORY_LOCAL: if repository == REPOSITORY_LOCAL:
self._id = repository self.slug = repository
elif repository == REPOSITORY_CORE: elif repository == REPOSITORY_CORE:
self._id = repository self.slug = repository
self.git = GitRepoHassIO(coresys) self.git = GitRepoHassIO(coresys)
else: else:
self._id = get_hash_from_repository(repository) self.slug = get_hash_from_repository(repository)
self.git = GitRepoCustom(coresys, repository) self.git = GitRepoCustom(coresys, repository)
self.source = repository self.source = repository
@property @property
def _mesh(self): def data(self):
"""Return data struct repository.""" """Return data struct repository."""
return self.sys_addons.data.repositories.get(self._id, {}) return self.sys_store.data.repositories[self.slug]
@property
def slug(self):
"""Return slug of repository."""
return self._id
@property @property
def name(self): def name(self):
"""Return name of repository.""" """Return name of repository."""
return self._mesh.get(ATTR_NAME, UNKNOWN) return self.data.get(ATTR_NAME, UNKNOWN)
@property @property
def url(self): def url(self):
"""Return URL of repository.""" """Return URL of repository."""
return self._mesh.get(ATTR_URL, self.source) return self.data.get(ATTR_URL, self.source)
@property @property
def maintainer(self): def maintainer(self):
"""Return url of repository.""" """Return url of repository."""
return self._mesh.get(ATTR_MAINTAINER, UNKNOWN) return self.data.get(ATTR_MAINTAINER, UNKNOWN)
async def load(self): async def load(self):
"""Load addon repository.""" """Load addon repository."""
@ -67,7 +64,7 @@ class Repository(CoreSysAttributes):
def remove(self): def remove(self):
"""Remove add-on repository.""" """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!") raise APIError("Can't remove built-in repositories!")
self.git.remove() 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 # Reload
self.jobs.add( 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.jobs.add(
self.sys_scheduler.register_task( self.sys_scheduler.register_task(
@ -93,7 +93,7 @@ class Tasks(CoreSysAttributes):
if not addon.is_installed or not addon.auto_update: if not addon.is_installed or not addon.auto_update:
continue continue
if addon.version_installed == addon.latest_version: if addon.version == addon.latest_version:
continue continue
if addon.test_update_schema(): if addon.test_update_schema():