Add Ingress support (#991)

* Add Ingress support to supervisor

* Update security

* cleanup add-on extraction

* update description

* fix header part

* fix

* Fix header check

* fix tox

* Migrate docker interface typing

* Update home assistant to new docker

* Migrate supervisor

* Fix host add-on problem

* Update hassos

* Update API

* Expose data to API

* Check on API ingress support

* Add ingress URL

* Some cleanups

* debug

* disable uvloop

* Fix issue

* test

* Fix bug

* Fix flow

* Fix interface

* Fix network

* Fix metadata

* cleanups

* Fix exception

* Migrate to token system

* Fix webui

* Fix update

* Fix relaod

* Update log messages

* Attach ingress url only if enabled

* Cleanup ingress url handling

* Ingress update

* Support check version

* Fix raise error

* Migrate default port

* Fix junks

* search error

* Fix content filter

* Add debug

* Update log

* Update flags

* Update documentation

* Cleanup debugs

* Fix lint

* change default port to 8099

* Fix lint

* fix lint
This commit is contained in:
Pascal Vizeli 2019-04-05 12:13:44 +02:00 committed by GitHub
parent c13a33bf71
commit 1edec61133
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1335 additions and 661 deletions

15
API.md
View File

@ -41,6 +41,7 @@ The addons from `addons` are only installed one.
"arch": "armhf|aarch64|i386|amd64", "arch": "armhf|aarch64|i386|amd64",
"channel": "stable|beta|dev", "channel": "stable|beta|dev",
"timezone": "TIMEZONE", "timezone": "TIMEZONE",
"ip_address": "ip address",
"wait_boot": "int", "wait_boot": "int",
"addons": [ "addons": [
{ {
@ -348,6 +349,7 @@ Load host configs from a USB stick.
"last_version": "LAST_VERSION", "last_version": "LAST_VERSION",
"arch": "arch", "arch": "arch",
"machine": "Image machine type", "machine": "Image machine type",
"ip_address": "ip address",
"image": "str", "image": "str",
"custom": "bool -> if custom image", "custom": "bool -> if custom image",
"boot": "bool", "boot": "bool",
@ -469,6 +471,7 @@ Get all available addons.
"available": "bool", "available": "bool",
"arch": ["armhf", "aarch64", "i386", "amd64"], "arch": ["armhf", "aarch64", "i386", "amd64"],
"machine": "[raspberrypi2, tinker]", "machine": "[raspberrypi2, tinker]",
"homeassistant": "min Home Assistant version",
"repository": "12345678|null", "repository": "12345678|null",
"version": "null|VERSION_INSTALLED", "version": "null|VERSION_INSTALLED",
"last_version": "LAST_VERSION", "last_version": "LAST_VERSION",
@ -505,7 +508,11 @@ Get all available addons.
"audio_input": "null|0,0", "audio_input": "null|0,0",
"audio_output": "null|0,0", "audio_output": "null|0,0",
"services_role": "['service:access']", "services_role": "['service:access']",
"discovery": "['service']" "discovery": "['service']",
"ip_address": "ip address",
"ingress": "bool",
"ingress_entry": "/api/hassio_ingress/slug",
"ingress_url": "/api/hassio_ingress/slug/entry.html"
} }
``` ```
@ -579,6 +586,12 @@ Write data to add-on stdin
} }
``` ```
### ingress
- VIEW `/ingress/{token}`
Ingress WebUI for this Add-on. The addon need support HASS Auth!
### discovery ### discovery
- GET `/discovery` - GET `/discovery`

View File

@ -13,6 +13,7 @@ def initialize_event_loop():
"""Attempt to use uvloop.""" """Attempt to use uvloop."""
try: try:
import uvloop import uvloop
uvloop.install() uvloop.install()
except ImportError: except ImportError:
pass pass

View File

@ -1,41 +1,105 @@
"""Init file for Hass.io add-ons.""" """Init file for Hass.io add-ons."""
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
from distutils.version import StrictVersion
from ipaddress import IPv4Address, ip_address
import logging import logging
from pathlib import Path, PurePath from pathlib import Path, PurePath
import re import re
import shutil import shutil
import tarfile import tarfile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Dict, Any from typing import Any, Awaitable, Dict, Optional
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
from ..const import ( from ..const import (
ATTR_ACCESS_TOKEN, ATTR_APPARMOR, ATTR_ARCH, ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_ACCESS_TOKEN,
ATTR_AUDIO_OUTPUT, ATTR_AUTH_API, ATTR_AUTO_UART, ATTR_AUTO_UPDATE, ATTR_APPARMOR,
ATTR_BOOT, ATTR_DESCRIPTON, ATTR_DEVICES, ATTR_DEVICETREE, ATTR_DISCOVERY, ATTR_ARCH,
ATTR_DOCKER_API, ATTR_ENVIRONMENT, ATTR_FULL_ACCESS, ATTR_GPIO, ATTR_AUDIO,
ATTR_HASSIO_API, ATTR_HASSIO_ROLE, ATTR_HOMEASSISTANT_API, ATTR_HOST_DBUS, ATTR_AUDIO_INPUT,
ATTR_HOST_IPC, ATTR_HOST_NETWORK, ATTR_HOST_PID, ATTR_IMAGE, ATTR_AUDIO_OUTPUT,
ATTR_KERNEL_MODULES, ATTR_LEGACY, ATTR_LOCATON, ATTR_MACHINE, ATTR_MAP, ATTR_AUTH_API,
ATTR_NAME, ATTR_NETWORK, ATTR_OPTIONS, ATTR_PORTS, ATTR_PRIVILEGED, ATTR_AUTO_UART,
ATTR_PROTECTED, ATTR_REPOSITORY, ATTR_SCHEMA, ATTR_SERVICES, ATTR_SLUG, ATTR_AUTO_UPDATE,
ATTR_STARTUP, ATTR_STATE, ATTR_STDIN, ATTR_SYSTEM, ATTR_TIMEOUT, ATTR_BOOT,
ATTR_TMPFS, ATTR_URL, ATTR_USER, ATTR_UUID, ATTR_VERSION, ATTR_WEBUI, ATTR_DESCRIPTON,
SECURITY_DEFAULT, SECURITY_DISABLE, SECURITY_PROFILE, STATE_NONE, ATTR_DEVICES,
STATE_STARTED, STATE_STOPPED) ATTR_DEVICETREE,
from ..coresys import CoreSysAttributes ATTR_DISCOVERY,
ATTR_DOCKER_API,
ATTR_ENVIRONMENT,
ATTR_FULL_ACCESS,
ATTR_GPIO,
ATTR_HASSIO_API,
ATTR_HASSIO_ROLE,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_API,
ATTR_HOST_DBUS,
ATTR_HOST_IPC,
ATTR_HOST_NETWORK,
ATTR_HOST_PID,
ATTR_IMAGE,
ATTR_INGRESS,
ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PORT,
ATTR_INGRESS_TOKEN,
ATTR_KERNEL_MODULES,
ATTR_LEGACY,
ATTR_LOCATON,
ATTR_MACHINE,
ATTR_MAP,
ATTR_NAME,
ATTR_NETWORK,
ATTR_OPTIONS,
ATTR_PORTS,
ATTR_PRIVILEGED,
ATTR_PROTECTED,
ATTR_REPOSITORY,
ATTR_SCHEMA,
ATTR_SERVICES,
ATTR_SLUG,
ATTR_STARTUP,
ATTR_STATE,
ATTR_STDIN,
ATTR_SYSTEM,
ATTR_TIMEOUT,
ATTR_TMPFS,
ATTR_URL,
ATTR_USER,
ATTR_UUID,
ATTR_VERSION,
ATTR_WEBUI,
SECURITY_DEFAULT,
SECURITY_DISABLE,
SECURITY_PROFILE,
STATE_NONE,
STATE_STARTED,
STATE_STOPPED,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.addon import DockerAddon from ..docker.addon import DockerAddon
from ..exceptions import HostAppArmorError, JsonFileError from ..docker.stats import DockerStats
from ..exceptions import (
AddonsError,
AddonsNotSupportedError,
DockerAPIError,
HostAppArmorError,
JsonFileError,
)
from ..utils import create_token from ..utils import create_token
from ..utils.apparmor import adjust_profile from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file from ..utils.json import read_json_file, write_json_file
from .utils import check_installed, remove_data from .utils import check_installed, remove_data
from .validate import ( from .validate import (
MACHINE_ALL, RE_SERVICE, RE_VOLUME, SCHEMA_ADDON_SNAPSHOT, MACHINE_ALL,
validate_options) RE_SERVICE,
RE_VOLUME,
SCHEMA_ADDON_SNAPSHOT,
validate_options,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -47,21 +111,28 @@ RE_WEBUI = re.compile(
class Addon(CoreSysAttributes): class Addon(CoreSysAttributes):
"""Hold data for add-on inside Hass.io.""" """Hold data for add-on inside Hass.io."""
def __init__(self, coresys, slug): def __init__(self, coresys: CoreSys, slug: str):
"""Initialize data holder.""" """Initialize data holder."""
self.coresys = coresys self.coresys: CoreSys = coresys
self.instance = DockerAddon(coresys, slug) self.instance: DockerAddon = DockerAddon(coresys, slug)
self._id: str = slug
self._id = slug async def load(self) -> None:
async def load(self):
"""Async initialize of object.""" """Async initialize of object."""
if not self.is_installed: if not self.is_installed:
return return
with suppress(DockerAPIError):
await self.instance.attach() await self.instance.attach()
@property @property
def slug(self): def ip_address(self) -> IPv4Address:
"""Return IP of Add-on instance."""
if not self.is_installed:
return ip_address("0.0.0.0")
return self.instance.ip_address
@property
def slug(self) -> str:
"""Return slug/id of add-on.""" """Return slug/id of add-on."""
return self._id return self._id
@ -76,30 +147,41 @@ class Addon(CoreSysAttributes):
return self.sys_addons.data return self.sys_addons.data
@property @property
def is_installed(self): def is_installed(self) -> bool:
"""Return True if an add-on is installed.""" """Return True if an add-on is installed."""
return self._id in self._data.system return self._id in self._data.system
@property @property
def is_detached(self): def is_detached(self) -> bool:
"""Return True if add-on is detached.""" """Return True if add-on is detached."""
return self._id not in self._data.cache return self._id not in self._data.cache
@property @property
def available(self): def available(self) -> bool:
"""Return True if this add-on is available on this platform.""" """Return True if this add-on is available on this platform."""
if self.is_detached:
addon_data = self._data.system.get(self._id)
else:
addon_data = self._data.cache.get(self._id)
# Architecture # Architecture
if not self.sys_arch.is_supported(self.supported_arch): if not self.sys_arch.is_supported(addon_data[ATTR_ARCH]):
return False return False
# Machine / Hardware # Machine / Hardware
if self.sys_machine not in self.supported_machine: machine = addon_data.get(ATTR_MACHINE) or MACHINE_ALL
if self.sys_machine not in machine:
return False
# Home Assistant
version = addon_data.get(ATTR_HOMEASSISTANT) or self.sys_homeassistant.version
if StrictVersion(self.sys_homeassistant.version) < StrictVersion(version):
return False return False
return True return True
@property @property
def version_installed(self): def version_installed(self) -> Optional[str]:
"""Return installed version.""" """Return installed version."""
return self._data.user.get(self._id, {}).get(ATTR_VERSION) return self._data.user.get(self._id, {}).get(ATTR_VERSION)
@ -202,6 +284,20 @@ class Addon(CoreSysAttributes):
return self._data.user[self._id].get(ATTR_ACCESS_TOKEN) return self._data.user[self._id].get(ATTR_ACCESS_TOKEN)
return None return None
@property
def ingress_token(self):
"""Return access token for Hass.io API."""
if self.is_installed:
return self._data.user[self._id].get(ATTR_INGRESS_TOKEN)
return None
@property
def ingress_entry(self):
"""Return ingress external URL."""
if self.is_installed and self.with_ingress:
return f"/api/hassio_ingress/{self.ingress_token}"
return None
@property @property
def description(self): def description(self):
"""Return description of add-on.""" """Return description of add-on."""
@ -292,6 +388,18 @@ class Addon(CoreSysAttributes):
self._data.user[self._id][ATTR_NETWORK] = new_ports self._data.user[self._id][ATTR_NETWORK] = new_ports
@property
def ingress_url(self):
"""Return URL to ingress url."""
# Use ingress
if not self.with_ingress:
return None
webui = f"/api/hassio_ingress/{self.ingress_token}/"
if ATTR_INGRESS_ENTRY in self._mesh:
return f"{webui}{self._mesh[ATTR_INGRESS_ENTRY]}"
return webui
@property @property
def webui(self): def webui(self):
"""Return URL to webui or None.""" """Return URL to webui or None."""
@ -323,6 +431,11 @@ class Addon(CoreSysAttributes):
return f"{proto}://[HOST]:{port}{s_suffix}" return f"{proto}://[HOST]:{port}{s_suffix}"
@property
def ingress_internal(self):
"""Return Ingress host URL."""
return f"http://{self.ip_address}:{self._mesh[ATTR_INGRESS_PORT]}"
@property @property
def host_network(self): def host_network(self):
"""Return True if add-on run on host network.""" """Return True if add-on run on host network."""
@ -407,6 +520,11 @@ class Addon(CoreSysAttributes):
"""Return True if the add-on access use stdin input.""" """Return True if the add-on access use stdin input."""
return self._mesh[ATTR_STDIN] return self._mesh[ATTR_STDIN]
@property
def with_ingress(self):
"""Return True if the add-on access support ingress."""
return self._mesh[ATTR_INGRESS]
@property @property
def with_gpio(self): def with_gpio(self):
"""Return True if the add-on access to GPIO interface.""" """Return True if the add-on access to GPIO interface."""
@ -437,6 +555,11 @@ class Addon(CoreSysAttributes):
"""Return True if the add-on access to audio.""" """Return True if the add-on access to audio."""
return self._mesh[ATTR_AUDIO] return self._mesh[ATTR_AUDIO]
@property
def homeassistant_version(self) -> Optional[str]:
"""Return min Home Assistant version they needed by Add-on."""
return self._mesh.get(ATTR_HOMEASSISTANT)
@property @property
def audio_output(self): def audio_output(self):
"""Return ALSA config for output or None.""" """Return ALSA config for output or None."""
@ -642,7 +765,7 @@ class Addon(CoreSysAttributes):
return True return True
async def _install_apparmor(self): async def _install_apparmor(self) -> None:
"""Install or Update AppArmor profile for Add-on.""" """Install or Update AppArmor profile for Add-on."""
exists_local = self.sys_host.apparmor.exists(self.slug) exists_local = self.sys_host.apparmor.exists(self.slug)
exists_addon = self.path_apparmor.exists() exists_addon = self.path_apparmor.exists()
@ -664,7 +787,7 @@ class Addon(CoreSysAttributes):
await self.sys_host.apparmor.load_profile(self.slug, profile_file) await self.sys_host.apparmor.load_profile(self.slug, profile_file)
@property @property
def schema(self): def schema(self) -> vol.Schema:
"""Create a schema for add-on options.""" """Create a schema for add-on options."""
raw_schema = self._mesh[ATTR_SCHEMA] raw_schema = self._mesh[ATTR_SCHEMA]
@ -672,7 +795,7 @@ class Addon(CoreSysAttributes):
return vol.Schema(dict) return vol.Schema(dict)
return vol.Schema(vol.All(dict, validate_options(raw_schema))) return vol.Schema(vol.All(dict, validate_options(raw_schema)))
def test_update_schema(self): def test_update_schema(self) -> bool:
"""Check if the existing configuration is valid after update.""" """Check if the existing configuration is valid after update."""
if not self.is_installed or self.is_detached: if not self.is_installed or self.is_detached:
return True return True
@ -702,17 +825,17 @@ class Addon(CoreSysAttributes):
return False return False
return True return True
async def install(self): async def install(self) -> None:
"""Install an add-on.""" """Install an add-on."""
if not self.available: if not self.available:
_LOGGER.error( _LOGGER.error(
"Add-on %s not supported on %s with %s architecture", "Add-on %s not supported on %s with %s architecture",
self._id, self.sys_machine, self.sys_arch.supported) self._id, self.sys_machine, self.sys_arch.supported)
return False raise AddonsNotSupportedError()
if self.is_installed: if self.is_installed:
_LOGGER.error("Add-on %s is already installed", self._id) _LOGGER.warning("Add-on %s is already installed", self._id)
return False return
if not self.path_data.is_dir(): if not self.path_data.is_dir():
_LOGGER.info( _LOGGER.info(
@ -722,18 +845,20 @@ class Addon(CoreSysAttributes):
# Setup/Fix AppArmor profile # Setup/Fix AppArmor profile
await self._install_apparmor() await self._install_apparmor()
if not await self.instance.install( try:
self.last_version, self.image_next): await self.instance.install(self.last_version, self.image_next)
return False except DockerAPIError:
raise AddonsError() from None
else:
self._set_install(self.image_next, self.last_version) self._set_install(self.image_next, self.last_version)
return True
@check_installed @check_installed
async def uninstall(self): async def uninstall(self) -> None:
"""Remove an add-on.""" """Remove an add-on."""
if not await self.instance.remove(): try:
return False await self.instance.remove()
except DockerAPIError:
raise AddonsError() from None
if self.path_data.is_dir(): if self.path_data.is_dir():
_LOGGER.info( _LOGGER.info(
@ -750,13 +875,11 @@ class Addon(CoreSysAttributes):
with suppress(HostAppArmorError): with suppress(HostAppArmorError):
await self.sys_host.apparmor.remove_profile(self.slug) await self.sys_host.apparmor.remove_profile(self.slug)
# Remove discovery messages # Cleanup internal data
self.remove_discovery() self.remove_discovery()
self._set_uninstall() self._set_uninstall()
return True
async def state(self): async def state(self) -> str:
"""Return running state of add-on.""" """Return running state of add-on."""
if not self.is_installed: if not self.is_installed:
return STATE_NONE return STATE_NONE
@ -766,7 +889,7 @@ class Addon(CoreSysAttributes):
return STATE_STOPPED return STATE_STOPPED
@check_installed @check_installed
async def start(self): async def start(self) -> None:
"""Set options and start add-on.""" """Set options and start add-on."""
if await self.instance.is_running(): if await self.instance.is_running():
_LOGGER.warning("%s already running!", self.slug) _LOGGER.warning("%s already running!", self.slug)
@ -778,34 +901,45 @@ class Addon(CoreSysAttributes):
# Options # Options
if not self.write_options(): if not self.write_options():
return False raise AddonsError()
# Sound # Sound
if self.with_audio and not self.write_asound(): if self.with_audio and not self.write_asound():
return False raise AddonsError()
return await self.instance.run() try:
await self.instance.run()
except DockerAPIError:
raise AddonsError() from None
@check_installed @check_installed
def stop(self): async def stop(self) -> None:
"""Stop add-on. """Stop add-on."""
try:
Return a coroutine. return await self.instance.stop()
""" except DockerAPIError:
return self.instance.stop() raise AddonsError() from None
@check_installed @check_installed
async def update(self): async def update(self) -> None:
"""Update add-on.""" """Update add-on."""
last_state = await self.state()
if self.last_version == self.version_installed: if self.last_version == self.version_installed:
_LOGGER.warning("No update available for add-on %s", self._id) _LOGGER.warning("No update available for add-on %s", self._id)
return False return
if not await self.instance.update( # Check if available, Maybe something have changed
self.last_version, self.image_next): if not self.available:
return False _LOGGER.error(
"Add-on %s not supported on %s with %s architecture",
self._id, self.sys_machine, self.sys_arch.supported)
raise AddonsNotSupportedError()
# Update instance
last_state = await self.state()
try:
await self.instance.update(self.last_version, self.image_next)
except DockerAPIError:
raise AddonsError() from None
self._set_update(self.image_next, self.last_version) self._set_update(self.image_next, self.last_version)
# Setup/Fix AppArmor profile # Setup/Fix AppArmor profile
@ -814,16 +948,16 @@ class Addon(CoreSysAttributes):
# restore state # restore state
if last_state == STATE_STARTED: if last_state == STATE_STARTED:
await self.start() await self.start()
return True
@check_installed @check_installed
async def restart(self): async def restart(self) -> None:
"""Restart add-on.""" """Restart add-on."""
with suppress(AddonsError):
await self.stop() await self.stop()
return await self.start() await self.start()
@check_installed @check_installed
def logs(self): def logs(self) -> Awaitable[bytes]:
"""Return add-ons log output. """Return add-ons log output.
Return a coroutine. Return a coroutine.
@ -831,33 +965,32 @@ class Addon(CoreSysAttributes):
return self.instance.logs() return self.instance.logs()
@check_installed @check_installed
def stats(self): async def stats(self) -> DockerStats:
"""Return stats of container. """Return stats of container."""
try:
Return a coroutine. return await self.instance.stats()
""" except DockerAPIError:
return self.instance.stats() raise AddonsError() from None
@check_installed @check_installed
async def rebuild(self): async def rebuild(self) -> None:
"""Perform a rebuild of local build add-on.""" """Perform a rebuild of local build add-on."""
last_state = await self.state() last_state = await self.state()
if not self.need_build: if not self.need_build:
_LOGGER.error("Can't rebuild a none local build add-on!") _LOGGER.error("Can't rebuild a none local build add-on!")
return False raise AddonsNotSupportedError()
# remove docker container but not addon config # remove docker container but not addon config
if not await self.instance.remove(): try:
return False await self.instance.remove()
await self.instance.install(self.version_installed)
if not await self.instance.install(self.version_installed): except DockerAPIError:
return False raise AddonsError() from None
# restore state # restore state
if last_state == STATE_STARTED: if last_state == STATE_STARTED:
await self.start() await self.start()
return True
@check_installed @check_installed
async def write_stdin(self, data): async def write_stdin(self, data):
@ -867,18 +1000,23 @@ class Addon(CoreSysAttributes):
""" """
if not self.with_stdin: if not self.with_stdin:
_LOGGER.error("Add-on don't support write to stdin!") _LOGGER.error("Add-on don't support write to stdin!")
return False raise AddonsNotSupportedError()
try:
return await self.instance.write_stdin(data) return await self.instance.write_stdin(data)
except DockerAPIError:
raise AddonsError() from None
@check_installed @check_installed
async def snapshot(self, tar_file): async def snapshot(self, tar_file: tarfile.TarFile) -> None:
"""Snapshot state of an add-on.""" """Snapshot state of an add-on."""
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp: with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp:
# store local image # store local image
if self.need_build and not await \ if self.need_build:
self.instance.export_image(Path(temp, 'image.tar')): try:
return False await self.instance.export_image(Path(temp, 'image.tar'))
except DockerAPIError:
raise AddonsError() from None
data = { data = {
ATTR_USER: self._data.user.get(self._id, {}), ATTR_USER: self._data.user.get(self._id, {}),
@ -892,7 +1030,7 @@ class Addon(CoreSysAttributes):
write_json_file(Path(temp, 'addon.json'), data) write_json_file(Path(temp, 'addon.json'), data)
except JsonFileError: except JsonFileError:
_LOGGER.error("Can't save meta for %s", self._id) _LOGGER.error("Can't save meta for %s", self._id)
return False raise AddonsError() from None
# Store AppArmor Profile # Store AppArmor Profile
if self.sys_host.apparmor.exists(self.slug): if self.sys_host.apparmor.exists(self.slug):
@ -901,7 +1039,7 @@ class Addon(CoreSysAttributes):
self.sys_host.apparmor.backup_profile(self.slug, profile) self.sys_host.apparmor.backup_profile(self.slug, profile)
except HostAppArmorError: except HostAppArmorError:
_LOGGER.error("Can't backup AppArmor profile") _LOGGER.error("Can't backup AppArmor profile")
return False raise AddonsError() from None
# write into tarfile # write into tarfile
def _write_tarfile(): def _write_tarfile():
@ -915,12 +1053,11 @@ class Addon(CoreSysAttributes):
await self.sys_run_in_executor(_write_tarfile) await self.sys_run_in_executor(_write_tarfile)
except (tarfile.TarError, OSError) as err: except (tarfile.TarError, OSError) as err:
_LOGGER.error("Can't write tarfile %s: %s", tar_file, err) _LOGGER.error("Can't write tarfile %s: %s", tar_file, err)
return False raise AddonsError() from None
_LOGGER.info("Finish snapshot for addon %s", self._id) _LOGGER.info("Finish snapshot for addon %s", self._id)
return True
async def restore(self, tar_file): async def restore(self, tar_file: tarfile.TarFile) -> None:
"""Restore state of an add-on.""" """Restore state of an add-on."""
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp: with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp:
# extract snapshot # extract snapshot
@ -933,13 +1070,13 @@ class Addon(CoreSysAttributes):
await self.sys_run_in_executor(_extract_tarfile) await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err: except tarfile.TarError as err:
_LOGGER.error("Can't read tarfile %s: %s", tar_file, err) _LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
return False raise AddonsError() from None
# Read snapshot data # Read snapshot data
try: try:
data = read_json_file(Path(temp, 'addon.json')) data = read_json_file(Path(temp, 'addon.json'))
except JsonFileError: except JsonFileError:
return False raise AddonsError() from None
# Validate # Validate
try: try:
@ -947,7 +1084,7 @@ class Addon(CoreSysAttributes):
except vol.Invalid as err: except vol.Invalid as err:
_LOGGER.error("Can't validate %s, snapshot data: %s", _LOGGER.error("Can't validate %s, snapshot data: %s",
self._id, humanize_error(data, err)) self._id, humanize_error(data, err))
return False raise AddonsError() from None
# Restore local add-on informations # Restore local add-on informations
_LOGGER.info("Restore config for addon %s", self._id) _LOGGER.info("Restore config for addon %s", self._id)
@ -961,14 +1098,18 @@ class Addon(CoreSysAttributes):
image_file = Path(temp, 'image.tar') image_file = Path(temp, 'image.tar')
if image_file.is_file(): if image_file.is_file():
with suppress(DockerAPIError):
await self.instance.import_image(image_file, version) await self.instance.import_image(image_file, version)
else: else:
if await self.instance.install(version, restore_image): with suppress(DockerAPIError):
await self.instance.install(version, restore_image)
await self.instance.cleanup() await self.instance.cleanup()
elif self.instance.version != version or self.legacy: elif self.instance.version != version or self.legacy:
_LOGGER.info("Restore/Update image for addon %s", self._id) _LOGGER.info("Restore/Update image for addon %s", self._id)
with suppress(DockerAPIError):
await self.instance.update(version, restore_image) await self.instance.update(version, restore_image)
else: else:
with suppress(DockerAPIError):
await self.instance.stop() await self.instance.stop()
# Restore data # Restore data
@ -983,7 +1124,7 @@ class Addon(CoreSysAttributes):
await self.sys_run_in_executor(_restore_data) await self.sys_run_in_executor(_restore_data)
except shutil.Error as err: except shutil.Error as err:
_LOGGER.error("Can't restore origin data: %s", err) _LOGGER.error("Can't restore origin data: %s", err)
return False raise AddonsError() from None
# Restore AppArmor # Restore AppArmor
profile_file = Path(temp, 'apparmor.txt') profile_file = Path(temp, 'apparmor.txt')
@ -993,11 +1134,10 @@ class Addon(CoreSysAttributes):
self.slug, profile_file) self.slug, profile_file)
except HostAppArmorError: except HostAppArmorError:
_LOGGER.error("Can't restore AppArmor profile") _LOGGER.error("Can't restore AppArmor profile")
return False raise AddonsError() from None
# Run add-on # Run add-on
if data[ATTR_STATE] == STATE_STARTED: if data[ATTR_STATE] == STATE_STARTED:
return await self.start() return await self.start()
_LOGGER.info("Finish restore for add-on %s", self._id) _LOGGER.info("Finish restore for add-on %s", self._id)
return True

View File

@ -20,6 +20,7 @@ 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 .addon import Addon
@ -107,7 +108,7 @@ def check_installed(method):
"""Return False if not installed or the function.""" """Return False if not installed or the function."""
if not addon.is_installed: if not addon.is_installed:
_LOGGER.error("Addon %s is not installed", addon.slug) _LOGGER.error("Addon %s is not installed", addon.slug)
return False raise AddonsNotSupportedError()
return await method(addon, *args, **kwargs) return await method(addon, *args, **kwargs)
return wrap_check return wrap_check

View File

@ -1,29 +1,87 @@
"""Validate add-ons options schema.""" """Validate add-ons options schema."""
import logging import logging
import re import re
import secrets
import uuid import uuid
import voluptuous as vol import voluptuous as vol
from ..const import ( from ..const import (
ARCH_ALL, ATTR_ACCESS_TOKEN, ATTR_APPARMOR, ATTR_ARCH, ATTR_ARGS, ARCH_ALL,
ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_AUTH_API, ATTR_ACCESS_TOKEN,
ATTR_AUTO_UART, ATTR_AUTO_UPDATE, ATTR_BOOT, ATTR_BUILD_FROM, ATTR_APPARMOR,
ATTR_DESCRIPTON, ATTR_DEVICES, ATTR_DEVICETREE, ATTR_DISCOVERY, ATTR_ARCH,
ATTR_DOCKER_API, ATTR_ENVIRONMENT, ATTR_FULL_ACCESS, ATTR_GPIO, ATTR_ARGS,
ATTR_HASSIO_API, ATTR_HASSIO_ROLE, ATTR_HOMEASSISTANT_API, ATTR_HOST_DBUS, ATTR_AUDIO,
ATTR_HOST_IPC, ATTR_HOST_NETWORK, ATTR_HOST_PID, ATTR_IMAGE, ATTR_AUDIO_INPUT,
ATTR_KERNEL_MODULES, ATTR_LEGACY, ATTR_LOCATON, ATTR_MACHINE, ATTR_AUDIO_OUTPUT,
ATTR_MAINTAINER, ATTR_MAP, ATTR_NAME, ATTR_NETWORK, ATTR_OPTIONS, ATTR_AUTH_API,
ATTR_PORTS, ATTR_PRIVILEGED, ATTR_PROTECTED, ATTR_REPOSITORY, ATTR_SCHEMA, ATTR_AUTO_UART,
ATTR_SERVICES, ATTR_SLUG, ATTR_SQUASH, ATTR_STARTUP, ATTR_STATE, ATTR_AUTO_UPDATE,
ATTR_STDIN, ATTR_SYSTEM, ATTR_TIMEOUT, ATTR_TMPFS, ATTR_URL, ATTR_USER, ATTR_BOOT,
ATTR_UUID, ATTR_VERSION, ATTR_WEBUI, BOOT_AUTO, BOOT_MANUAL, ATTR_BUILD_FROM,
PRIVILEGED_ALL, ROLE_ALL, ROLE_DEFAULT, STARTUP_ALL, STARTUP_APPLICATION, ATTR_DESCRIPTON,
STARTUP_SERVICES, STATE_STARTED, STATE_STOPPED) ATTR_DEVICES,
ATTR_DEVICETREE,
ATTR_DISCOVERY,
ATTR_DOCKER_API,
ATTR_ENVIRONMENT,
ATTR_FULL_ACCESS,
ATTR_GPIO,
ATTR_HASSIO_API,
ATTR_HASSIO_ROLE,
ATTR_HOMEASSISTANT_API,
ATTR_HOMEASSISTANT,
ATTR_HOST_DBUS,
ATTR_HOST_IPC,
ATTR_HOST_NETWORK,
ATTR_HOST_PID,
ATTR_IMAGE,
ATTR_INGRESS,
ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PORT,
ATTR_INGRESS_TOKEN,
ATTR_KERNEL_MODULES,
ATTR_LEGACY,
ATTR_LOCATON,
ATTR_MACHINE,
ATTR_MAINTAINER,
ATTR_MAP,
ATTR_NAME,
ATTR_NETWORK,
ATTR_OPTIONS,
ATTR_PORTS,
ATTR_PRIVILEGED,
ATTR_PROTECTED,
ATTR_REPOSITORY,
ATTR_SCHEMA,
ATTR_SERVICES,
ATTR_SLUG,
ATTR_SQUASH,
ATTR_STARTUP,
ATTR_STATE,
ATTR_STDIN,
ATTR_SYSTEM,
ATTR_TIMEOUT,
ATTR_TMPFS,
ATTR_URL,
ATTR_USER,
ATTR_UUID,
ATTR_VERSION,
ATTR_WEBUI,
BOOT_AUTO,
BOOT_MANUAL,
PRIVILEGED_ALL,
ROLE_ALL,
ROLE_DEFAULT,
STARTUP_ALL,
STARTUP_APPLICATION,
STARTUP_SERVICES,
STATE_STARTED,
STATE_STOPPED,
)
from ..discovery.validate import valid_discovery_service from ..discovery.validate import valid_discovery_service
from ..validate import ( from ..validate import ALSA_DEVICE, DOCKER_PORTS, NETWORK_PORT, SHA256, UUID_MATCH
ALSA_DEVICE, DOCKER_PORTS, NETWORK_PORT, SHA256, UUID_MATCH)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -89,6 +147,10 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Optional(ATTR_PORTS): DOCKER_PORTS, vol.Optional(ATTR_PORTS): DOCKER_PORTS,
vol.Optional(ATTR_WEBUI): vol.Optional(ATTR_WEBUI):
vol.Match(r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"), vol.Match(r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"),
vol.Optional(ATTR_INGRESS, default=False): vol.Boolean(),
vol.Optional(ATTR_INGRESS_PORT, default=8099): NETWORK_PORT,
vol.Optional(ATTR_INGRESS_ENTRY): vol.Coerce(str),
vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(), vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
@ -159,6 +221,7 @@ SCHEMA_ADDON_USER = vol.Schema({
vol.Optional(ATTR_IMAGE): vol.Coerce(str), vol.Optional(ATTR_IMAGE): vol.Coerce(str),
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH, vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH,
vol.Optional(ATTR_ACCESS_TOKEN): SHA256, vol.Optional(ATTR_ACCESS_TOKEN): SHA256,
vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): vol.Coerce(str),
vol.Optional(ATTR_OPTIONS, default=dict): dict, vol.Optional(ATTR_OPTIONS, default=dict): dict,
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
vol.Optional(ATTR_BOOT): vol.Optional(ATTR_BOOT):

View File

@ -14,6 +14,7 @@ from .hassos import APIHassOS
from .homeassistant import APIHomeAssistant from .homeassistant import APIHomeAssistant
from .host import APIHost from .host import APIHost
from .info import APIInfo from .info import APIInfo
from .ingress import APIIngress
from .proxy import APIProxy from .proxy import APIProxy
from .security import SecurityMiddleware from .security import SecurityMiddleware
from .services import APIServices from .services import APIServices
@ -47,6 +48,7 @@ class RestAPI(CoreSysAttributes):
self._register_proxy() self._register_proxy()
self._register_panel() self._register_panel()
self._register_addons() self._register_addons()
self._register_ingress()
self._register_snapshots() self._register_snapshots()
self._register_discovery() self._register_discovery()
self._register_services() self._register_services()
@ -186,6 +188,15 @@ class RestAPI(CoreSysAttributes):
web.get('/addons/{addon}/stats', api_addons.stats), web.get('/addons/{addon}/stats', api_addons.stats),
]) ])
def _register_ingress(self) -> None:
"""Register Ingress functions."""
api_ingress = APIIngress()
api_ingress.coresys = self.coresys
self.webapp.add_routes([
web.view('/ingress/{token}/{path:.*}', api_ingress.handler),
])
def _register_snapshots(self) -> None: def _register_snapshots(self) -> None:
"""Register snapshots functions.""" """Register snapshots functions."""
api_snapshots = APISnapshots() api_snapshots = APISnapshots()

View File

@ -1,31 +1,89 @@
"""Init file for Hass.io Home Assistant RESTful API.""" """Init file for Hass.io Home Assistant RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable, Dict, List
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 .utils import api_process, api_process_raw, api_validate from ..addons.addon import Addon
from ..addons.utils import rating_security from ..addons.utils import rating_security
from ..const import ( from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS, ATTR_ADDONS,
ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY, ATTR_APPARMOR,
ATTR_BUILD, ATTR_AUTO_UPDATE, ATTR_NETWORK, ATTR_HOST_NETWORK, ATTR_SLUG, ATTR_ARCH,
ATTR_SOURCE, ATTR_REPOSITORIES, ATTR_ADDONS, ATTR_ARCH, ATTR_MAINTAINER, ATTR_AUDIO,
ATTR_INSTALLED, ATTR_LOGO, ATTR_WEBUI, ATTR_DEVICES, ATTR_PRIVILEGED, ATTR_AUDIO_INPUT,
ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_AUDIO_OUTPUT,
ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, BOOT_AUTO, BOOT_MANUAL, ATTR_AUTH_API,
ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION, ATTR_AUTO_UPDATE,
ATTR_CPU_PERCENT, ATTR_MEMORY_LIMIT, ATTR_MEMORY_USAGE, ATTR_NETWORK_TX, ATTR_AVAILABLE,
ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES, ATTR_BLK_READ,
ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_BLK_WRITE,
ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING, ATTR_HOST_PID, ATTR_BOOT,
ATTR_HASSIO_ROLE, ATTR_MACHINE, ATTR_AVAILABLE, ATTR_AUTH_API, ATTR_BUILD,
ATTR_CHANGELOG,
ATTR_CPU_PERCENT,
ATTR_DESCRIPTON,
ATTR_DETACHED,
ATTR_DEVICES,
ATTR_DEVICETREE,
ATTR_DISCOVERY,
ATTR_DOCKER_API,
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_ICON,
ATTR_INGRESS,
ATTR_INGRESS_ENTRY,
ATTR_INGRESS_URL,
ATTR_INSTALLED,
ATTR_IP_ADDRESS,
ATTR_KERNEL_MODULES, ATTR_KERNEL_MODULES,
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, REQUEST_FROM) ATTR_LAST_VERSION,
ATTR_LOGO,
ATTR_LONG_DESCRIPTION,
ATTR_MACHINE,
ATTR_MAINTAINER,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_USAGE,
ATTR_NAME,
ATTR_NETWORK,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
ATTR_OPTIONS,
ATTR_PRIVILEGED,
ATTR_PROTECTED,
ATTR_RATING,
ATTR_REPOSITORIES,
ATTR_REPOSITORY,
ATTR_SERVICES,
ATTR_SLUG,
ATTR_SOURCE,
ATTR_STATE,
ATTR_STDIN,
ATTR_URL,
ATTR_VERSION,
ATTR_WEBUI,
BOOT_AUTO,
BOOT_MANUAL,
CONTENT_TYPE_BINARY,
CONTENT_TYPE_PNG,
CONTENT_TYPE_TEXT,
REQUEST_FROM,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..validate import DOCKER_PORTS, ALSA_DEVICE
from ..exceptions import APIError from ..exceptions import APIError
from ..validate import ALSA_DEVICE, DOCKER_PORTS
from .utils import api_process, api_process_raw, api_validate
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -51,7 +109,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, check_installed=True): def _extract_addon(self, request: web.Request, check_installed: bool = True) -> Addon:
"""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')
@ -69,7 +127,7 @@ class APIAddons(CoreSysAttributes):
return addon return addon
@api_process @api_process
async def list(self, request): 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.list_addons:
@ -104,13 +162,12 @@ class APIAddons(CoreSysAttributes):
} }
@api_process @api_process
async def reload(self, request): async def reload(self, request: web.Request) -> None:
"""Reload all add-on data.""" """Reload all add-on data."""
await asyncio.shield(self.sys_addons.reload()) await asyncio.shield(self.sys_addons.reload())
return True
@api_process @api_process
async def info(self, request): 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)
@ -130,6 +187,7 @@ class APIAddons(CoreSysAttributes):
ATTR_OPTIONS: addon.options, ATTR_OPTIONS: addon.options,
ATTR_ARCH: addon.supported_arch, ATTR_ARCH: addon.supported_arch,
ATTR_MACHINE: addon.supported_machine, ATTR_MACHINE: addon.supported_machine,
ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_URL: addon.url, ATTR_URL: addon.url,
ATTR_DETACHED: addon.is_detached, ATTR_DETACHED: addon.is_detached,
ATTR_AVAILABLE: addon.available, ATTR_AVAILABLE: addon.available,
@ -161,17 +219,20 @@ class APIAddons(CoreSysAttributes):
ATTR_AUDIO_OUTPUT: addon.audio_output, ATTR_AUDIO_OUTPUT: addon.audio_output,
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_INGRESS: addon.with_ingress,
ATTR_INGRESS_ENTRY: addon.ingress_entry,
ATTR_INGRESS_URL: addon.ingress_url,
} }
@api_process @api_process
async def options(self, request): async def options(self, request: web.Request) -> None:
"""Store user options for add-on.""" """Store user options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
addon_schema = SCHEMA_OPTIONS.extend({ addon_schema = SCHEMA_OPTIONS.extend({
vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema), vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema),
}) })
body = await api_validate(addon_schema, request) body = await api_validate(addon_schema, request)
if ATTR_OPTIONS in body: if ATTR_OPTIONS in body:
@ -188,10 +249,9 @@ class APIAddons(CoreSysAttributes):
addon.audio_output = body[ATTR_AUDIO_OUTPUT] addon.audio_output = body[ATTR_AUDIO_OUTPUT]
addon.save_data() addon.save_data()
return True
@api_process @api_process
async def security(self, request): async def security(self, request: web.Request) -> None:
"""Store security options for add-on.""" """Store security options for add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
body = await api_validate(SCHEMA_SECURITY, request) body = await api_validate(SCHEMA_SECURITY, request)
@ -201,17 +261,13 @@ class APIAddons(CoreSysAttributes):
addon.protected = body[ATTR_PROTECTED] addon.protected = body[ATTR_PROTECTED]
addon.save_data() addon.save_data()
return True
@api_process @api_process
async def stats(self, request): async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
stats = await addon.stats() stats = await addon.stats()
if not stats:
raise APIError("No stats available")
return { return {
ATTR_CPU_PERCENT: stats.cpu_percent, ATTR_CPU_PERCENT: stats.cpu_percent,
ATTR_MEMORY_USAGE: stats.memory_usage, ATTR_MEMORY_USAGE: stats.memory_usage,
@ -223,19 +279,19 @@ class APIAddons(CoreSysAttributes):
} }
@api_process @api_process
def install(self, request): def install(self, request: web.Request) -> Awaitable[None]:
"""Install add-on.""" """Install add-on."""
addon = self._extract_addon(request, check_installed=False) addon = self._extract_addon(request, check_installed=False)
return asyncio.shield(addon.install()) return asyncio.shield(addon.install())
@api_process @api_process
def uninstall(self, request): def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on.""" """Uninstall add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
return asyncio.shield(addon.uninstall()) return asyncio.shield(addon.uninstall())
@api_process @api_process
def start(self, request): def start(self, request: web.Request) -> Awaitable[None]:
"""Start add-on.""" """Start add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
@ -249,13 +305,13 @@ class APIAddons(CoreSysAttributes):
return asyncio.shield(addon.start()) return asyncio.shield(addon.start())
@api_process @api_process
def stop(self, request): def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop add-on.""" """Stop add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
return asyncio.shield(addon.stop()) return asyncio.shield(addon.stop())
@api_process @api_process
def update(self, request): def update(self, request: web.Request) -> Awaitable[None]:
"""Update add-on.""" """Update add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
@ -265,13 +321,13 @@ class APIAddons(CoreSysAttributes):
return asyncio.shield(addon.update()) return asyncio.shield(addon.update())
@api_process @api_process
def restart(self, request): def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart add-on.""" """Restart add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
return asyncio.shield(addon.restart()) return asyncio.shield(addon.restart())
@api_process @api_process
def rebuild(self, request): def rebuild(self, request: web.Request) -> Awaitable[None]:
"""Rebuild local build add-on.""" """Rebuild local build add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
if not addon.need_build: if not addon.need_build:
@ -280,13 +336,13 @@ class APIAddons(CoreSysAttributes):
return asyncio.shield(addon.rebuild()) return asyncio.shield(addon.rebuild())
@api_process_raw(CONTENT_TYPE_BINARY) @api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request): def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return logs from add-on.""" """Return logs from add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
return addon.logs() return addon.logs()
@api_process_raw(CONTENT_TYPE_PNG) @api_process_raw(CONTENT_TYPE_PNG)
async def icon(self, request): async def icon(self, request: web.Request) -> bytes:
"""Return icon from add-on.""" """Return icon from add-on."""
addon = self._extract_addon(request, check_installed=False) addon = self._extract_addon(request, check_installed=False)
if not addon.with_icon: if not addon.with_icon:
@ -296,7 +352,7 @@ class APIAddons(CoreSysAttributes):
return png.read() return png.read()
@api_process_raw(CONTENT_TYPE_PNG) @api_process_raw(CONTENT_TYPE_PNG)
async def logo(self, request): async def logo(self, request: web.Request) -> bytes:
"""Return logo from add-on.""" """Return logo from add-on."""
addon = self._extract_addon(request, check_installed=False) addon = self._extract_addon(request, check_installed=False)
if not addon.with_logo: if not addon.with_logo:
@ -306,7 +362,7 @@ class APIAddons(CoreSysAttributes):
return png.read() return png.read()
@api_process_raw(CONTENT_TYPE_TEXT) @api_process_raw(CONTENT_TYPE_TEXT)
async def changelog(self, request): async def changelog(self, request: web.Request) -> str:
"""Return changelog from add-on.""" """Return changelog from add-on."""
addon = self._extract_addon(request, check_installed=False) addon = self._extract_addon(request, check_installed=False)
if not addon.with_changelog: if not addon.with_changelog:
@ -316,17 +372,17 @@ class APIAddons(CoreSysAttributes):
return changelog.read() return changelog.read()
@api_process @api_process
async def stdin(self, request): async def stdin(self, request: web.Request) -> None:
"""Write to stdin of add-on.""" """Write to stdin of add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
if not addon.with_stdin: if not addon.with_stdin:
raise APIError("STDIN not supported by add-on") raise APIError("STDIN not supported by add-on")
data = await request.read() data = await request.read()
return await asyncio.shield(addon.write_stdin(data)) await asyncio.shield(addon.write_stdin(data))
def _pretty_devices(addon): def _pretty_devices(addon: Addon) -> 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:
@ -334,7 +390,7 @@ def _pretty_devices(addon):
return [row.split(':')[0] for row in dev_list] return [row.split(':')[0] for row in dev_list]
def _pretty_services(addon): def _pretty_services(addon: Addon) -> 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

@ -1,27 +1,31 @@
"""Init file for Hass.io HassOS RESTful API.""" """Init file for Hass.io HassOS RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable, Dict
import voluptuous as vol import voluptuous as vol
from aiohttp import web
from .utils import api_process, api_validate
from ..const import ( from ..const import (
ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST, ATTR_VERSION_CLI, ATTR_BOARD,
ATTR_VERSION_CLI_LATEST) ATTR_VERSION,
ATTR_VERSION_CLI,
ATTR_VERSION_CLI_LATEST,
ATTR_VERSION_LATEST,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from .utils import api_process, api_validate
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCHEMA_VERSION = vol.Schema({ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
vol.Optional(ATTR_VERSION): vol.Coerce(str),
})
class APIHassOS(CoreSysAttributes): class APIHassOS(CoreSysAttributes):
"""Handle RESTful API for HassOS functions.""" """Handle RESTful API for HassOS functions."""
@api_process @api_process
async def info(self, request): async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HassOS information.""" """Return HassOS information."""
return { return {
ATTR_VERSION: self.sys_hassos.version, ATTR_VERSION: self.sys_hassos.version,
@ -32,7 +36,7 @@ class APIHassOS(CoreSysAttributes):
} }
@api_process @api_process
async def update(self, request): async def update(self, request: web.Request) -> None:
"""Update HassOS.""" """Update HassOS."""
body = await api_validate(SCHEMA_VERSION, request) body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_latest) version = body.get(ATTR_VERSION, self.sys_hassos.version_latest)
@ -40,7 +44,7 @@ class APIHassOS(CoreSysAttributes):
await asyncio.shield(self.sys_hassos.update(version)) await asyncio.shield(self.sys_hassos.update(version))
@api_process @api_process
async def update_cli(self, request): async def update_cli(self, request: web.Request) -> None:
"""Update HassOS CLI.""" """Update HassOS CLI."""
body = await api_validate(SCHEMA_VERSION, request) body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest) version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest)
@ -48,6 +52,6 @@ class APIHassOS(CoreSysAttributes):
await asyncio.shield(self.sys_hassos.update_cli(version)) await asyncio.shield(self.sys_hassos.update_cli(version))
@api_process @api_process
def config_sync(self, request): def config_sync(self, request: web.Request) -> Awaitable[None]:
"""Trigger config reload on HassOS.""" """Trigger config reload on HassOS."""
return asyncio.shield(self.sys_hassos.config_sync()) return asyncio.shield(self.sys_hassos.config_sync())

View File

@ -27,6 +27,7 @@ from ..const import (
ATTR_VERSION, ATTR_VERSION,
ATTR_WAIT_BOOT, ATTR_WAIT_BOOT,
ATTR_WATCHDOG, ATTR_WATCHDOG,
ATTR_IP_ADDRESS,
CONTENT_TYPE_BINARY, CONTENT_TYPE_BINARY,
) )
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
@ -64,6 +65,7 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_VERSION: self.sys_homeassistant.version, ATTR_VERSION: self.sys_homeassistant.version,
ATTR_LAST_VERSION: self.sys_homeassistant.last_version, ATTR_LAST_VERSION: self.sys_homeassistant.last_version,
ATTR_MACHINE: self.sys_homeassistant.machine, ATTR_MACHINE: self.sys_homeassistant.machine,
ATTR_IP_ADDRESS: str(self.sys_homeassistant.ip_address),
ATTR_ARCH: self.sys_homeassistant.arch, ATTR_ARCH: self.sys_homeassistant.arch,
ATTR_IMAGE: self.sys_homeassistant.image, ATTR_IMAGE: self.sys_homeassistant.image,
ATTR_CUSTOM: self.sys_homeassistant.is_custom_image, ATTR_CUSTOM: self.sys_homeassistant.is_custom_image,

205
hassio/api/ingress.py Normal file
View File

@ -0,0 +1,205 @@
"""Hass.io Add-on ingress service."""
import asyncio
from ipaddress import ip_address
import logging
from typing import Dict, Union
import aiohttp
from aiohttp import hdrs, web
from aiohttp.web_exceptions import (
HTTPBadGateway,
HTTPServiceUnavailable,
HTTPUnauthorized,
)
from multidict import CIMultiDict, istr
from ..addons.addon import Addon
from ..const import HEADER_TOKEN, REQUEST_FROM
from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__)
class APIIngress(CoreSysAttributes):
"""Ingress view to handle add-on webui routing."""
def _extract_addon(self, request: web.Request) -> Addon:
"""Return addon, throw an exception it it doesn't exist."""
token = request.match_info.get("token")
# Find correct add-on
for addon in self.sys_addons.list_installed:
if addon.ingress_token != token:
continue
return addon
_LOGGER.warning("Ingress for %s not available", token)
raise HTTPServiceUnavailable()
def _create_url(self, addon: Addon, path: str) -> str:
"""Create URL to container."""
return f"{addon.ingress_internal}/{path}"
async def handler(
self, request: web.Request
) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
"""Route data to Hass.io ingress service."""
addon = self._extract_addon(request)
path = request.match_info.get("path")
# Only Home Assistant call this
if request[REQUEST_FROM] != self.sys_homeassistant:
_LOGGER.warning("Ingress is only available behind Home Assistant")
raise HTTPUnauthorized()
if not addon.with_ingress:
_LOGGER.warning("Add-on %s don't support ingress feature", addon.slug)
raise HTTPBadGateway()
# Process requests
try:
# Websocket
if _is_websocket(request):
return await self._handle_websocket(request, addon, path)
# Request
return await self._handle_request(request, addon, path)
except aiohttp.ClientError as err:
_LOGGER.error("Ingress error: %s", err)
raise HTTPBadGateway() from None
async def _handle_websocket(
self, request: web.Request, addon: Addon, path: str
) -> web.WebSocketResponse:
"""Ingress route for websocket."""
ws_server = web.WebSocketResponse()
await ws_server.prepare(request)
# Preparing
url = self._create_url(addon, path)
source_header = _init_header(request, addon)
# Support GET query
if request.query_string:
url = "{}?{}".format(url, request.query_string)
# Start proxy
async with self.sys_websession.ws_connect(
url, headers=source_header
) as ws_client:
# Proxy requests
await asyncio.wait(
[
_websocket_forward(ws_server, ws_client),
_websocket_forward(ws_client, ws_server),
],
return_when=asyncio.FIRST_COMPLETED,
)
return ws_server
async def _handle_request(
self, request: web.Request, addon: Addon, path: str
) -> Union[web.Response, web.StreamResponse]:
"""Ingress route for request."""
url = self._create_url(addon, path)
data = await request.read()
source_header = _init_header(request, addon)
async with self.sys_websession.request(
request.method, url, headers=source_header, params=request.query, data=data
) as result:
headers = _response_header(result)
# Simple request
if (
hdrs.CONTENT_LENGTH in result.headers
and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000
):
# Return Response
body = await result.read()
return web.Response(headers=headers, status=result.status, body=body)
# Stream response
response = web.StreamResponse(status=result.status, headers=headers)
response.content_type = result.content_type
try:
await response.prepare(request)
async for data in result.content.iter_chunked(4096):
await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err:
_LOGGER.error("Stream error with %s: %s", url, err)
return response
def _init_header(
request: web.Request, addon: str
) -> Union[CIMultiDict, Dict[str, str]]:
"""Create initial header."""
headers = {}
# filter flags
for name, value in request.headers.items():
if name in (
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_TYPE,
hdrs.CONTENT_ENCODING,
istr(HEADER_TOKEN),
):
continue
headers[name] = value
# Update X-Forwarded-For
forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
headers[hdrs.X_FORWARDED_FOR] = f"{forward_for}, {connected_ip!s}"
return headers
def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
"""Create response header."""
headers = {}
for name, value in response.headers.items():
if name in (
hdrs.TRANSFER_ENCODING,
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_TYPE,
hdrs.CONTENT_ENCODING,
):
continue
headers[name] = value
return headers
def _is_websocket(request: web.Request) -> bool:
"""Return True if request is a websocket."""
headers = request.headers
if (
headers.get(hdrs.CONNECTION) == "Upgrade"
and headers.get(hdrs.UPGRADE) == "websocket"
):
return True
return False
async def _websocket_forward(ws_from, ws_to):
"""Handle websocket message directly."""
async for msg in ws_from:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws_to.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await ws_to.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.PING:
await ws_to.ping()
elif msg.type == aiohttp.WSMsgType.PONG:
await ws_to.pong()
elif ws_to.closed:
await ws_to.close(code=ws_to.close_code, message=msg.extra)

View File

@ -6,12 +6,19 @@ from aiohttp.web import middleware
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPForbidden from aiohttp.web_exceptions import HTTPUnauthorized, HTTPForbidden
from ..const import ( from ..const import (
HEADER_TOKEN, REQUEST_FROM, ROLE_ADMIN, ROLE_DEFAULT, ROLE_HOMEASSISTANT, HEADER_TOKEN,
ROLE_MANAGER, ROLE_BACKUP) REQUEST_FROM,
ROLE_ADMIN,
ROLE_DEFAULT,
ROLE_HOMEASSISTANT,
ROLE_MANAGER,
ROLE_BACKUP,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# fmt: off
# Block Anytime # Block Anytime
BLACKLIST = re.compile( BLACKLIST = re.compile(
@ -74,6 +81,8 @@ ADDONS_ROLE_ACCESS = {
), ),
} }
# fmt: off
class SecurityMiddleware(CoreSysAttributes): class SecurityMiddleware(CoreSysAttributes):
"""Security middleware functions.""" """Security middleware functions."""
@ -104,9 +113,7 @@ class SecurityMiddleware(CoreSysAttributes):
raise HTTPUnauthorized() raise HTTPUnauthorized()
# Home-Assistant # Home-Assistant
# UUID check need removed with 131 if hassio_token == self.sys_homeassistant.hassio_token:
if hassio_token in (self.sys_homeassistant.uuid,
self.sys_homeassistant.hassio_token):
_LOGGER.debug("%s access from Home Assistant", request.path) _LOGGER.debug("%s access from Home Assistant", request.path)
request_from = self.sys_homeassistant request_from = self.sys_homeassistant

View File

@ -1,34 +1,57 @@
"""Init file for Hass.io Supervisor RESTful API.""" """Init file for Hass.io Supervisor RESTful API."""
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable, Dict
from aiohttp import web
import voluptuous as vol import voluptuous as vol
from .utils import api_process, api_process_raw, api_validate
from ..const import ( from ..const import (
ATTR_ADDONS, ATTR_VERSION, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_ARCH, ATTR_ADDONS,
HASSIO_VERSION, ATTR_ADDONS_REPOSITORIES, ATTR_LOGO, ATTR_REPOSITORY, ATTR_ADDONS_REPOSITORIES,
ATTR_DESCRIPTON, ATTR_NAME, ATTR_SLUG, ATTR_INSTALLED, ATTR_TIMEZONE, ATTR_ARCH,
ATTR_STATE, ATTR_WAIT_BOOT, ATTR_CPU_PERCENT, ATTR_MEMORY_USAGE, ATTR_BLK_READ,
ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX, ATTR_BLK_READ, ATTR_BLK_WRITE,
ATTR_BLK_WRITE, CONTENT_TYPE_BINARY, ATTR_ICON) ATTR_CHANNEL,
ATTR_CPU_PERCENT,
ATTR_DESCRIPTON,
ATTR_ICON,
ATTR_INSTALLED,
ATTR_LAST_VERSION,
ATTR_LOGO,
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_USAGE,
ATTR_NAME,
ATTR_NETWORK_RX,
ATTR_NETWORK_TX,
ATTR_REPOSITORY,
ATTR_SLUG,
ATTR_STATE,
ATTR_TIMEZONE,
ATTR_VERSION,
ATTR_WAIT_BOOT,
ATTR_IP_ADDRESS,
CONTENT_TYPE_BINARY,
HASSIO_VERSION,
)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..validate import WAIT_BOOT, REPOSITORIES, CHANNELS
from ..exceptions import APIError from ..exceptions import APIError
from ..utils.validate import validate_timezone from ..utils.validate import validate_timezone
from ..validate import CHANNELS, REPOSITORIES, WAIT_BOOT
from .utils import api_process, api_process_raw, api_validate
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCHEMA_OPTIONS = vol.Schema({ SCHEMA_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_CHANNEL): CHANNELS, vol.Optional(ATTR_CHANNEL): CHANNELS,
vol.Optional(ATTR_ADDONS_REPOSITORIES): REPOSITORIES, vol.Optional(ATTR_ADDONS_REPOSITORIES): REPOSITORIES,
vol.Optional(ATTR_TIMEZONE): validate_timezone, vol.Optional(ATTR_TIMEZONE): validate_timezone,
vol.Optional(ATTR_WAIT_BOOT): WAIT_BOOT, vol.Optional(ATTR_WAIT_BOOT): WAIT_BOOT,
}) }
)
SCHEMA_VERSION = vol.Schema({ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
vol.Optional(ATTR_VERSION): vol.Coerce(str),
})
class APISupervisor(CoreSysAttributes): class APISupervisor(CoreSysAttributes):
@ -40,12 +63,13 @@ class APISupervisor(CoreSysAttributes):
return True return True
@api_process @api_process
async def info(self, request): 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.list_addons:
if addon.is_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,
@ -55,13 +79,15 @@ class APISupervisor(CoreSysAttributes):
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,
ATTR_LAST_VERSION: self.sys_updater.version_hassio, ATTR_LAST_VERSION: self.sys_updater.version_hassio,
ATTR_CHANNEL: self.sys_updater.channel, ATTR_CHANNEL: self.sys_updater.channel,
ATTR_ARCH: self.sys_supervisor.arch, ATTR_ARCH: self.sys_supervisor.arch,
ATTR_IP_ADDRESS: str(self.sys_supervisor.ip_address),
ATTR_WAIT_BOOT: self.sys_config.wait_boot, ATTR_WAIT_BOOT: self.sys_config.wait_boot,
ATTR_TIMEZONE: self.sys_config.timezone, ATTR_TIMEZONE: self.sys_config.timezone,
ATTR_ADDONS: list_addons, ATTR_ADDONS: list_addons,
@ -69,7 +95,7 @@ class APISupervisor(CoreSysAttributes):
} }
@api_process @api_process
async def options(self, request): async def options(self, request: web.Request) -> None:
"""Set Supervisor options.""" """Set Supervisor options."""
body = await api_validate(SCHEMA_OPTIONS, request) body = await api_validate(SCHEMA_OPTIONS, request)
@ -88,14 +114,11 @@ class APISupervisor(CoreSysAttributes):
self.sys_updater.save_data() self.sys_updater.save_data()
self.sys_config.save_data() self.sys_config.save_data()
return True
@api_process @api_process
async def stats(self, request): async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information.""" """Return resource information."""
stats = await self.sys_supervisor.stats() stats = await self.sys_supervisor.stats()
if not stats:
raise APIError("No stats available")
return { return {
ATTR_CPU_PERCENT: stats.cpu_percent, ATTR_CPU_PERCENT: stats.cpu_percent,
@ -108,31 +131,21 @@ class APISupervisor(CoreSysAttributes):
} }
@api_process @api_process
async def update(self, request): async def update(self, request: web.Request) -> None:
"""Update Supervisor OS.""" """Update Supervisor OS."""
body = await api_validate(SCHEMA_VERSION, request) body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_updater.version_hassio) version = body.get(ATTR_VERSION, self.sys_updater.version_hassio)
if version == self.sys_supervisor.version: if version == self.sys_supervisor.version:
raise APIError("Version {} is already in use".format(version)) raise APIError("Version {} is already in use".format(version))
await asyncio.shield(self.sys_supervisor.update(version))
return await asyncio.shield(self.sys_supervisor.update(version))
@api_process @api_process
async def reload(self, request): def reload(self, request: web.Request) -> Awaitable[None]:
"""Reload add-ons, configuration, etc.""" """Reload add-ons, configuration, etc."""
tasks = [ return asyncio.shield(self.sys_updater.reload())
self.sys_updater.reload(),
]
results, _ = await asyncio.shield(asyncio.wait(tasks))
for result in results:
if result.exception() is not None:
raise APIError("Some reload task fails!")
return True
@api_process_raw(CONTENT_TYPE_BINARY) @api_process_raw(CONTENT_TYPE_BINARY)
def logs(self, request): def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return supervisor Docker logs.""" """Return supervisor Docker logs."""
return self.sys_supervisor.logs() return self.sys_supervisor.logs()

View File

@ -2,6 +2,7 @@
from pathlib import Path from pathlib import Path
from ipaddress import ip_network from ipaddress import ip_network
HASSIO_VERSION = "153" HASSIO_VERSION = "153"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
@ -51,8 +52,8 @@ CONTENT_TYPE_JSON = "application/json"
CONTENT_TYPE_TEXT = "text/plain" CONTENT_TYPE_TEXT = "text/plain"
CONTENT_TYPE_TAR = "application/tar" CONTENT_TYPE_TAR = "application/tar"
CONTENT_TYPE_URL = "application/x-www-form-urlencoded" CONTENT_TYPE_URL = "application/x-www-form-urlencoded"
HEADER_HA_ACCESS = "x-ha-access" HEADER_HA_ACCESS = "X-Ha-Access"
HEADER_TOKEN = "x-hassio-key" HEADER_TOKEN = "X-Hassio-Key"
ENV_TOKEN = "HASSIO_TOKEN" ENV_TOKEN = "HASSIO_TOKEN"
ENV_TIME = "TZ" ENV_TIME = "TZ"
@ -187,6 +188,12 @@ ATTR_SUPERVISOR = "supervisor"
ATTR_AUTH_API = "auth_api" ATTR_AUTH_API = "auth_api"
ATTR_KERNEL_MODULES = "kernel_modules" ATTR_KERNEL_MODULES = "kernel_modules"
ATTR_SUPPORTED_ARCH = "supported_arch" ATTR_SUPPORTED_ARCH = "supported_arch"
ATTR_INGRESS = "ingress"
ATTR_INGRESS_PORT = "ingress_port"
ATTR_INGRESS_ENTRY = "ingress_entry"
ATTR_INGRESS_TOKEN = "ingress_token"
ATTR_INGRESS_URL = "ingress_url"
ATTR_IP_ADDRESS = "ip_address"
PROVIDE_SERVICE = "provide" PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"

View File

@ -1,12 +1,14 @@
"""Init file for Hass.io Docker object.""" """Init file for Hass.io Docker object."""
from contextlib import suppress
import logging import logging
from contextlib import suppress
from typing import Any, Dict, Optional
import attr import attr
import docker import docker
from .network import DockerNetwork
from ..const import SOCKET_DOCKER from ..const import SOCKET_DOCKER
from ..exceptions import DockerAPIError
from .network import DockerNetwork
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -14,8 +16,9 @@ _LOGGER = logging.getLogger(__name__)
@attr.s(frozen=True) @attr.s(frozen=True)
class CommandReturn: class CommandReturn:
"""Return object from command run.""" """Return object from command run."""
exit_code = attr.ib()
output = attr.ib() exit_code: int = attr.ib()
output: bytes = attr.ib()
class DockerAPI: class DockerAPI:
@ -26,75 +29,87 @@ class DockerAPI:
def __init__(self): def __init__(self):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
self.docker = docker.DockerClient( self.docker: docker.DockerClient = docker.DockerClient(
base_url="unix:/{}".format(str(SOCKET_DOCKER)), base_url="unix:/{}".format(str(SOCKET_DOCKER)), version="auto", timeout=900
version='auto', timeout=900) )
self.network = DockerNetwork(self.docker) self.network: DockerNetwork = DockerNetwork(self.docker)
@property @property
def images(self): def images(self) -> docker.models.images.ImageCollection:
"""Return API images.""" """Return API images."""
return self.docker.images return self.docker.images
@property @property
def containers(self): def containers(self) -> docker.models.containers.ContainerCollection:
"""Return API containers.""" """Return API containers."""
return self.docker.containers return self.docker.containers
@property @property
def api(self): def api(self) -> docker.APIClient:
"""Return API containers.""" """Return API containers."""
return self.docker.api return self.docker.api
def run(self, image, **kwargs): def run(
self, image: str, **kwargs: Dict[str, Any]
) -> docker.models.containers.Container:
""""Create a Docker container and run it. """"Create a Docker container and run it.
Need run inside executor. Need run inside executor.
""" """
name = kwargs.get('name', image) name = kwargs.get("name", image)
network_mode = kwargs.get('network_mode') network_mode = kwargs.get("network_mode")
hostname = kwargs.get('hostname') hostname = kwargs.get("hostname")
# Setup network # Setup network
kwargs['dns_search'] = ["."] kwargs["dns_search"] = ["."]
if network_mode: if network_mode:
kwargs['dns'] = [str(self.network.supervisor)] kwargs["dns"] = [str(self.network.supervisor)]
kwargs['dns_opt'] = ["ndots:0"] kwargs["dns_opt"] = ["ndots:0"]
else: else:
kwargs['network'] = None kwargs["network"] = None
# Create container # Create container
try: try:
container = self.docker.containers.create( container = self.docker.containers.create(
image, use_config_proxy=False, **kwargs) image, use_config_proxy=False, **kwargs
)
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't create container from %s: %s", name, err) _LOGGER.error("Can't create container from %s: %s", name, err)
return False raise DockerAPIError() from None
# attach network # Attach network
if not network_mode: if not network_mode:
alias = [hostname] if hostname else None alias = [hostname] if hostname else None
if self.network.attach_container(container, alias=alias): try:
self.network.detach_default_bridge(container) self.network.attach_container(container, alias=alias)
else: except DockerAPIError:
_LOGGER.warning("Can't attach %s to hassio-net!", name) _LOGGER.warning("Can't attach %s to hassio-net!", name)
else:
with suppress(DockerAPIError):
self.network.detach_default_bridge(container)
# run container # Run container
try: try:
container.start() container.start()
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't start %s: %s", name, err) _LOGGER.error("Can't start %s: %s", name, err)
return False raise DockerAPIError() from None
return True # Update metadata
with suppress(docker.errors.DockerException):
container.reload()
def run_command(self, image, command=None, **kwargs): return container
def run_command(
self, image: str, command: Optional[str] = None, **kwargs: Dict[str, Any]
) -> CommandReturn:
"""Create a temporary container and run command. """Create a temporary container and run command.
Need run inside executor. Need run inside executor.
""" """
stdout = kwargs.get('stdout', True) stdout = kwargs.get("stdout", True)
stderr = kwargs.get('stderr', True) stderr = kwargs.get("stderr", True)
_LOGGER.info("Run command '%s' on %s", command, image) _LOGGER.info("Run command '%s' on %s", command, image)
try: try:
@ -112,11 +127,11 @@ class DockerAPI:
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't execute command: %s", err) _LOGGER.error("Can't execute command: %s", err)
return CommandReturn(None, b"") raise DockerAPIError() from None
finally: finally:
# cleanup container # cleanup container
with suppress(docker.errors.DockerException): with suppress(docker.errors.DockerException):
container.remove(force=True) container.remove(force=True)
return CommandReturn(result.get('StatusCode'), output) return CommandReturn(result.get("StatusCode"), output)

View File

@ -1,15 +1,35 @@
"""Init file for Hass.io add-on Docker object.""" """Init file for Hass.io add-on Docker object."""
from __future__ import annotations
from contextlib import suppress
from ipaddress import IPv4Address, ip_address
import logging import logging
import os import os
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Union, Awaitable
import docker import docker
import requests import requests
from .interface import DockerInterface
from ..addons.build import AddonBuild from ..addons.build import AddonBuild
from ..const import (MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE, from ..const import (
ENV_TOKEN, ENV_TIME, SECURITY_PROFILE, SECURITY_DISABLE) ENV_TIME,
ENV_TOKEN,
MAP_ADDONS,
MAP_BACKUP,
MAP_CONFIG,
MAP_SHARE,
MAP_SSL,
SECURITY_DISABLE,
SECURITY_PROFILE,
)
from ..coresys import CoreSys
from ..exceptions import DockerAPIError
from ..utils import process_lock from ..utils import process_lock
from .interface import DockerInterface
if TYPE_CHECKING:
from ..addons.addon import Addon
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -19,64 +39,77 @@ 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, slug): def __init__(self, coresys: CoreSys, slug: str):
"""Initialize Docker Home Assistant wrapper.""" """Initialize Docker Home Assistant wrapper."""
super().__init__(coresys) super().__init__(coresys)
self._id = slug self._id: str = slug
@property @property
def addon(self): def addon(self) -> Addon:
"""Return add-on of Docker image.""" """Return add-on of Docker image."""
return self.sys_addons.get(self._id) return self.sys_addons.get(self._id)
@property @property
def image(self): def image(self) -> str:
"""Return name of Docker image.""" """Return name of Docker image."""
return self.addon.image return self.addon.image
@property @property
def timeout(self): def ip_address(self) -> IPv4Address:
"""Return IP address of this container."""
if self.addon.host_network:
return self.sys_docker.network.gateway
# Extract IP-Address
try:
return ip_address(
self._meta["NetworkSettings"]["Networks"]["hassio"]["IPAddress"])
except (KeyError, TypeError, ValueError):
return ip_address("0.0.0.0")
@property
def timeout(self) -> int:
"""Return timeout for Docker actions.""" """Return timeout for Docker actions."""
return self.addon.timeout return self.addon.timeout
@property @property
def version(self): 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_installed
return super().version return super().version
@property @property
def arch(self): def arch(self) -> str:
"""Return arch of Docker image.""" """Return arch of Docker image."""
if self.addon.legacy: if self.addon.legacy:
return self.sys_arch.default return self.sys_arch.default
return super().arch return super().arch
@property @property
def name(self): def name(self) -> str:
"""Return name of Docker container.""" """Return name of Docker container."""
return "addon_{}".format(self.addon.slug) return f"addon_{self.addon.slug}"
@property @property
def ipc(self): def ipc(self) -> Optional[str]:
"""Return the IPC namespace.""" """Return the IPC namespace."""
if self.addon.host_ipc: if self.addon.host_ipc:
return 'host' return "host"
return None return None
@property @property
def full_access(self): def full_access(self) -> bool:
"""Return True if full access is enabled.""" """Return True if full access is enabled."""
return not self.addon.protected and self.addon.with_full_access return not self.addon.protected and self.addon.with_full_access
@property @property
def hostname(self): def hostname(self) -> str:
"""Return slug/id of add-on.""" """Return slug/id of add-on."""
return self.addon.slug.replace('_', '-') return self.addon.slug.replace("_", "-")
@property @property
def environment(self): def environment(self) -> Dict[str, str]:
"""Return environment for Docker add-on.""" """Return environment for Docker add-on."""
addon_env = self.addon.environment or {} addon_env = self.addon.environment or {}
@ -86,8 +119,7 @@ class DockerAddon(DockerInterface):
if isinstance(value, (int, str)): if isinstance(value, (int, str)):
addon_env[key] = value addon_env[key] = value
else: else:
_LOGGER.warning( _LOGGER.warning("Can not set nested option %s as Docker env", key)
"Can not set nested option %s as Docker env", key)
return { return {
**addon_env, **addon_env,
@ -96,7 +128,7 @@ class DockerAddon(DockerInterface):
} }
@property @property
def devices(self): def devices(self) -> List[str]:
"""Return needed devices.""" """Return needed devices."""
devices = self.addon.devices or [] devices = self.addon.devices or []
@ -113,7 +145,7 @@ class DockerAddon(DockerInterface):
return devices or None return devices or None
@property @property
def ports(self): def ports(self) -> Optional[Dict[str, Union[str, int, None]]]:
"""Filter None from add-on ports.""" """Filter None from add-on ports."""
if not self.addon.ports: if not self.addon.ports:
return None return None
@ -125,7 +157,7 @@ class DockerAddon(DockerInterface):
} }
@property @property
def security_opt(self): def security_opt(self) -> List[str]:
"""Controlling security options.""" """Controlling security options."""
security = [] security = []
@ -143,7 +175,7 @@ class DockerAddon(DockerInterface):
return security return security
@property @property
def tmpfs(self): def tmpfs(self) -> Optional[Dict[str, str]]:
"""Return tmpfs for Docker add-on.""" """Return tmpfs for Docker add-on."""
options = self.addon.tmpfs options = self.addon.tmpfs
if options: if options:
@ -151,156 +183,148 @@ class DockerAddon(DockerInterface):
return None return None
@property @property
def network_mapping(self): def network_mapping(self) -> Dict[str, str]:
"""Return hosts mapping.""" """Return hosts mapping."""
return { return {
'homeassistant': self.sys_docker.network.gateway, "homeassistant": self.sys_docker.network.gateway,
'hassio': self.sys_docker.network.supervisor, "hassio": self.sys_docker.network.supervisor,
} }
@property @property
def network_mode(self): def network_mode(self) -> Optional[str]:
"""Return network mode for add-on.""" """Return network mode for add-on."""
if self.addon.host_network: if self.addon.host_network:
return 'host' return "host"
return None return None
@property @property
def pid_mode(self): def pid_mode(self) -> Optional[str]:
"""Return PID mode for add-on.""" """Return PID mode for add-on."""
if not self.addon.protected and self.addon.host_pid: if not self.addon.protected and self.addon.host_pid:
return 'host' return "host"
return None return None
@property @property
def volumes(self): def volumes(self) -> Dict[str, Dict[str, str]]:
"""Generate volumes for mappings.""" """Generate volumes for mappings."""
volumes = { volumes = {str(self.addon.path_extern_data): {"bind": "/data", "mode": "rw"}}
str(self.addon.path_extern_data): {
'bind': "/data",
'mode': 'rw'
}
}
addon_mapping = self.addon.map_volumes addon_mapping = self.addon.map_volumes
# setup config mappings # setup config mappings
if MAP_CONFIG in addon_mapping: if MAP_CONFIG in addon_mapping:
volumes.update({ volumes.update(
{
str(self.sys_config.path_extern_homeassistant): { str(self.sys_config.path_extern_homeassistant): {
'bind': "/config", "bind": "/config",
'mode': addon_mapping[MAP_CONFIG] "mode": addon_mapping[MAP_CONFIG],
} }
}) }
)
if MAP_SSL in addon_mapping: if MAP_SSL in addon_mapping:
volumes.update({ volumes.update(
{
str(self.sys_config.path_extern_ssl): { str(self.sys_config.path_extern_ssl): {
'bind': "/ssl", "bind": "/ssl",
'mode': addon_mapping[MAP_SSL] "mode": addon_mapping[MAP_SSL],
} }
}) }
)
if MAP_ADDONS in addon_mapping: if MAP_ADDONS in addon_mapping:
volumes.update({ volumes.update(
{
str(self.sys_config.path_extern_addons_local): { str(self.sys_config.path_extern_addons_local): {
'bind': "/addons", "bind": "/addons",
'mode': addon_mapping[MAP_ADDONS] "mode": addon_mapping[MAP_ADDONS],
} }
}) }
)
if MAP_BACKUP in addon_mapping: if MAP_BACKUP in addon_mapping:
volumes.update({ volumes.update(
{
str(self.sys_config.path_extern_backup): { str(self.sys_config.path_extern_backup): {
'bind': "/backup", "bind": "/backup",
'mode': addon_mapping[MAP_BACKUP] "mode": addon_mapping[MAP_BACKUP],
} }
}) }
)
if MAP_SHARE in addon_mapping: if MAP_SHARE in addon_mapping:
volumes.update({ volumes.update(
{
str(self.sys_config.path_extern_share): { str(self.sys_config.path_extern_share): {
'bind': "/share", "bind": "/share",
'mode': addon_mapping[MAP_SHARE] "mode": addon_mapping[MAP_SHARE],
} }
}) }
)
# Init other hardware mappings # Init other hardware mappings
# GPIO support # GPIO support
if self.addon.with_gpio and self.sys_hardware.support_gpio: if self.addon.with_gpio and self.sys_hardware.support_gpio:
for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"): for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"):
volumes.update({ volumes.update({gpio_path: {"bind": gpio_path, "mode": "rw"}})
gpio_path: {
'bind': gpio_path,
'mode': 'rw'
},
})
# DeviceTree support # DeviceTree support
if self.addon.with_devicetree: if self.addon.with_devicetree:
volumes.update({ volumes.update(
{
"/sys/firmware/devicetree/base": { "/sys/firmware/devicetree/base": {
'bind': "/device-tree", "bind": "/device-tree",
'mode': 'ro' "mode": "ro",
}, }
}) }
)
# Kernel Modules support # Kernel Modules support
if self.addon.with_kernel_modules: if self.addon.with_kernel_modules:
volumes.update({ volumes.update({"/lib/modules": {"bind": "/lib/modules", "mode": "ro"}})
"/lib/modules": {
'bind': "/lib/modules",
'mode': 'ro'
},
})
# Docker API support # Docker API support
if not self.addon.protected and self.addon.access_docker_api: if not self.addon.protected and self.addon.access_docker_api:
volumes.update({ volumes.update(
"/var/run/docker.sock": { {"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "ro"}}
'bind': "/var/run/docker.sock", )
'mode': 'ro'
},
})
# Host D-Bus system # Host D-Bus system
if self.addon.host_dbus: if self.addon.host_dbus:
volumes.update({ volumes.update({"/var/run/dbus": {"bind": "/var/run/dbus", "mode": "rw"}})
"/var/run/dbus": {
'bind': "/var/run/dbus",
'mode': 'rw'
}
})
# ALSA configuration # ALSA configuration
if self.addon.with_audio: if self.addon.with_audio:
volumes.update({ volumes.update(
{
str(self.addon.path_extern_asound): { str(self.addon.path_extern_asound): {
'bind': "/etc/asound.conf", "bind": "/etc/asound.conf",
'mode': 'ro' "mode": "ro",
} }
}) }
)
return volumes return volumes
def _run(self): def _run(self) -> None:
"""Run Docker image. """Run Docker image.
Need run inside executor. Need run inside executor.
""" """
if self._is_running(): if self._is_running():
return True return
# Security check # Security check
if not self.addon.protected: if not self.addon.protected:
_LOGGER.warning("%s run with disabled protected mode!", _LOGGER.warning("%s run with disabled protected mode!", self.addon.name)
self.addon.name)
# cleanup # Cleanup
with suppress(DockerAPIError):
self._stop() self._stop()
ret = self.sys_docker.run( # Create & Run container
docker_container = self.sys_docker.run(
self.image, self.image,
name=self.name, name=self.name,
hostname=self.hostname, hostname=self.hostname,
@ -318,25 +342,23 @@ class DockerAddon(DockerInterface):
security_opt=self.security_opt, security_opt=self.security_opt,
environment=self.environment, environment=self.environment,
volumes=self.volumes, volumes=self.volumes,
tmpfs=self.tmpfs) tmpfs=self.tmpfs,
)
if ret: _LOGGER.info("Start Docker add-on %s with version %s", self.image, self.version)
_LOGGER.info("Start Docker add-on %s with version %s", self.image, self._meta = docker_container.attrs
self.version)
return ret def _install(self, tag: str, image: Optional[str] = None) -> None:
def _install(self, tag, image=None):
"""Pull Docker image or build it. """Pull Docker image or build it.
Need run inside executor. Need run inside executor.
""" """
if self.addon.need_build: if self.addon.need_build:
return self._build(tag) self._build(tag)
return super()._install(tag, image) super()._install(tag, image)
def _build(self, tag): def _build(self, tag: str) -> None:
"""Build a Docker container. """Build a Docker container.
Need run inside executor. Need run inside executor.
@ -346,27 +368,27 @@ class DockerAddon(DockerInterface):
_LOGGER.info("Start build %s:%s", self.image, tag) _LOGGER.info("Start build %s:%s", self.image, tag)
try: try:
image, log = self.sys_docker.images.build( image, log = self.sys_docker.images.build(
use_config_proxy=False, **build_env.get_docker_args(tag)) use_config_proxy=False, **build_env.get_docker_args(tag)
)
_LOGGER.debug("Build %s:%s done: %s", self.image, tag, log) _LOGGER.debug("Build %s:%s done: %s", self.image, tag, log)
image.tag(self.image, tag='latest') image.tag(self.image, tag="latest")
# Update meta data # Update meta data
self._meta = image.attrs self._meta = image.attrs
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't build %s:%s: %s", self.image, tag, err) _LOGGER.error("Can't build %s:%s: %s", self.image, tag, err)
return False raise DockerAPIError() from None
_LOGGER.info("Build %s:%s done", self.image, tag) _LOGGER.info("Build %s:%s done", self.image, tag)
return True
@process_lock @process_lock
def export_image(self, path): def export_image(self, tar_file: Path) -> Awaitable[None]:
"""Export current images into a tar file.""" """Export current images into a tar file."""
return self.sys_run_in_executor(self._export_image, path) return self.sys_run_in_executor(self._export_image, tar_file)
def _export_image(self, tar_file): def _export_image(self, tar_file: Path) -> None:
"""Export current images into a tar file. """Export current images into a tar file.
Need run inside executor. Need run inside executor.
@ -375,7 +397,7 @@ class DockerAddon(DockerInterface):
image = self.sys_docker.api.get_image(self.image) image = self.sys_docker.api.get_image(self.image)
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't fetch image %s: %s", self.image, err) _LOGGER.error("Can't fetch image %s: %s", self.image, err)
return False raise DockerAPIError() from None
_LOGGER.info("Export image %s to %s", self.image, tar_file) _LOGGER.info("Export image %s to %s", self.image, tar_file)
try: try:
@ -384,17 +406,16 @@ class DockerAddon(DockerInterface):
write_tar.write(chunk) write_tar.write(chunk)
except (OSError, requests.exceptions.ReadTimeout) as err: except (OSError, requests.exceptions.ReadTimeout) as err:
_LOGGER.error("Can't write tar file %s: %s", tar_file, err) _LOGGER.error("Can't write tar file %s: %s", tar_file, err)
return False raise DockerAPIError() from None
_LOGGER.info("Export image %s done", self.image) _LOGGER.info("Export image %s done", self.image)
return True
@process_lock @process_lock
def import_image(self, path, tag): def import_image(self, tar_file: Path, tag: str) -> Awaitable[None]:
"""Import a tar file as image.""" """Import a tar file as image."""
return self.sys_run_in_executor(self._import_image, path, tag) return self.sys_run_in_executor(self._import_image, tar_file, tag)
def _import_image(self, tar_file, tag): def _import_image(self, tar_file: Path, tag: str) -> None:
"""Import a tar file as image. """Import a tar file as image.
Need run inside executor. Need run inside executor.
@ -403,37 +424,38 @@ class DockerAddon(DockerInterface):
with tar_file.open("rb") as read_tar: with tar_file.open("rb") as read_tar:
self.sys_docker.api.load_image(read_tar, quiet=True) self.sys_docker.api.load_image(read_tar, quiet=True)
image = self.sys_docker.images.get(self.image) docker_image = self.sys_docker.images.get(self.image)
image.tag(self.image, tag=tag) docker_image.tag(self.image, tag=tag)
except (docker.errors.DockerException, OSError) as err: except (docker.errors.DockerException, OSError) as err:
_LOGGER.error("Can't import image %s: %s", self.image, err) _LOGGER.error("Can't import image %s: %s", self.image, err)
return False raise DockerAPIError() from None
_LOGGER.info("Import image %s and tag %s", tar_file, tag) _LOGGER.info("Import image %s and tag %s", tar_file, tag)
self._meta = image.attrs self._meta = docker_image.attrs
with suppress(DockerAPIError):
self._cleanup() self._cleanup()
return True
@process_lock @process_lock
def write_stdin(self, data): def write_stdin(self, data: bytes) -> Awaitable[None]:
"""Write to add-on stdin.""" """Write to add-on stdin."""
return self.sys_run_in_executor(self._write_stdin, data) return self.sys_run_in_executor(self._write_stdin, data)
def _write_stdin(self, data): def _write_stdin(self, data: bytes) -> None:
"""Write to add-on stdin. """Write to add-on stdin.
Need run inside executor. Need run inside executor.
""" """
if not self._is_running(): if not self._is_running():
return False raise DockerAPIError() from None
try: try:
# Load needed docker objects # Load needed docker objects
container = self.sys_docker.containers.get(self.name) container = self.sys_docker.containers.get(self.name)
socket = container.attach_socket(params={'stdin': 1, 'stream': 1}) socket = container.attach_socket(params={"stdin": 1, "stream": 1})
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't attach to %s stdin: %s", self.name, err) _LOGGER.error("Can't attach to %s stdin: %s", self.name, err)
return False raise DockerAPIError() from None
try: try:
# Write to stdin # Write to stdin
@ -442,6 +464,4 @@ class DockerAddon(DockerInterface):
socket.close() socket.close()
except OSError as err: except OSError as err:
_LOGGER.error("Can't write to %s stdin: %s", self.name, err) _LOGGER.error("Can't write to %s stdin: %s", self.name, err)
return False raise DockerAPIError() from None
return True

View File

@ -1,10 +1,14 @@
"""Init file for Hass.io Docker object.""" """Init file for Hass.io Docker object."""
from contextlib import suppress
from ipaddress import IPv4Address
import logging import logging
from typing import Awaitable
import docker import docker
from .interface import DockerInterface from ..const import ENV_TIME, ENV_TOKEN, LABEL_MACHINE
from ..const import ENV_TOKEN, ENV_TIME, LABEL_MACHINE from ..exceptions import DockerAPIError
from .interface import CommandReturn, DockerInterface
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -39,18 +43,25 @@ class DockerHomeAssistant(DockerInterface):
devices.append(f"{device}:{device}:rwm") devices.append(f"{device}:{device}:rwm")
return devices or None return devices or None
def _run(self): @property
def ip_address(self) -> IPv4Address:
"""Return IP address of this container."""
return self.sys_docker.network.gateway
def _run(self) -> None:
"""Run Docker image. """Run Docker image.
Need run inside executor. Need run inside executor.
""" """
if self._is_running(): if self._is_running():
return False return
# cleanup # Cleanup
with suppress(DockerAPIError):
self._stop() self._stop()
ret = self.sys_docker.run( # Create & Run container
docker_container = self.sys_docker.run(
self.image, self.image,
name=self.name, name=self.name,
hostname=self.name, hostname=self.name,
@ -77,14 +88,10 @@ class DockerHomeAssistant(DockerInterface):
}, },
) )
if ret: _LOGGER.info("Start homeassistant %s with version %s", self.image, self.version)
_LOGGER.info( self._meta = docker_container.attrs
"Start homeassistant %s with version %s", self.image, self.version
)
return ret def _execute_command(self, command: str) -> CommandReturn:
def _execute_command(self, command):
"""Create a temporary container and run command. """Create a temporary container and run command.
Need run inside executor. Need run inside executor.
@ -112,11 +119,11 @@ class DockerHomeAssistant(DockerInterface):
}, },
) )
def is_initialize(self): def is_initialize(self) -> Awaitable[bool]:
"""Return True if Docker container exists.""" """Return True if Docker container exists."""
return self.sys_run_in_executor(self._is_initialize) return self.sys_run_in_executor(self._is_initialize)
def _is_initialize(self): def _is_initialize(self) -> bool:
"""Return True if docker container exists. """Return True if docker container exists.
Need run inside executor. Need run inside executor.

View File

@ -2,13 +2,16 @@
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
import logging import logging
from typing import Any, Dict, Optional, Awaitable
import docker import docker
from ..const import LABEL_ARCH, LABEL_VERSION from ..const import LABEL_ARCH, LABEL_VERSION
from ..coresys import CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import DockerAPIError
from ..utils import process_lock from ..utils import process_lock
from .stats import DockerStats from .stats import DockerStats
from . import CommandReturn
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -16,60 +19,60 @@ _LOGGER = logging.getLogger(__name__)
class DockerInterface(CoreSysAttributes): class DockerInterface(CoreSysAttributes):
"""Docker Hass.io interface.""" """Docker Hass.io interface."""
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._meta = None self._meta: Optional[Dict[str, Any]] = None
self.lock = asyncio.Lock(loop=coresys.loop) self.lock: asyncio.Lock = asyncio.Lock(loop=coresys.loop)
@property @property
def timeout(self): def timeout(self) -> str:
"""Return timeout for Docker actions.""" """Return timeout for Docker actions."""
return 30 return 30
@property @property
def name(self): def name(self) -> Optional[str]:
"""Return name of Docker container.""" """Return name of Docker container."""
return None return None
@property @property
def meta_config(self): def meta_config(self) -> Dict[str, Any]:
"""Return meta data of configuration for container/image.""" """Return meta data of configuration for container/image."""
if not self._meta: if not self._meta:
return {} return {}
return self._meta.get("Config", {}) return self._meta.get("Config", {})
@property @property
def meta_labels(self): def meta_labels(self) -> Dict[str, str]:
"""Return meta data of labels for container/image.""" """Return meta data of labels for container/image."""
return self.meta_config.get("Labels") or {} return self.meta_config.get("Labels") or {}
@property @property
def image(self): def image(self) -> Optional[str]:
"""Return name of Docker image.""" """Return name of Docker image."""
return self.meta_config.get("Image") return self.meta_config.get("Image")
@property @property
def version(self): def version(self) -> Optional[str]:
"""Return version of Docker image.""" """Return version of Docker image."""
return self.meta_labels.get(LABEL_VERSION) return self.meta_labels.get(LABEL_VERSION)
@property @property
def arch(self): def arch(self) -> Optional[str]:
"""Return arch of Docker image.""" """Return arch of Docker image."""
return self.meta_labels.get(LABEL_ARCH) return self.meta_labels.get(LABEL_ARCH)
@property @property
def in_progress(self): def in_progress(self) -> bool:
"""Return True if a task is in progress.""" """Return True if a task is in progress."""
return self.lock.locked() return self.lock.locked()
@process_lock @process_lock
def install(self, tag, image=None): def install(self, tag: str, image: Optional[str] = None):
"""Pull docker image.""" """Pull docker image."""
return self.sys_run_in_executor(self._install, tag, image) return self.sys_run_in_executor(self._install, tag, image)
def _install(self, tag, image=None): def _install(self, tag: str, image: Optional[str] = None) -> None:
"""Pull Docker image. """Pull Docker image.
Need run inside executor. Need run inside executor.
@ -80,20 +83,19 @@ class DockerInterface(CoreSysAttributes):
_LOGGER.info("Pull image %s tag %s.", image, tag) _LOGGER.info("Pull image %s tag %s.", image, tag)
docker_image = self.sys_docker.images.pull(f"{image}:{tag}") docker_image = self.sys_docker.images.pull(f"{image}:{tag}")
_LOGGER.info("Tag image %s with version %s as latest", image, tag)
docker_image.tag(image, tag="latest") docker_image.tag(image, tag="latest")
self._meta = docker_image.attrs
except docker.errors.APIError as err: except docker.errors.APIError as err:
_LOGGER.error("Can't install %s:%s -> %s.", image, tag, err) _LOGGER.error("Can't install %s:%s -> %s.", image, tag, err)
return False raise DockerAPIError() from None
else:
self._meta = docker_image.attrs
_LOGGER.info("Tag image %s with version %s as latest", image, tag) def exists(self) -> Awaitable[bool]:
return True
def exists(self):
"""Return True if Docker image exists in local repository.""" """Return True if Docker image exists in local repository."""
return self.sys_run_in_executor(self._exists) return self.sys_run_in_executor(self._exists)
def _exists(self): def _exists(self) -> bool:
"""Return True if Docker image exists in local repository. """Return True if Docker image exists in local repository.
Need run inside executor. Need run inside executor.
@ -106,14 +108,14 @@ class DockerInterface(CoreSysAttributes):
return True return True
def is_running(self): def is_running(self) -> Awaitable[bool]:
"""Return True if Docker is running. """Return True if Docker is running.
Return a Future. Return a Future.
""" """
return self.sys_run_in_executor(self._is_running) return self.sys_run_in_executor(self._is_running)
def _is_running(self): def _is_running(self) -> bool:
"""Return True if Docker is running. """Return True if Docker is running.
Need run inside executor. Need run inside executor.
@ -139,7 +141,7 @@ class DockerInterface(CoreSysAttributes):
"""Attach to running Docker container.""" """Attach to running Docker container."""
return self.sys_run_in_executor(self._attach) return self.sys_run_in_executor(self._attach)
def _attach(self): def _attach(self) -> None:
"""Attach to running docker container. """Attach to running docker container.
Need run inside executor. Need run inside executor.
@ -147,21 +149,21 @@ class DockerInterface(CoreSysAttributes):
try: try:
if self.image: if self.image:
self._meta = self.sys_docker.images.get(self.image).attrs self._meta = self.sys_docker.images.get(self.image).attrs
else:
self._meta = self.sys_docker.containers.get(self.name).attrs self._meta = self.sys_docker.containers.get(self.name).attrs
except docker.errors.DockerException: except docker.errors.DockerException:
return False pass
_LOGGER.info("Attach to image %s with version %s", self.image, self.version) # Successfull?
if not self._meta:
return True raise DockerAPIError() from None
_LOGGER.info("Attach to %s with version %s", self.image, self.version)
@process_lock @process_lock
def run(self): def run(self) -> Awaitable[None]:
"""Run Docker image.""" """Run Docker image."""
return self.sys_run_in_executor(self._run) return self.sys_run_in_executor(self._run)
def _run(self): def _run(self) -> None:
"""Run Docker image. """Run Docker image.
Need run inside executor. Need run inside executor.
@ -169,11 +171,11 @@ class DockerInterface(CoreSysAttributes):
raise NotImplementedError() raise NotImplementedError()
@process_lock @process_lock
def stop(self, remove_container=True): def stop(self, remove_container=True) -> Awaitable[None]:
"""Stop/remove Docker container.""" """Stop/remove Docker container."""
return self.sys_run_in_executor(self._stop, remove_container) return self.sys_run_in_executor(self._stop, remove_container)
def _stop(self, remove_container=True): def _stop(self, remove_container=True) -> None:
"""Stop/remove Docker container. """Stop/remove Docker container.
Need run inside executor. Need run inside executor.
@ -181,26 +183,24 @@ class DockerInterface(CoreSysAttributes):
try: try:
docker_container = self.sys_docker.containers.get(self.name) docker_container = self.sys_docker.containers.get(self.name)
except docker.errors.DockerException: except docker.errors.DockerException:
return False raise DockerAPIError() from None
if docker_container.status == "running": if docker_container.status == "running":
_LOGGER.info("Stop %s Docker application", self.image) _LOGGER.info("Stop %s application", self.name)
with suppress(docker.errors.DockerException): with suppress(docker.errors.DockerException):
docker_container.stop(timeout=self.timeout) docker_container.stop(timeout=self.timeout)
if remove_container: if remove_container:
with suppress(docker.errors.DockerException): with suppress(docker.errors.DockerException):
_LOGGER.info("Clean %s Docker application", self.image) _LOGGER.info("Clean %s application", self.name)
docker_container.remove(force=True) docker_container.remove(force=True)
return True
@process_lock @process_lock
def start(self): def start(self) -> Awaitable[None]:
"""Start Docker container.""" """Start Docker container."""
return self.sys_run_in_executor(self._start) return self.sys_run_in_executor(self._start)
def _start(self): def _start(self) -> None:
"""Start docker container. """Start docker container.
Need run inside executor. Need run inside executor.
@ -208,31 +208,30 @@ class DockerInterface(CoreSysAttributes):
try: try:
docker_container = self.sys_docker.containers.get(self.name) docker_container = self.sys_docker.containers.get(self.name)
except docker.errors.DockerException: except docker.errors.DockerException:
return False raise DockerAPIError() from None
_LOGGER.info("Start %s", self.image) _LOGGER.info("Start %s", self.image)
try: try:
docker_container.start() docker_container.start()
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't start %s: %s", self.image, err) _LOGGER.error("Can't start %s: %s", self.image, err)
return False raise DockerAPIError() from None
return True
@process_lock @process_lock
def remove(self): def remove(self) -> Awaitable[None]:
"""Remove Docker images.""" """Remove Docker images."""
return self.sys_run_in_executor(self._remove) return self.sys_run_in_executor(self._remove)
def _remove(self): def _remove(self) -> None:
"""remove docker images. """remove docker images.
Need run inside executor. Need run inside executor.
""" """
# Cleanup container # Cleanup container
with suppress(DockerAPIError):
self._stop() self._stop()
_LOGGER.info("Remove Docker %s with latest and %s", self.image, self.version) _LOGGER.info("Remove image %s with latest and %s", self.image, self.version)
try: try:
with suppress(docker.errors.ImageNotFound): with suppress(docker.errors.ImageNotFound):
@ -245,17 +244,16 @@ class DockerInterface(CoreSysAttributes):
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.warning("Can't remove image %s: %s", self.image, err) _LOGGER.warning("Can't remove image %s: %s", self.image, err)
return False raise DockerAPIError() from None
self._meta = None self._meta = None
return True
@process_lock @process_lock
def update(self, tag, image=None): def update(self, tag: str, image: Optional[str] = None) -> Awaitable[None]:
"""Update a Docker image.""" """Update a Docker image."""
return self.sys_run_in_executor(self._update, tag, image) return self.sys_run_in_executor(self._update, tag, image)
def _update(self, tag, image=None): def _update(self, tag: str, image: Optional[str] = None) -> None:
"""Update a docker image. """Update a docker image.
Need run inside executor. Need run inside executor.
@ -263,27 +261,27 @@ class DockerInterface(CoreSysAttributes):
image = image or self.image image = image or self.image
_LOGGER.info( _LOGGER.info(
"Update Docker %s:%s to %s:%s", self.image, self.version, image, tag "Update image %s:%s to %s:%s", self.image, self.version, image, tag
) )
# Update docker image # Update docker image
if not self._install(tag, image): self._install(tag, image)
return False
# Stop container & cleanup # Stop container & cleanup
with suppress(DockerAPIError):
try:
self._stop() self._stop()
finally:
self._cleanup() self._cleanup()
return True def logs(self) -> Awaitable[bytes]:
def logs(self):
"""Return Docker logs of container. """Return Docker logs of container.
Return a Future. Return a Future.
""" """
return self.sys_run_in_executor(self._logs) return self.sys_run_in_executor(self._logs)
def _logs(self): def _logs(self) -> bytes:
"""Return Docker logs of container. """Return Docker logs of container.
Need run inside executor. Need run inside executor.
@ -299,11 +297,11 @@ class DockerInterface(CoreSysAttributes):
_LOGGER.warning("Can't grep logs from %s: %s", self.image, err) _LOGGER.warning("Can't grep logs from %s: %s", self.image, err)
@process_lock @process_lock
def cleanup(self): def cleanup(self) -> Awaitable[None]:
"""Check if old version exists and cleanup.""" """Check if old version exists and cleanup."""
return self.sys_run_in_executor(self._cleanup) return self.sys_run_in_executor(self._cleanup)
def _cleanup(self): def _cleanup(self) -> None:
"""Check if old version exists and cleanup. """Check if old version exists and cleanup.
Need run inside executor. Need run inside executor.
@ -312,24 +310,22 @@ class DockerInterface(CoreSysAttributes):
latest = self.sys_docker.images.get(self.image) latest = self.sys_docker.images.get(self.image)
except docker.errors.DockerException: except docker.errors.DockerException:
_LOGGER.warning("Can't find %s for cleanup", self.image) _LOGGER.warning("Can't find %s for cleanup", self.image)
return False raise DockerAPIError() from None
for image in self.sys_docker.images.list(name=self.image): for image in self.sys_docker.images.list(name=self.image):
if latest.id == image.id: if latest.id == image.id:
continue continue
with suppress(docker.errors.DockerException): with suppress(docker.errors.DockerException):
_LOGGER.info("Cleanup Docker images: %s", image.tags) _LOGGER.info("Cleanup images: %s", image.tags)
self.sys_docker.images.remove(image.id, force=True) self.sys_docker.images.remove(image.id, force=True)
return True
@process_lock @process_lock
def restart(self): def restart(self) -> Awaitable[None]:
"""Restart docker container.""" """Restart docker container."""
return self.sys_loop.run_in_executor(None, self._restart) return self.sys_loop.run_in_executor(None, self._restart)
def _restart(self): def _restart(self) -> None:
"""Restart docker container. """Restart docker container.
Need run inside executor. Need run inside executor.
@ -337,33 +333,32 @@ class DockerInterface(CoreSysAttributes):
try: try:
container = self.sys_docker.containers.get(self.name) container = self.sys_docker.containers.get(self.name)
except docker.errors.DockerException: except docker.errors.DockerException:
return False raise DockerAPIError() from None
_LOGGER.info("Restart %s", self.image) _LOGGER.info("Restart %s", self.image)
try: try:
container.restart(timeout=self.timeout) container.restart(timeout=self.timeout)
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.warning("Can't restart %s: %s", self.image, err) _LOGGER.warning("Can't restart %s: %s", self.image, err)
return False raise DockerAPIError() from None
return True
@process_lock @process_lock
def execute_command(self, command): def execute_command(self, command: str) -> Awaitable[CommandReturn]:
"""Create a temporary container and run command.""" """Create a temporary container and run command."""
return self.sys_run_in_executor(self._execute_command, command) return self.sys_run_in_executor(self._execute_command, command)
def _execute_command(self, command): def _execute_command(self, command: str) -> CommandReturn:
"""Create a temporary container and run command. """Create a temporary container and run command.
Need run inside executor. Need run inside executor.
""" """
raise NotImplementedError() raise NotImplementedError()
def stats(self): def stats(self) -> Awaitable[DockerStats]:
"""Read and return stats from container.""" """Read and return stats from container."""
return self.sys_run_in_executor(self._stats) return self.sys_run_in_executor(self._stats)
def _stats(self): def _stats(self) -> DockerStats:
"""Create a temporary container and run command. """Create a temporary container and run command.
Need run inside executor. Need run inside executor.
@ -371,23 +366,23 @@ class DockerInterface(CoreSysAttributes):
try: try:
docker_container = self.sys_docker.containers.get(self.name) docker_container = self.sys_docker.containers.get(self.name)
except docker.errors.DockerException: except docker.errors.DockerException:
return None raise DockerAPIError() from None
try: try:
stats = docker_container.stats(stream=False) stats = docker_container.stats(stream=False)
return DockerStats(stats) return DockerStats(stats)
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.error("Can't read stats from %s: %s", self.name, err) _LOGGER.error("Can't read stats from %s: %s", self.name, err)
return None raise DockerAPIError() from None
def is_fails(self): def is_fails(self) -> Awaitable[bool]:
"""Return True if Docker is failing state. """Return True if Docker is failing state.
Return a Future. Return a Future.
""" """
return self.sys_run_in_executor(self._is_fails) return self.sys_run_in_executor(self._is_fails)
def _is_fails(self): def _is_fails(self) -> bool:
"""Return True if Docker is failing state. """Return True if Docker is failing state.
Need run inside executor. Need run inside executor.

View File

@ -1,9 +1,12 @@
"""Internal network manager for Hass.io.""" """Internal network manager for Hass.io."""
from ipaddress import IPv4Address
import logging import logging
from typing import List, Optional
import docker import docker
from ..const import DOCKER_NETWORK_MASK, DOCKER_NETWORK, DOCKER_NETWORK_RANGE from ..const import DOCKER_NETWORK, DOCKER_NETWORK_MASK, DOCKER_NETWORK_RANGE
from ..exceptions import DockerAPIError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -14,32 +17,32 @@ class DockerNetwork:
This class is not AsyncIO safe! This class is not AsyncIO safe!
""" """
def __init__(self, dock): def __init__(self, docker_client: docker.DockerClient):
"""Initialize internal Hass.io network.""" """Initialize internal Hass.io network."""
self.docker = dock self.docker: docker.DockerClient = docker_client
self.network = self._get_network() self.network: docker.models.networks.Network = self._get_network()
@property @property
def name(self): def name(self) -> str:
"""Return name of network.""" """Return name of network."""
return DOCKER_NETWORK return DOCKER_NETWORK
@property @property
def containers(self): def containers(self) -> List[docker.models.containers.Container]:
"""Return of connected containers from network.""" """Return of connected containers from network."""
return self.network.containers return self.network.containers
@property @property
def gateway(self): def gateway(self) -> IPv4Address:
"""Return gateway of the network.""" """Return gateway of the network."""
return DOCKER_NETWORK_MASK[1] return DOCKER_NETWORK_MASK[1]
@property @property
def supervisor(self): def supervisor(self) -> IPv4Address:
"""Return supervisor of the network.""" """Return supervisor of the network."""
return DOCKER_NETWORK_MASK[2] return DOCKER_NETWORK_MASK[2]
def _get_network(self): def _get_network(self) -> docker.models.networks.Network:
"""Get HassIO network.""" """Get HassIO network."""
try: try:
return self.docker.networks.get(DOCKER_NETWORK) return self.docker.networks.get(DOCKER_NETWORK)
@ -49,18 +52,25 @@ class DockerNetwork:
ipam_pool = docker.types.IPAMPool( ipam_pool = docker.types.IPAMPool(
subnet=str(DOCKER_NETWORK_MASK), subnet=str(DOCKER_NETWORK_MASK),
gateway=str(self.gateway), gateway=str(self.gateway),
iprange=str(DOCKER_NETWORK_RANGE) iprange=str(DOCKER_NETWORK_RANGE),
) )
ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool]) ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool])
return self.docker.networks.create( return self.docker.networks.create(
DOCKER_NETWORK, driver='bridge', ipam=ipam_config, DOCKER_NETWORK,
enable_ipv6=False, options={ driver="bridge",
"com.docker.network.bridge.name": DOCKER_NETWORK, ipam=ipam_config,
}) enable_ipv6=False,
options={"com.docker.network.bridge.name": DOCKER_NETWORK},
)
def attach_container(self, container, alias=None, ipv4=None): def attach_container(
self,
container: docker.models.containers.Container,
alias: Optional[List[str]] = None,
ipv4: Optional[IPv4Address] = None,
) -> None:
"""Attach container to Hass.io network. """Attach container to Hass.io network.
Need run inside executor. Need run inside executor.
@ -71,23 +81,24 @@ class DockerNetwork:
self.network.connect(container, aliases=alias, ipv4_address=ipv4) self.network.connect(container, aliases=alias, ipv4_address=ipv4)
except docker.errors.APIError as err: except docker.errors.APIError as err:
_LOGGER.error("Can't link container to hassio-net: %s", err) _LOGGER.error("Can't link container to hassio-net: %s", err)
return False raise DockerAPIError() from None
self.network.reload() self.network.reload()
return True
def detach_default_bridge(self, container): def detach_default_bridge(
self, container: docker.models.containers.Container
) -> None:
"""Detach default Docker bridge. """Detach default Docker bridge.
Need run inside executor. Need run inside executor.
""" """
try: try:
default_network = self.docker.networks.get('bridge') default_network = self.docker.networks.get("bridge")
default_network.disconnect(container) default_network.disconnect(container)
except docker.errors.NotFound: except docker.errors.NotFound:
return return
except docker.errors.APIError as err: except docker.errors.APIError as err:
_LOGGER.warning( _LOGGER.warning("Can't disconnect container from default: %s", err)
"Can't disconnect container from default: %s", err) raise DockerAPIError() from None

View File

@ -1,11 +1,13 @@
"""Init file for Hass.io Docker object.""" """Init file for Hass.io Docker object."""
from ipaddress import IPv4Address
import logging import logging
import os import os
import docker import docker
from .interface import DockerInterface
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..exceptions import DockerAPIError
from .interface import DockerInterface
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -14,29 +16,36 @@ class DockerSupervisor(DockerInterface, CoreSysAttributes):
"""Docker Hass.io wrapper for Supervisor.""" """Docker Hass.io wrapper for Supervisor."""
@property @property
def name(self): def name(self) -> str:
"""Return name of Docker container.""" """Return name of Docker container."""
return os.environ['SUPERVISOR_NAME'] return os.environ["SUPERVISOR_NAME"]
def _attach(self): @property
def ip_address(self) -> IPv4Address:
"""Return IP address of this container."""
return self.sys_docker.network.supervisor
def _attach(self) -> None:
"""Attach to running docker container. """Attach to running docker container.
Need run inside executor. Need run inside executor.
""" """
try: try:
container = self.sys_docker.containers.get(self.name) docker_container = self.sys_docker.containers.get(self.name)
except docker.errors.DockerException: except docker.errors.DockerException:
return False raise DockerAPIError() from None
self._meta = container.attrs self._meta = docker_container.attrs
_LOGGER.info("Attach to Supervisor %s with version %s", _LOGGER.info(
self.image, self.version) "Attach to Supervisor %s with version %s", self.image, self.version
)
# If already attach # If already attach
if container in self.sys_docker.network.containers: if docker_container in self.sys_docker.network.containers:
return True return
# Attach to network # Attach to network
return self.sys_docker.network.attach_container( _LOGGER.info("Connect Supervisor to Hass.io Network")
container, alias=['hassio'], self.sys_docker.network.attach_container(
ipv4=self.sys_docker.network.supervisor) docker_container, alias=["hassio"], ipv4=self.sys_docker.network.supervisor
)

View File

@ -28,6 +28,17 @@ class HomeAssistantAuthError(HomeAssistantAPIError):
"""Home Assistant Auth API exception.""" """Home Assistant Auth API exception."""
# Supervisor
class SupervisorError(HassioError):
"""Supervisor error."""
class SupervisorUpdateError(SupervisorError):
"""Supervisor update error."""
# HassOS # HassOS
@ -43,6 +54,17 @@ class HassOSNotSupportedError(HassioNotSupportedError):
"""Function not supported by HassOS.""" """Function not supported by HassOS."""
# Addons
class AddonsError(HassioError):
"""Addons exception."""
class AddonsNotSupportedError(HassioNotSupportedError):
"""Addons don't support a function."""
# Arch # Arch
@ -144,3 +166,10 @@ class AppArmorInvalidError(AppArmorError):
class JsonFileError(HassioError): class JsonFileError(HassioError):
"""Invalid json file.""" """Invalid json file."""
# docker/api
class DockerAPIError(HassioError):
"""Docker API error."""

View File

@ -1,15 +1,22 @@
"""HassOS support on supervisor.""" """HassOS support on supervisor."""
import asyncio import asyncio
from contextlib import suppress
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Awaitable, Optional
import aiohttp import aiohttp
from cpe import CPE from cpe import CPE
from .coresys import CoreSysAttributes
from .const import URL_HASSOS_OTA from .const import URL_HASSOS_OTA
from .coresys import CoreSysAttributes, CoreSys
from .docker.hassos_cli import DockerHassOSCli from .docker.hassos_cli import DockerHassOSCli
from .exceptions import HassOSNotSupportedError, HassOSUpdateError, DBusError from .exceptions import (
DBusError,
HassOSNotSupportedError,
HassOSUpdateError,
DockerAPIError,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -17,61 +24,61 @@ _LOGGER = logging.getLogger(__name__)
class HassOS(CoreSysAttributes): class HassOS(CoreSysAttributes):
"""HassOS interface inside HassIO.""" """HassOS interface inside HassIO."""
def __init__(self, coresys): def __init__(self, coresys: CoreSys):
"""Initialize HassOS handler.""" """Initialize HassOS handler."""
self.coresys = coresys self.coresys: CoreSys = coresys
self.instance = DockerHassOSCli(coresys) self.instance: DockerHassOSCli = DockerHassOSCli(coresys)
self._available = False self._available: bool = False
self._version = None self._version: Optional[str] = None
self._board = None self._board: Optional[str] = None
@property @property
def available(self): def available(self) -> bool:
"""Return True, if HassOS on host.""" """Return True, if HassOS on host."""
return self._available return self._available
@property @property
def version(self): def version(self) -> Optional[str]:
"""Return version of HassOS.""" """Return version of HassOS."""
return self._version return self._version
@property @property
def version_cli(self): def version_cli(self) -> Optional[str]:
"""Return version of HassOS cli.""" """Return version of HassOS cli."""
return self.instance.version return self.instance.version
@property @property
def version_latest(self): def version_latest(self) -> str:
"""Return version of HassOS.""" """Return version of HassOS."""
return self.sys_updater.version_hassos return self.sys_updater.version_hassos
@property @property
def version_cli_latest(self): def version_cli_latest(self) -> str:
"""Return version of HassOS.""" """Return version of HassOS."""
return self.sys_updater.version_hassos_cli return self.sys_updater.version_hassos_cli
@property @property
def need_update(self): def need_update(self) -> bool:
"""Return true if a HassOS update is available.""" """Return true if a HassOS update is available."""
return self.version != self.version_latest return self.version != self.version_latest
@property @property
def need_cli_update(self): def need_cli_update(self) -> bool:
"""Return true if a HassOS cli update is available.""" """Return true if a HassOS cli update is available."""
return self.version_cli != self.version_cli_latest return self.version_cli != self.version_cli_latest
@property @property
def board(self): def board(self) -> Optional[str]:
"""Return board name.""" """Return board name."""
return self._board return self._board
def _check_host(self): def _check_host(self) -> None:
"""Check if HassOS is available.""" """Check if HassOS is available."""
if not self.available: if not self.available:
_LOGGER.error("No HassOS available") _LOGGER.error("No HassOS available")
raise HassOSNotSupportedError() raise HassOSNotSupportedError()
async def _download_raucb(self, version): async def _download_raucb(self, version: str) -> None:
"""Download rauc bundle (OTA) from github.""" """Download rauc bundle (OTA) from github."""
url = URL_HASSOS_OTA.format(version=version, board=self.board) url = URL_HASSOS_OTA.format(version=version, board=self.board)
raucb = Path(self.sys_config.path_tmp, f"hassos-{version}.raucb") raucb = Path(self.sys_config.path_tmp, f"hassos-{version}.raucb")
@ -83,9 +90,9 @@ class HassOS(CoreSysAttributes):
raise HassOSUpdateError() raise HassOSUpdateError()
# Download RAUCB file # Download RAUCB file
with raucb.open('wb') as ota_file: with raucb.open("wb") as ota_file:
while True: while True:
chunk = await request.content.read(1048576) chunk = await request.content.read(1_048_576)
if not chunk: if not chunk:
break break
ota_file.write(chunk) ota_file.write(chunk)
@ -101,7 +108,7 @@ class HassOS(CoreSysAttributes):
raise HassOSUpdateError() raise HassOSUpdateError()
async def load(self): async def load(self) -> None:
"""Load HassOS data.""" """Load HassOS data."""
try: try:
# Check needed host functions # Check needed host functions
@ -111,7 +118,7 @@ class HassOS(CoreSysAttributes):
assert self.sys_host.info.cpe is not None assert self.sys_host.info.cpe is not None
cpe = CPE(self.sys_host.info.cpe) cpe = CPE(self.sys_host.info.cpe)
assert cpe.get_product()[0] == 'hassos' assert cpe.get_product()[0] == "hassos"
except (AssertionError, NotImplementedError): except (AssertionError, NotImplementedError):
_LOGGER.debug("Found no HassOS") _LOGGER.debug("Found no HassOS")
return return
@ -122,9 +129,10 @@ class HassOS(CoreSysAttributes):
self._board = cpe.get_target_hardware()[0] self._board = cpe.get_target_hardware()[0]
_LOGGER.info("Detect HassOS %s on host system", self.version) _LOGGER.info("Detect HassOS %s on host system", self.version)
with suppress(DockerAPIError):
await self.instance.attach() await self.instance.attach()
def config_sync(self): def config_sync(self) -> Awaitable[None]:
"""Trigger a host config reload from usb. """Trigger a host config reload from usb.
Return a coroutine. Return a coroutine.
@ -132,9 +140,9 @@ class HassOS(CoreSysAttributes):
self._check_host() self._check_host()
_LOGGER.info("Syncing configuration from USB with HassOS.") _LOGGER.info("Syncing configuration from USB with HassOS.")
return self.sys_host.services.restart('hassos-config.service') return self.sys_host.services.restart("hassos-config.service")
async def update(self, version=None): async def update(self, version: Optional[str] = None) -> None:
"""Update HassOS system.""" """Update HassOS system."""
version = version or self.version_latest version = version or self.version_latest
@ -167,20 +175,19 @@ class HassOS(CoreSysAttributes):
# Update fails # Update fails
rauc_status = await self.sys_dbus.get_properties() rauc_status = await self.sys_dbus.get_properties()
_LOGGER.error( _LOGGER.error("HassOS update fails with: %s", rauc_status.get("LastError"))
"HassOS update fails with: %s", rauc_status.get('LastError'))
raise HassOSUpdateError() raise HassOSUpdateError()
async def update_cli(self, version=None): async def update_cli(self, version: Optional[str] = None) -> None:
"""Update local HassOS cli.""" """Update local HassOS cli."""
version = version or self.version_cli_latest version = version or self.version_cli_latest
if version == self.version_cli: if version == self.version_cli:
_LOGGER.warning("Version %s is already installed for CLI", version) _LOGGER.warning("Version %s is already installed for CLI", version)
raise HassOSUpdateError()
if await self.instance.update(version):
return return
try:
await self.instance.update(version)
except DockerAPIError:
_LOGGER.error("HassOS CLI update fails") _LOGGER.error("HassOS CLI update fails")
raise HassOSUpdateError() raise HassOSUpdateError() from None

View File

@ -9,7 +9,7 @@ from pathlib import Path
import re import re
import socket import socket
import time import time
from typing import Any, AsyncContextManager, Coroutine, Dict, Optional from typing import Any, AsyncContextManager, Awaitable, Dict, Optional
from uuid import UUID from uuid import UUID
import aiohttp import aiohttp
@ -33,11 +33,13 @@ from .const import (
) )
from .coresys import CoreSys, CoreSysAttributes from .coresys import CoreSys, CoreSysAttributes
from .docker.homeassistant import DockerHomeAssistant from .docker.homeassistant import DockerHomeAssistant
from .docker.stats import DockerStats
from .exceptions import ( from .exceptions import (
HomeAssistantAPIError, HomeAssistantAPIError,
HomeAssistantAuthError, HomeAssistantAuthError,
HomeAssistantError, HomeAssistantError,
HomeAssistantUpdateError, HomeAssistantUpdateError,
DockerAPIError
) )
from .utils import convert_to_ascii, create_token, process_lock from .utils import convert_to_ascii, create_token, process_lock
from .utils.json import JsonConfig from .utils.json import JsonConfig
@ -72,7 +74,8 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
async def load(self) -> None: async def load(self) -> None:
"""Prepare Home Assistant object.""" """Prepare Home Assistant object."""
if await self.instance.attach(): with suppress(DockerAPIError):
await self.instance.attach()
return return
_LOGGER.info("No Home Assistant Docker image %s found.", self.image) _LOGGER.info("No Home Assistant Docker image %s found.", self.image)
@ -94,9 +97,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return self._error_state return self._error_state
@property @property
def api_ip(self) -> IPv4Address: def ip_address(self) -> IPv4Address:
"""Return IP of Home Assistant instance.""" """Return IP of Home Assistant instance."""
return self.sys_docker.network.gateway return self.instance.ip_address
@property @property
def api_port(self) -> int: def api_port(self) -> int:
@ -132,7 +135,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
def api_url(self) -> str: def api_url(self) -> str:
"""Return API url to Home Assistant.""" """Return API url to Home Assistant."""
return "{}://{}:{}".format('https' if self.api_ssl else 'http', return "{}://{}:{}".format('https' if self.api_ssl else 'http',
self.api_ip, self.api_port) self.ip_address, self.api_port)
@property @property
def watchdog(self) -> bool: def watchdog(self) -> bool:
@ -230,8 +233,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Install a landing page.""" """Install a landing page."""
_LOGGER.info("Setup HomeAssistant landingpage") _LOGGER.info("Setup HomeAssistant landingpage")
while True: while True:
if await self.instance.install('landingpage'): with suppress(DockerAPIError):
break await self.instance.install('landingpage')
return
_LOGGER.warning("Fails install landingpage, retry after 30sec") _LOGGER.warning("Fails install landingpage, retry after 30sec")
await asyncio.sleep(30) await asyncio.sleep(30)
@ -245,7 +249,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
await self.sys_updater.reload() await self.sys_updater.reload()
tag = self.last_version tag = self.last_version
if tag and await self.instance.install(tag): if tag:
with suppress(DockerAPIError):
await self.instance.install(tag)
break break
_LOGGER.warning("Error on install Home Assistant. Retry in 30sec") _LOGGER.warning("Error on install Home Assistant. Retry in 30sec")
await asyncio.sleep(30) await asyncio.sleep(30)
@ -260,6 +266,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
except HomeAssistantError: except HomeAssistantError:
_LOGGER.error("Can't start Home Assistant!") _LOGGER.error("Can't start Home Assistant!")
finally: finally:
with suppress(DockerAPIError):
await self.instance.cleanup() await self.instance.cleanup()
@process_lock @process_lock
@ -272,14 +279,17 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
if exists and version == self.instance.version: if exists and version == self.instance.version:
_LOGGER.warning("Version %s is already installed", version) _LOGGER.warning("Version %s is already installed", version)
return HomeAssistantUpdateError() return
# process an update # process an update
async def _update(to_version): async def _update(to_version):
"""Run Home Assistant update.""" """Run Home Assistant update."""
_LOGGER.info("Update Home Assistant to version %s", to_version) _LOGGER.info("Update Home Assistant to version %s", to_version)
if not await self.instance.update(to_version): try:
raise HomeAssistantUpdateError() await self.instance.update(to_version)
except DockerAPIError:
_LOGGER.warning("Update Home Assistant image fails")
raise HomeAssistantUpdateError() from None
if running: if running:
await self._start() await self._start()
@ -307,13 +317,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
self._data[ATTR_ACCESS_TOKEN] = create_token() self._data[ATTR_ACCESS_TOKEN] = create_token()
self.save_data() self.save_data()
if not await self.instance.run(): try:
raise HomeAssistantError() await self.instance.run()
except DockerAPIError:
raise HomeAssistantError() from None
await self._block_till_run() await self._block_till_run()
@process_lock @process_lock
async def start(self) -> None: async def start(self) -> None:
"""Run Home Assistant docker.""" """Run Home Assistant docker."""
try:
if await self.instance.is_running(): if await self.instance.is_running():
await self.instance.restart() await self.instance.restart()
elif await self.instance.is_initialize(): elif await self.instance.is_initialize():
@ -323,51 +336,62 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return return
await self._block_till_run() await self._block_till_run()
except DockerAPIError:
raise HomeAssistantError() from None
@process_lock @process_lock
def stop(self) -> Coroutine: async def stop(self) -> None:
"""Stop Home Assistant Docker. """Stop Home Assistant Docker.
Return a coroutine. Return a coroutine.
""" """
return self.instance.stop(remove_container=False) try:
return await self.instance.stop(remove_container=False)
except DockerAPIError:
raise HomeAssistantError() from None
@process_lock @process_lock
async def restart(self) -> None: async def restart(self) -> None:
"""Restart Home Assistant Docker.""" """Restart Home Assistant Docker."""
if not await self.instance.restart(): try:
raise HomeAssistantError() await self.instance.restart()
except DockerAPIError:
raise HomeAssistantError() from None
await self._block_till_run() await self._block_till_run()
@process_lock @process_lock
async def rebuild(self) -> None: async def rebuild(self) -> None:
"""Rebuild Home Assistant Docker container.""" """Rebuild Home Assistant Docker container."""
with suppress(DockerAPIError):
await self.instance.stop() await self.instance.stop()
await self._start() await self._start()
def logs(self) -> Coroutine: def logs(self) -> Awaitable[bytes]:
"""Get HomeAssistant docker logs. """Get HomeAssistant docker logs.
Return a coroutine. Return a coroutine.
""" """
return self.instance.logs() return self.instance.logs()
def stats(self) -> Coroutine: async def stats(self) -> DockerStats:
"""Return stats of Home Assistant. """Return stats of Home Assistant.
Return a coroutine. Return a coroutine.
""" """
return self.instance.stats() try:
return await self.instance.stats()
except DockerAPIError:
raise HomeAssistantError() from None
def is_running(self) -> Coroutine: def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running. """Return True if Docker container is running.
Return a coroutine. Return a coroutine.
""" """
return self.instance.is_running() return self.instance.is_running()
def is_fails(self) -> Coroutine: def is_fails(self) -> Awaitable[bool]:
"""Return True if a Docker container is fails state. """Return True if a Docker container is fails state.
Return a coroutine. Return a coroutine.
@ -485,7 +509,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Check if port is mapped.""" """Check if port is mapped."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try: try:
result = sock.connect_ex((str(self.api_ip), self.api_port)) result = sock.connect_ex((str(self.ip_address), self.api_port))
sock.close() sock.close()
# Check if the port is available # Check if the port is available

View File

@ -39,6 +39,7 @@ from ..const import (
CRYPTO_AES128, CRYPTO_AES128,
) )
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import AddonsError
from ..utils.json import write_json_file from ..utils.json import write_json_file
from ..utils.tar import SecureTarFile from ..utils.tar import SecureTarFile
from .utils import key_to_iv, password_for_validating, password_to_key, remove_folder from .utils import key_to_iv, password_for_validating, password_to_key, remove_folder
@ -289,7 +290,9 @@ class Snapshot(CoreSysAttributes):
'w', key=self._key) 'w', key=self._key)
# Take snapshot # Take snapshot
if not await addon.snapshot(addon_file): try:
await addon.snapshot(addon_file)
except AddonsError:
_LOGGER.error("Can't make snapshot from %s", addon.slug) _LOGGER.error("Can't make snapshot from %s", addon.slug)
return return
@ -326,10 +329,11 @@ class Snapshot(CoreSysAttributes):
_LOGGER.error("Can't find snapshot for %s", addon.slug) _LOGGER.error("Can't find snapshot for %s", addon.slug)
return return
# Performe a restore # Perform a restore
if not await addon.restore(addon_file): try:
await addon.restore(addon_file)
except AddonsError:
_LOGGER.error("Can't restore snapshot for %s", addon.slug) _LOGGER.error("Can't restore snapshot for %s", addon.slug)
return
# Run tasks # Run tasks
tasks = [_addon_restore(addon) for addon in addon_list] tasks = [_addon_restore(addon) for addon in addon_list]

View File

@ -1,15 +1,24 @@
"""Home Assistant control object.""" """Home Assistant control object."""
import asyncio import asyncio
from contextlib import suppress
from ipaddress import IPv4Address
import logging import logging
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Awaitable, Optional
import aiohttp import aiohttp
from .coresys import CoreSysAttributes
from .docker.supervisor import DockerSupervisor
from .const import URL_HASSIO_APPARMOR from .const import URL_HASSIO_APPARMOR
from .exceptions import HostAppArmorError from .coresys import CoreSys, CoreSysAttributes
from .docker.stats import DockerStats
from .docker.supervisor import DockerSupervisor
from .exceptions import (
DockerAPIError,
HostAppArmorError,
SupervisorError,
SupervisorUpdateError,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -17,43 +26,52 @@ _LOGGER = logging.getLogger(__name__)
class Supervisor(CoreSysAttributes): class Supervisor(CoreSysAttributes):
"""Home Assistant core object for handle it.""" """Home Assistant core object for handle it."""
def __init__(self, coresys): def __init__(self, coresys: CoreSys):
"""Initialize hass object.""" """Initialize hass object."""
self.coresys = coresys self.coresys: CoreSys = coresys
self.instance = DockerSupervisor(coresys) self.instance: DockerSupervisor = DockerSupervisor(coresys)
async def load(self): async def load(self) -> None:
"""Prepare Home Assistant object.""" """Prepare Home Assistant object."""
if not await self.instance.attach(): try:
await self.instance.attach()
except DockerAPIError:
_LOGGER.fatal("Can't setup Supervisor Docker container!") _LOGGER.fatal("Can't setup Supervisor Docker container!")
with suppress(DockerAPIError):
await self.instance.cleanup() await self.instance.cleanup()
@property @property
def need_update(self): def ip_address(self) -> IPv4Address:
"""Return IP of Supervisor instance."""
return self.instance.ip_address
@property
def need_update(self) -> bool:
"""Return True if an update is available.""" """Return True if an update is available."""
return self.version != self.last_version return self.version != self.last_version
@property @property
def version(self): def version(self) -> str:
"""Return version of running Home Assistant.""" """Return version of running Home Assistant."""
return self.instance.version return self.instance.version
@property @property
def last_version(self): def last_version(self) -> str:
"""Return last available version of Home Assistant.""" """Return last available version of Home Assistant."""
return self.sys_updater.version_hassio return self.sys_updater.version_hassio
@property @property
def image(self): def image(self) -> str:
"""Return image name of Home Assistant container.""" """Return image name of Home Assistant container."""
return self.instance.image return self.instance.image
@property @property
def arch(self): def arch(self) -> str:
"""Return arch of the Hass.io container.""" """Return arch of the Hass.io container."""
return self.instance.arch return self.instance.arch
async def update_apparmor(self): async def update_apparmor(self) -> None:
"""Fetch last version and update profile.""" """Fetch last version and update profile."""
url = URL_HASSIO_APPARMOR url = URL_HASSIO_APPARMOR
try: try:
@ -63,22 +81,25 @@ class Supervisor(CoreSysAttributes):
except (aiohttp.ClientError, asyncio.TimeoutError) as err: except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Can't fetch AppArmor profile: %s", err) _LOGGER.warning("Can't fetch AppArmor profile: %s", err)
return raise SupervisorError() from None
with TemporaryDirectory(dir=self.sys_config.path_tmp) as tmp_dir: with TemporaryDirectory(dir=self.sys_config.path_tmp) as tmp_dir:
profile_file = Path(tmp_dir, 'apparmor.txt') profile_file = Path(tmp_dir, "apparmor.txt")
try: try:
profile_file.write_text(data) profile_file.write_text(data)
except OSError as err: except OSError as err:
_LOGGER.error("Can't write temporary profile: %s", err) _LOGGER.error("Can't write temporary profile: %s", err)
return raise SupervisorError() from None
try: try:
await self.sys_host.apparmor.load_profile( await self.sys_host.apparmor.load_profile(
"hassio-supervisor", profile_file) "hassio-supervisor", profile_file
)
except HostAppArmorError: except HostAppArmorError:
_LOGGER.error("Can't update AppArmor profile!") _LOGGER.error("Can't update AppArmor profile!")
raise SupervisorError() from None
async def update(self, version=None): async def update(self, version: Optional[str] = None) -> None:
"""Update Home Assistant version.""" """Update Home Assistant version."""
version = version or self.last_version version = version or self.last_version
@ -87,29 +108,31 @@ class Supervisor(CoreSysAttributes):
return return
_LOGGER.info("Update Supervisor to version %s", version) _LOGGER.info("Update Supervisor to version %s", version)
if await self.instance.install(version): try:
await self.instance.install(version)
except DockerAPIError:
_LOGGER.error("Update of Hass.io fails!")
raise SupervisorUpdateError() from None
with suppress(SupervisorError):
await self.update_apparmor() await self.update_apparmor()
self.sys_loop.call_later(1, self.sys_loop.stop) self.sys_loop.call_later(1, self.sys_loop.stop)
return True
_LOGGER.error("Update of Hass.io fails!")
return False
@property @property
def in_progress(self): def in_progress(self) -> bool:
"""Return True if a task is in progress.""" """Return True if a task is in progress."""
return self.instance.in_progress return self.instance.in_progress
def logs(self): def logs(self) -> Awaitable[bytes]:
"""Get Supervisor docker logs. """Get Supervisor docker logs.
Return a coroutine. Return Coroutine.
""" """
return self.instance.logs() return self.instance.logs()
def stats(self): async def stats(self) -> DockerStats:
"""Return stats of Supervisor. """Return stats of Supervisor."""
try:
Return a coroutine. return await self.instance.stats()
""" except DockerAPIError:
return self.instance.stats() raise SupervisorError() from None

View File

@ -1,32 +1,33 @@
"""Tools file for Hass.io.""" """Tools file for Hass.io."""
from datetime import datetime
import hashlib import hashlib
import logging import logging
import re import re
import uuid import uuid
from datetime import datetime
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
def convert_to_ascii(raw): def convert_to_ascii(raw) -> str:
"""Convert binary to ascii and remove colors.""" """Convert binary to ascii and remove colors."""
return RE_STRING.sub("", raw.decode()) return RE_STRING.sub("", raw.decode())
def create_token(): def create_token() -> str:
"""Create token for API access.""" """Create token for API access."""
return hashlib.sha256(uuid.uuid4().bytes).hexdigest() return hashlib.sha256(uuid.uuid4().bytes).hexdigest()
def process_lock(method): def process_lock(method):
"""Wrap function with only run once.""" """Wrap function with only run once."""
async def wrap_api(api, *args, **kwargs): async def wrap_api(api, *args, **kwargs):
"""Return api wrapper.""" """Return api wrapper."""
if api.lock.locked(): if api.lock.locked():
_LOGGER.error( _LOGGER.error(
"Can't execute %s while a task is in progress", "Can't execute %s while a task is in progress", method.__name__
method.__name__) )
return False return False
async with api.lock: async with api.lock:
@ -40,6 +41,7 @@ class AsyncThrottle:
Decorator that prevents a function from being called more than once every Decorator that prevents a function from being called more than once every
time period. time period.
""" """
def __init__(self, delta): def __init__(self, delta):
"""Initialize async throttle.""" """Initialize async throttle."""
self.throttle_period = delta self.throttle_period = delta
@ -47,6 +49,7 @@ class AsyncThrottle:
def __call__(self, method): def __call__(self, method):
"""Throttle function""" """Throttle function"""
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
"""Throttle function wrapper""" """Throttle function wrapper"""
now = datetime.now() now = datetime.now()

View File

@ -45,3 +45,7 @@ disable=
[EXCEPTIONS] [EXCEPTIONS]
overgeneral-exceptions=Exception overgeneral-exceptions=Exception
[TYPECHECK]
ignored-modules = distutils

View File

@ -5,7 +5,7 @@ cchardet==2.1.4
colorlog==4.0.2 colorlog==4.0.2
cpe==1.2.1 cpe==1.2.1
cryptography==2.6.1 cryptography==2.6.1
docker==3.7.0 docker==3.7.2
gitpython==2.1.11 gitpython==2.1.11
pytz==2018.9 pytz==2018.9
pyudev==0.21.0 pyudev==0.21.0

View File

@ -14,4 +14,4 @@ use_parentheses = true
[flake8] [flake8]
max-line-length = 88 max-line-length = 88
ignore = E501 ignore = E501, W503