diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py index 9d7875e4b..7eb1c6867 100644 --- a/hassio/addons/__init__.py +++ b/hassio/addons/__init__.py @@ -15,11 +15,11 @@ BUILTIN_REPOSITORIES = set((REPOSITORY_CORE, REPOSITORY_LOCAL)) class AddonManager(object): """Manage addons inside HassIO.""" - def __init__(self, config, loop, dock): + def __init__(self, config, loop, docker): """Initialize docker base wrapper.""" self.loop = loop self.config = config - self.dock = dock + self.docker = docker self.data = Data(config) self.addons = {} self.repositories = {} @@ -108,7 +108,7 @@ class AddonManager(object): tasks = [] for addon_slug in add_addons: addon = Addon( - self.config, self.loop, self.dock, self.data, addon_slug) + self.config, self.loop, self.docker, self.data, addon_slug) tasks.append(addon.load()) self.addons[addon_slug] = addon diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 1a5666bc8..f9607b5be 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -37,14 +37,14 @@ MERGE_OPT = Merger([(dict, ['merge'])], ['override'], ['override']) class Addon(object): """Hold data for addon inside HassIO.""" - def __init__(self, config, loop, dock, data, slug): + def __init__(self, config, loop, docker, data, slug): """Initialize data holder.""" self.loop = loop self.config = config self.data = data self._id = slug - self.docker = DockerAddon(config, loop, dock, self) + self.docker = DockerAddon(config, loop, docker, self) async def load(self): """Async initialize of object.""" @@ -179,8 +179,8 @@ class Addon(object): @property def ports(self): """Return ports of addon.""" - if self.network_mode != 'bridge' or ATTR_PORTS not in self._mesh: - return + if self.network_mode or ATTR_PORTS not in self._mesh: + return None if not self.is_installed or \ ATTR_NETWORK not in self.data.user[self._id]: @@ -206,7 +206,7 @@ class Addon(object): def webui(self): """Return URL to webui or None.""" if ATTR_WEBUI not in self._mesh: - return + return None webui = self._mesh[ATTR_WEBUI] dock_port = RE_WEBUI.sub(r"\2", webui) @@ -226,7 +226,7 @@ class Addon(object): """Return network mode of addon.""" if self._mesh[ATTR_HOST_NETWORK]: return 'host' - return 'bridge' + return None @property def devices(self): @@ -262,7 +262,7 @@ class Addon(object): def audio_output(self): """Return ALSA config for output or None.""" if not self.with_audio: - return + return None setting = self.config.audio_output if self.is_installed and ATTR_AUDIO_OUTPUT in self.data.user[self._id]: diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index 0e161f9d5..daa09371a 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -2,6 +2,7 @@ import logging import os import signal +import shutil from pathlib import Path from colorlog import ColoredFormatter @@ -100,6 +101,7 @@ def initialize_logging(): def check_environment(): """Check if all environment are exists.""" + # check environment variables for key in ('SUPERVISOR_SHARE', 'SUPERVISOR_NAME', 'HOMEASSISTANT_REPOSITORY'): try: @@ -108,10 +110,16 @@ def check_environment(): _LOGGER.fatal("Can't find %s in env!", key) return False + # check docker socket if not SOCKET_DOCKER.is_socket(): _LOGGER.fatal("Can't find docker socket!") return False + # check socat exec + if not shutil.which('socat'): + _LOGGER.fatal("Can0t find socat program!") + return False + return True diff --git a/hassio/config.py b/hassio/config.py index 7f501ba68..9386c2ad2 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -6,8 +6,8 @@ from pathlib import Path, PurePath from .const import ( FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_SECURITY, ATTR_SESSIONS, - ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_API_ENDPOINT, - ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT) + ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, + ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT) from .tools import JsonConfig from .validate import SCHEMA_HASSIO_CONFIG @@ -37,16 +37,6 @@ class CoreConfig(JsonConfig): super().__init__(FILE_HASSIO_CONFIG, SCHEMA_HASSIO_CONFIG) self.arch = None - @property - def api_endpoint(self): - """Return IP address of api endpoint.""" - return self._data[ATTR_API_ENDPOINT] - - @api_endpoint.setter - def api_endpoint(self, value): - """Store IP address of api endpoint.""" - self._data[ATTR_API_ENDPOINT] = value - @property def timezone(self): """Return system timezone.""" diff --git a/hassio/const.py b/hassio/const.py index d0461cdd0..e2ac55128 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -1,5 +1,6 @@ """Const file for HassIO.""" from pathlib import Path +from ipaddress import ip_network HASSIO_VERSION = '0.58' @@ -28,6 +29,10 @@ FILE_HASSIO_UPDATER = Path(HASSIO_DATA, "updater.json") SOCKET_DOCKER = Path("/var/run/docker.sock") SOCKET_HC = Path("/var/run/hassio-hc.sock") +DOCKER_NETWORK = 'hassio' +DOCKER_NETWORK_MASK = ip_network('172.30.32.0/23') +DOCKER_NETWORK_RANGE = ip_network('172.30.33.0/24') + LABEL_VERSION = 'io.hass.version' LABEL_ARCH = 'io.hass.arch' LABEL_TYPE = 'io.hass.type' @@ -111,7 +116,6 @@ ATTR_OUTPUT = 'output' ATTR_DISK = 'disk' ATTR_SERIAL = 'serial' ATTR_SECURITY = 'security' -ATTR_API_ENDPOINT = 'api_endpoint' ATTR_ADDONS_CUSTOM_LIST = 'addons_custom_list' STARTUP_INITIALIZE = 'initialize' diff --git a/hassio/core.py b/hassio/core.py index 059b99a55..6eecc158c 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -3,13 +3,12 @@ import asyncio import logging import aiohttp -import docker from .addons import AddonManager from .api import RestAPI from .host_control import HostControl from .const import ( - SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, + RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS, @@ -17,12 +16,14 @@ from .const import ( from .hardware import Hardware from .homeassistant import HomeAssistant from .scheduler import Scheduler +from .dock import DockerAPI from .dock.supervisor import DockerSupervisor +from .dns import DNSForward from .snapshots import SnapshotsManager from .updater import Updater from .tasks import ( hassio_update, homeassistant_watchdog, api_sessions_cleanup, addons_update) -from .tools import get_local_ip, fetch_timezone +from .tools import fetch_timezone _LOGGER = logging.getLogger(__name__) @@ -40,21 +41,22 @@ class HassIO(object): self.scheduler = Scheduler(loop) self.api = RestAPI(config, loop) self.hardware = Hardware() - self.dock = docker.DockerClient( - base_url="unix:/{}".format(str(SOCKET_DOCKER)), version='auto') + self.docker = DockerAPI() + self.dns = DNSForward() # init basic docker container - self.supervisor = DockerSupervisor(config, loop, self.dock, self.stop) + self.supervisor = DockerSupervisor( + config, loop, self.docker, self.stop) # init homeassistant self.homeassistant = HomeAssistant( - config, loop, self.dock, self.updater) + config, loop, self.docker, self.updater) # init HostControl self.host_control = HostControl(loop) # init addon system - self.addons = AddonManager(config, loop, self.dock) + self.addons = AddonManager(config, loop, self.docker) # init snapshot system self.snapshots = SnapshotsManager( @@ -64,15 +66,12 @@ class HassIO(object): """Setup HassIO orchestration.""" # supervisor if not await self.supervisor.attach(): - _LOGGER.fatal("Can't attach to supervisor docker container!") + _LOGGER.fatal("Can't setup supervisor docker container!") await self.supervisor.cleanup() # set running arch self.config.arch = self.supervisor.arch - # set api endpoint - self.config.api_endpoint = await get_local_ip(self.loop) - # update timezone if self.config.timezone == 'UTC': self.config.timezone = await fetch_timezone(self.websession) @@ -122,6 +121,9 @@ class HassIO(object): self.scheduler.register_task( self.snapshots.reload, RUN_RELOAD_SNAPSHOTS_TASKS, now=True) + # start dns forwarding + self.loop.create_task(self.dns.start()) + # start addon mark as initialize await self.addons.auto_boot(STARTUP_INITIALIZE) @@ -136,7 +138,7 @@ class HassIO(object): # start api await self.api.start() - _LOGGER.info("Start hassio api on %s", self.config.api_endpoint) + _LOGGER.info("Start hassio api on %s", self.docker.network.supervisor) try: # HomeAssistant is already running / supervisor have only reboot @@ -173,7 +175,7 @@ class HassIO(object): # process stop tasks self.websession.close() - await self.api.stop() + await asyncio.wait([self.api.stop(), self.dns.stop()], loop=self.loop) self.exit_code = exit_code self.loop.stop() diff --git a/hassio/dns.py b/hassio/dns.py new file mode 100644 index 000000000..11e441b20 --- /dev/null +++ b/hassio/dns.py @@ -0,0 +1,40 @@ +"""Setup the internal DNS service for host applications.""" +import asyncio +import logging +import shlex + +_LOGGER = logging.getLogger(__name__) + +COMMAND = "socat UDP-LISTEN:53,fork UDP:127.0.0.11:53" + + +class DNSForward(object): + """Manage DNS forwarding to internal DNS.""" + + def __init__(self): + """Initialize DNS forwarding.""" + self.proc = None + + async def start(self): + """Start DNS forwarding.""" + try: + self.proc = await asyncio.create_subprocess_exec( + *shlex.split(COMMAND), + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + except OSError as err: + _LOGGER.error("Can't start DNS forwarding -> %s", err) + else: + _LOGGER.info("Start DNS port forwarding for host add-ons") + + async def stop(self): + """Stop DNS forwarding.""" + if not self.proc: + _LOGGER.warning("DNS forwarding is not running!") + return + + self.proc.kill() + await self.proc.wait() + _LOGGER.info("Stop DNS forwarding") diff --git a/hassio/dock/__init__.py b/hassio/dock/__init__.py index f166c5502..8e09ca1e4 100644 --- a/hassio/dock/__init__.py +++ b/hassio/dock/__init__.py @@ -1,324 +1,106 @@ """Init file for HassIO docker object.""" -import asyncio from contextlib import suppress import logging import docker -from .util import docker_process -from ..const import LABEL_VERSION, LABEL_ARCH +from .network import DockerNetwork +from ..const import SOCKET_DOCKER _LOGGER = logging.getLogger(__name__) -class DockerBase(object): - """Docker hassio wrapper.""" +class DockerAPI(object): + """Docker hassio wrapper. - def __init__(self, config, loop, dock, image=None, timeout=30): + This class is not AsyncIO safe! + """ + + def __init__(self): """Initialize docker base wrapper.""" - self.config = config - self.loop = loop - self.dock = dock - self.image = image - self.timeout = timeout - self.version = None - self.arch = None - self._lock = asyncio.Lock(loop=loop) + self.docker = docker.DockerClient( + base_url="unix:/{}".format(str(SOCKET_DOCKER)), version='auto') + self.network = DockerNetwork(self.docker) @property - def name(self): - """Return name of docker container.""" - return None + def images(self): + """Return api images.""" + return self.docker.images @property - def in_progress(self): - """Return True if a task is in progress.""" - return self._lock.locked() + def containers(self): + """Return api containers.""" + return self.docker.containers - def process_metadata(self, metadata, force=False): - """Read metadata and set it to object.""" - # read image - if not self.image: - self.image = metadata['Config']['Image'] + @property + def api(self): + """Return api containers.""" + return self.docker.api - # read version - need_version = force or not self.version - if need_version and LABEL_VERSION in metadata['Config']['Labels']: - self.version = metadata['Config']['Labels'][LABEL_VERSION] - elif need_version: - _LOGGER.warning("Can't read version from %s", self.name) - - # read arch - need_arch = force or not self.arch - if need_arch and LABEL_ARCH in metadata['Config']['Labels']: - self.arch = metadata['Config']['Labels'][LABEL_ARCH] - - @docker_process - def install(self, tag): - """Pull docker image.""" - return self.loop.run_in_executor(None, self._install, tag) - - def _install(self, tag): - """Pull docker image. + def run(self, image, **kwargs): + """"Create a docker and run it. Need run inside executor. """ + name = kwargs.get('name', image) + network_mode = kwargs.get('network_mode') + hostname = kwargs.get('hostname') + + # setup network + if network_mode: + kwargs['dns'] = [str(self.network.supervisor)] + else: + kwargs['network'] = None + + # create container try: - _LOGGER.info("Pull image %s tag %s.", self.image, tag) - image = self.dock.images.pull("{}:{}".format(self.image, tag)) - - image.tag(self.image, tag='latest') - self.process_metadata(image.attrs, force=True) - except docker.errors.APIError as err: - _LOGGER.error("Can't install %s:%s -> %s.", self.image, tag, err) - return False - - _LOGGER.info("Tag image %s with version %s as latest", self.image, tag) - return True - - def exists(self): - """Return True if docker image exists in local repo.""" - return self.loop.run_in_executor(None, self._exists) - - def _exists(self): - """Return True if docker image exists in local repo. - - Need run inside executor. - """ - try: - self.dock.images.get(self.image) - except docker.errors.DockerException: - return False - - return True - - def is_running(self): - """Return True if docker is Running. - - Return a Future. - """ - return self.loop.run_in_executor(None, self._is_running) - - def _is_running(self): - """Return True if docker is Running. - - Need run inside executor. - """ - try: - container = self.dock.containers.get(self.name) - image = self.dock.images.get(self.image) - except docker.errors.DockerException: - return False - - # container is not running - if container.status != 'running': - return False - - # we run on a old image, stop and start it - if container.image.id != image.id: - return False - - return True - - @docker_process - def attach(self): - """Attach to running docker container.""" - return self.loop.run_in_executor(None, self._attach) - - def _attach(self): - """Attach to running docker container. - - Need run inside executor. - """ - try: - if self.image: - obj_data = self.dock.images.get(self.image).attrs - else: - obj_data = self.dock.containers.get(self.name).attrs - except docker.errors.DockerException: - return False - - self.process_metadata(obj_data) - _LOGGER.info( - "Attach to image %s with version %s", self.image, self.version) - - return True - - @docker_process - def run(self): - """Run docker image.""" - return self.loop.run_in_executor(None, self._run) - - def _run(self): - """Run docker image. - - Need run inside executor. - """ - raise NotImplementedError() - - @docker_process - def stop(self): - """Stop/remove docker container.""" - return self.loop.run_in_executor(None, self._stop) - - def _stop(self): - """Stop/remove and remove docker container. - - Need run inside executor. - """ - try: - container = self.dock.containers.get(self.name) - except docker.errors.DockerException: - return False - - if container.status == 'running': - _LOGGER.info("Stop %s docker application", self.image) - with suppress(docker.errors.DockerException): - container.stop(timeout=self.timeout) - - with suppress(docker.errors.DockerException): - _LOGGER.info("Clean %s docker application", self.image) - container.remove(force=True) - - return True - - @docker_process - def remove(self): - """Remove docker images.""" - return self.loop.run_in_executor(None, self._remove) - - def _remove(self): - """remove docker images. - - Need run inside executor. - """ - # cleanup container - self._stop() - - _LOGGER.info( - "Remove docker %s with latest and %s", self.image, self.version) - - try: - with suppress(docker.errors.ImageNotFound): - self.dock.images.remove( - image="{}:latest".format(self.image), force=True) - - with suppress(docker.errors.ImageNotFound): - self.dock.images.remove( - image="{}:{}".format(self.image, self.version), force=True) - + container = self.docker.containers.create(image, **kwargs) except docker.errors.DockerException as err: - _LOGGER.warning("Can't remove image %s -> %s", self.image, err) + _LOGGER.error("Can't create container from %s -> %s", name, err) return False - # clean metadata - self.version = None - self.arch = None + # attach network + if not network_mode: + alias = [hostname] if hostname else None + if not self.network.attach_container(container, alias=alias): + _LOGGER.warning("Can't attach %s to hassio-net!", name) - return True - - @docker_process - def update(self, tag): - """Update a docker image.""" - return self.loop.run_in_executor(None, self._update, tag) - - def _update(self, tag): - """Update a docker image. - - Need run inside executor. - """ - _LOGGER.info( - "Update docker %s with %s:%s", self.version, self.image, tag) - - # update docker image - if not self._install(tag): - return False - - # stop container & cleanup - self._stop() - self._cleanup() - - return True - - @docker_process - def logs(self): - """Return docker logs of container.""" - return self.loop.run_in_executor(None, self._logs) - - def _logs(self): - """Return docker logs of container. - - Need run inside executor. - """ + # run container try: - container = self.dock.containers.get(self.name) - except docker.errors.DockerException: - return b"" - - try: - return container.logs(tail=100, stdout=True, stderr=True) + container.start() except docker.errors.DockerException as err: - _LOGGER.warning("Can't grap logs from %s -> %s", self.image, err) - - @docker_process - def restart(self): - """Restart docker container.""" - return self.loop.run_in_executor(None, self._restart) - - def _restart(self): - """Restart docker container. - - Need run inside executor. - """ - try: - container = self.dock.containers.get(self.name) - except docker.errors.DockerException: - return False - - _LOGGER.info("Restart %s", self.image) - - try: - container.restart(timeout=self.timeout) - except docker.errors.DockerException as err: - _LOGGER.warning("Can't restart %s -> %s", self.image, err) + _LOGGER.error("Can't start %s -> %s", name, err) return False return True - @docker_process - def cleanup(self): - """Check if old version exists and cleanup.""" - return self.loop.run_in_executor(None, self._cleanup) - - def _cleanup(self): - """Check if old version exists and cleanup. - - Need run inside executor. - """ - try: - latest = self.dock.images.get(self.image) - except docker.errors.DockerException: - _LOGGER.warning("Can't find %s for cleanup", self.image) - return False - - for image in self.dock.images.list(name=self.image): - if latest.id == image.id: - continue - - with suppress(docker.errors.DockerException): - _LOGGER.info("Cleanup docker images: %s", image.tags) - self.dock.images.remove(image.id, force=True) - - return True - - @docker_process - def execute_command(self, command): - """Create a temporary container and run command.""" - return self.loop.run_in_executor(None, self._execute_command, command) - - def _execute_command(self, command): + def run_command(self, image, command=None, **kwargs): """Create a temporary container and run command. Need run inside executor. """ - raise NotImplementedError() + stdout = kwargs.get('stdout', True) + stderr = kwargs.get('stderr', True) + + _LOGGER.info("Run command '%s' on %s", command, image) + try: + container = self.docker.containers.run( + image, + command=command, + network=self.network.name, + **kwargs + ) + + # wait until command is done + exit_code = container.wait() + output = container.logs(stdout=stdout, stderr=stderr) + + except docker.errors.DockerException as err: + _LOGGER.error("Can't execute command -> %s", err) + return (None, b"") + + # cleanup container + with suppress(docker.errors.DockerException): + container.remove(force=True) + + return (exit_code, output) diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index b7f9b083e..029f14004 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -6,7 +6,7 @@ import shutil import docker import requests -from . import DockerBase +from .interface import DockerInterface from .util import dockerfile_template, docker_process from ..const import ( META_ADDON, MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE) @@ -16,13 +16,13 @@ _LOGGER = logging.getLogger(__name__) AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm" -class DockerAddon(DockerBase): +class DockerAddon(DockerInterface): """Docker hassio wrapper for HomeAssistant.""" - def __init__(self, config, loop, dock, addon): + def __init__(self, config, loop, api, addon): """Initialize docker homeassistant wrapper.""" super().__init__( - config, loop, dock, image=addon.image, timeout=addon.timeout) + config, loop, api, image=addon.image, timeout=addon.timeout) self.addon = addon @property @@ -73,13 +73,10 @@ class DockerAddon(DockerBase): return None @property - def mapping(self): + def network_mapping(self): """Return hosts mapping.""" - if not self.addon.use_hassio_api: - return None - return { - 'hassio': self.config.api_endpoint, + 'homeassistant': self.docker.network.gateway, } @property @@ -139,29 +136,26 @@ class DockerAddon(DockerBase): if not self.addon.write_options(): return False - try: - self.dock.containers.run( - self.image, - name=self.name, - hostname=self.hostname, - detach=True, - network_mode=self.addon.network_mode, - ports=self.addon.ports, - extra_hosts=self.mapping, - devices=self.devices, - cap_add=self.addon.privileged, - environment=self.environment, - volumes=self.volumes, - tmpfs=self.tmpfs - ) + ret = self.docker.run( + self.image, + name=self.name, + hostname=self.hostname, + detach=True, + network_mode=self.addon.network_mode, + ports=self.addon.ports, + extra_hosts=self.network_mapping, + devices=self.devices, + cap_add=self.addon.privileged, + environment=self.environment, + volumes=self.volumes, + tmpfs=self.tmpfs + ) - except docker.errors.DockerException as err: - _LOGGER.error("Can't run %s -> %s", self.image, err) - return False + if ret: + _LOGGER.info("Start docker addon %s with version %s", + self.image, self.version) - _LOGGER.info( - "Start docker addon %s with version %s", self.image, self.version) - return True + return ret def _install(self, tag): """Pull docker image or build it. @@ -202,7 +196,7 @@ class DockerAddon(DockerBase): build_tag = "{}:{}".format(self.image, tag) _LOGGER.info("Start build %s on %s", build_tag, build_dir) - image = self.dock.images.build( + image = self.docker.images.build( path=str(build_dir), tag=build_tag, pull=True, forcerm=True ) @@ -231,7 +225,7 @@ class DockerAddon(DockerBase): Need run inside executor. """ try: - image = self.dock.api.get_image(self.image) + image = self.docker.api.get_image(self.image) except docker.errors.DockerException as err: _LOGGER.error("Can't fetch image %s -> %s", self.image, err) return False @@ -259,9 +253,9 @@ class DockerAddon(DockerBase): """ try: with tar_file.open("rb") as read_tar: - self.dock.api.load_image(read_tar) + self.docker.api.load_image(read_tar) - image = self.dock.images.get(self.image) + image = self.docker.images.get(self.image) image.tag(self.image, tag=tag) except (docker.errors.DockerException, OSError) as err: _LOGGER.error("Can't import image %s -> %s", self.image, err) diff --git a/hassio/dock/homeassistant.py b/hassio/dock/homeassistant.py index 9dc4847cd..1a19b51a6 100644 --- a/hassio/dock/homeassistant.py +++ b/hassio/dock/homeassistant.py @@ -1,22 +1,19 @@ """Init file for HassIO docker object.""" -from contextlib import suppress import logging -import docker - -from . import DockerBase +from .interface import DockerInterface _LOGGER = logging.getLogger(__name__) HASS_DOCKER_NAME = 'homeassistant' -class DockerHomeAssistant(DockerBase): +class DockerHomeAssistant(DockerInterface): """Docker hassio wrapper for HomeAssistant.""" - def __init__(self, config, loop, dock, data): + def __init__(self, config, loop, api, data): """Initialize docker homeassistant wrapper.""" - super().__init__(config, loop, dock, image=data.image) + super().__init__(config, loop, api, image=data.image) self.data = data @property @@ -47,71 +44,52 @@ class DockerHomeAssistant(DockerBase): # cleanup self._stop() - try: - self.dock.containers.run( - self.image, - name=self.name, - hostname=self.name, - detach=True, - privileged=True, - devices=self.devices, - network_mode='host', - environment={ - 'HASSIO': self.config.api_endpoint, - 'TZ': self.config.timezone, - }, - volumes={ - str(self.config.path_extern_config): - {'bind': '/config', 'mode': 'rw'}, - str(self.config.path_extern_ssl): - {'bind': '/ssl', 'mode': 'ro'}, - str(self.config.path_extern_share): - {'bind': '/share', 'mode': 'rw'}, - } - ) + ret = self.docker.run( + self.image, + name=self.name, + hostname=self.name, + detach=True, + privileged=True, + devices=self.devices, + network_mode='host', + environment={ + 'HASSIO': self.docker.network.supervisor, + 'TZ': self.config.timezone, + }, + volumes={ + str(self.config.path_extern_config): + {'bind': '/config', 'mode': 'rw'}, + str(self.config.path_extern_ssl): + {'bind': '/ssl', 'mode': 'ro'}, + str(self.config.path_extern_share): + {'bind': '/share', 'mode': 'rw'}, + } + ) - except docker.errors.DockerException as err: - _LOGGER.error("Can't run %s -> %s", self.image, err) - return False + if ret: + _LOGGER.info("Start homeassistant %s with version %s", + self.image, self.version) - _LOGGER.info( - "Start homeassistant %s with version %s", self.image, self.version) - return True + return ret def _execute_command(self, command): """Create a temporary container and run command. Need run inside executor. """ - _LOGGER.info("Run command '%s' on %s", command, self.image) - try: - container = self.dock.containers.run( - self.image, - command=command, - detach=True, - stdout=True, - stderr=True, - environment={ - 'TZ': self.config.timezone, - }, - volumes={ - str(self.config.path_extern_config): - {'bind': '/config', 'mode': 'ro'}, - str(self.config.path_extern_ssl): - {'bind': '/ssl', 'mode': 'ro'}, - } - ) - - # wait until command is done - exit_code = container.wait() - output = container.logs() - - except docker.errors.DockerException as err: - _LOGGER.error("Can't execute command -> %s", err) - return (None, b"") - - # cleanup container - with suppress(docker.errors.DockerException): - container.remove(force=True) - - return (exit_code, output) + return self.docker.run_command( + self.image, + command, + detach=True, + stdout=True, + stderr=True, + environment={ + 'TZ': self.config.timezone, + }, + volumes={ + str(self.config.path_extern_config): + {'bind': '/config', 'mode': 'ro'}, + str(self.config.path_extern_ssl): + {'bind': '/ssl', 'mode': 'ro'}, + } + ) diff --git a/hassio/dock/interface.py b/hassio/dock/interface.py new file mode 100644 index 000000000..82a766e16 --- /dev/null +++ b/hassio/dock/interface.py @@ -0,0 +1,325 @@ +"""Interface class for HassIO docker object.""" +import asyncio +from contextlib import suppress +import logging + +import docker + +from .util import docker_process +from ..const import LABEL_VERSION, LABEL_ARCH + +_LOGGER = logging.getLogger(__name__) + + +class DockerInterface(object): + """Docker hassio interface.""" + + def __init__(self, config, loop, api, image=None, timeout=30): + """Initialize docker base wrapper.""" + self.config = config + self.loop = loop + self.docker = api + + self.image = image + self.timeout = timeout + self.version = None + self.arch = None + self._lock = asyncio.Lock(loop=loop) + + @property + def name(self): + """Return name of docker container.""" + return None + + @property + def in_progress(self): + """Return True if a task is in progress.""" + return self._lock.locked() + + def process_metadata(self, metadata, force=False): + """Read metadata and set it to object.""" + # read image + if not self.image: + self.image = metadata['Config']['Image'] + + # read version + need_version = force or not self.version + if need_version and LABEL_VERSION in metadata['Config']['Labels']: + self.version = metadata['Config']['Labels'][LABEL_VERSION] + elif need_version: + _LOGGER.warning("Can't read version from %s", self.name) + + # read arch + need_arch = force or not self.arch + if need_arch and LABEL_ARCH in metadata['Config']['Labels']: + self.arch = metadata['Config']['Labels'][LABEL_ARCH] + + @docker_process + def install(self, tag): + """Pull docker image.""" + return self.loop.run_in_executor(None, self._install, tag) + + def _install(self, tag): + """Pull docker image. + + Need run inside executor. + """ + try: + _LOGGER.info("Pull image %s tag %s.", self.image, tag) + image = self.docker.images.pull("{}:{}".format(self.image, tag)) + + image.tag(self.image, tag='latest') + self.process_metadata(image.attrs, force=True) + except docker.errors.APIError as err: + _LOGGER.error("Can't install %s:%s -> %s.", self.image, tag, err) + return False + + _LOGGER.info("Tag image %s with version %s as latest", self.image, tag) + return True + + def exists(self): + """Return True if docker image exists in local repo.""" + return self.loop.run_in_executor(None, self._exists) + + def _exists(self): + """Return True if docker image exists in local repo. + + Need run inside executor. + """ + try: + self.docker.images.get(self.image) + except docker.errors.DockerException: + return False + + return True + + def is_running(self): + """Return True if docker is Running. + + Return a Future. + """ + return self.loop.run_in_executor(None, self._is_running) + + def _is_running(self): + """Return True if docker is Running. + + Need run inside executor. + """ + try: + container = self.docker.containers.get(self.name) + image = self.docker.images.get(self.image) + except docker.errors.DockerException: + return False + + # container is not running + if container.status != 'running': + return False + + # we run on a old image, stop and start it + if container.image.id != image.id: + return False + + return True + + @docker_process + def attach(self): + """Attach to running docker container.""" + return self.loop.run_in_executor(None, self._attach) + + def _attach(self): + """Attach to running docker container. + + Need run inside executor. + """ + try: + if self.image: + obj_data = self.docker.images.get(self.image).attrs + else: + obj_data = self.docker.containers.get(self.name).attrs + except docker.errors.DockerException: + return False + + self.process_metadata(obj_data) + _LOGGER.info( + "Attach to image %s with version %s", self.image, self.version) + + return True + + @docker_process + def run(self): + """Run docker image.""" + return self.loop.run_in_executor(None, self._run) + + def _run(self): + """Run docker image. + + Need run inside executor. + """ + raise NotImplementedError() + + @docker_process + def stop(self): + """Stop/remove docker container.""" + return self.loop.run_in_executor(None, self._stop) + + def _stop(self): + """Stop/remove and remove docker container. + + Need run inside executor. + """ + try: + container = self.docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + if container.status == 'running': + _LOGGER.info("Stop %s docker application", self.image) + with suppress(docker.errors.DockerException): + container.stop(timeout=self.timeout) + + with suppress(docker.errors.DockerException): + _LOGGER.info("Clean %s docker application", self.image) + container.remove(force=True) + + return True + + @docker_process + def remove(self): + """Remove docker images.""" + return self.loop.run_in_executor(None, self._remove) + + def _remove(self): + """remove docker images. + + Need run inside executor. + """ + # cleanup container + self._stop() + + _LOGGER.info( + "Remove docker %s with latest and %s", self.image, self.version) + + try: + with suppress(docker.errors.ImageNotFound): + self.docker.images.remove( + image="{}:latest".format(self.image), force=True) + + with suppress(docker.errors.ImageNotFound): + self.docker.images.remove( + image="{}:{}".format(self.image, self.version), force=True) + + except docker.errors.DockerException as err: + _LOGGER.warning("Can't remove image %s -> %s", self.image, err) + return False + + # clean metadata + self.version = None + self.arch = None + + return True + + @docker_process + def update(self, tag): + """Update a docker image.""" + return self.loop.run_in_executor(None, self._update, tag) + + def _update(self, tag): + """Update a docker image. + + Need run inside executor. + """ + _LOGGER.info( + "Update docker %s with %s:%s", self.version, self.image, tag) + + # update docker image + if not self._install(tag): + return False + + # stop container & cleanup + self._stop() + self._cleanup() + + return True + + @docker_process + def logs(self): + """Return docker logs of container.""" + return self.loop.run_in_executor(None, self._logs) + + def _logs(self): + """Return docker logs of container. + + Need run inside executor. + """ + try: + container = self.docker.containers.get(self.name) + except docker.errors.DockerException: + return b"" + + try: + return container.logs(tail=100, stdout=True, stderr=True) + except docker.errors.DockerException as err: + _LOGGER.warning("Can't grap logs from %s -> %s", self.image, err) + + @docker_process + def restart(self): + """Restart docker container.""" + return self.loop.run_in_executor(None, self._restart) + + def _restart(self): + """Restart docker container. + + Need run inside executor. + """ + try: + container = self.docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + _LOGGER.info("Restart %s", self.image) + + try: + container.restart(timeout=self.timeout) + except docker.errors.DockerException as err: + _LOGGER.warning("Can't restart %s -> %s", self.image, err) + return False + + return True + + @docker_process + def cleanup(self): + """Check if old version exists and cleanup.""" + return self.loop.run_in_executor(None, self._cleanup) + + def _cleanup(self): + """Check if old version exists and cleanup. + + Need run inside executor. + """ + try: + latest = self.docker.images.get(self.image) + except docker.errors.DockerException: + _LOGGER.warning("Can't find %s for cleanup", self.image) + return False + + for image in self.docker.images.list(name=self.image): + if latest.id == image.id: + continue + + with suppress(docker.errors.DockerException): + _LOGGER.info("Cleanup docker images: %s", image.tags) + self.docker.images.remove(image.id, force=True) + + return True + + @docker_process + def execute_command(self, command): + """Create a temporary container and run command.""" + return self.loop.run_in_executor(None, self._execute_command, command) + + def _execute_command(self, command): + """Create a temporary container and run command. + + Need run inside executor. + """ + raise NotImplementedError() diff --git a/hassio/dock/network.py b/hassio/dock/network.py new file mode 100644 index 000000000..aa90b42fb --- /dev/null +++ b/hassio/dock/network.py @@ -0,0 +1,73 @@ +"""Internal network manager for HassIO.""" +import logging + +import docker + +from ..const import DOCKER_NETWORK_MASK, DOCKER_NETWORK, DOCKER_NETWORK_RANGE + +_LOGGER = logging.getLogger(__name__) + + +class DockerNetwork(object): + """Internal HassIO Network.""" + + def __init__(self, dock): + """Initialize internal hassio network.""" + self.docker = dock + self.network = self._get_network() + + @property + def name(self): + """Return name of network.""" + return DOCKER_NETWORK + + @property + def containers(self): + """Return of connected containers from network.""" + return self.network.containers + + @property + def gateway(self): + """Return gateway of the network.""" + return DOCKER_NETWORK_MASK[1] + + @property + def supervisor(self): + """Return supervisor of the network.""" + return DOCKER_NETWORK_MASK[2] + + def _get_network(self): + """Get HassIO network.""" + try: + return self.docker.networks.get(DOCKER_NETWORK) + except docker.errors.NotFound: + _LOGGER.info("Can't find HassIO network, create new network") + + ipam_pool = docker.types.IPAMPool( + subnet=str(DOCKER_NETWORK_MASK), + gateway=str(self.gateway), + iprange=str(DOCKER_NETWORK_RANGE) + ) + + ipam_config = docker.types.IPAMConfig(pool_configs=[ipam_pool]) + + return self.docker.networks.create( + DOCKER_NETWORK, driver='bridge', ipam=ipam_config, options={ + "com.docker.network.bridge.name": DOCKER_NETWORK, + }) + + def attach_container(self, container, alias=None, ipv4=None): + """Attach container to hassio network. + + Need run inside executor. + """ + ipv4 = str(ipv4) if ipv4 else "" + + try: + self.network.connect(container, aliases=alias, ipv4_address=ipv4) + except docker.errors.APIError as err: + _LOGGER.error("Can't link container to hassio-net -> %s", err) + return False + + self.network.reload() + return True diff --git a/hassio/dock/supervisor.py b/hassio/dock/supervisor.py index abc491caf..96e9fc2b4 100644 --- a/hassio/dock/supervisor.py +++ b/hassio/dock/supervisor.py @@ -2,19 +2,21 @@ import logging import os -from . import DockerBase +import docker + +from .interface import DockerInterface from .util import docker_process from ..const import RESTART_EXIT_CODE _LOGGER = logging.getLogger(__name__) -class DockerSupervisor(DockerBase): +class DockerSupervisor(DockerInterface): """Docker hassio wrapper for HomeAssistant.""" - def __init__(self, config, loop, dock, stop_callback, image=None): + def __init__(self, config, loop, api, stop_callback, image=None): """Initialize docker base wrapper.""" - super().__init__(config, loop, dock, image=image) + super().__init__(config, loop, api, image=image) self.stop_callback = stop_callback @property @@ -22,6 +24,28 @@ class DockerSupervisor(DockerBase): """Return name of docker container.""" return os.environ['SUPERVISOR_NAME'] + def _attach(self): + """Attach to running docker container. + + Need run inside executor. + """ + try: + container = self.docker.containers.get(self.name) + except docker.errors.DockerException: + return False + + self.process_metadata(container.attrs) + _LOGGER.info("Attach to supervisor %s with version %s", + self.image, self.version) + + # if already attach + if container in self.docker.network.containers: + return True + + # attach to network + return self.docker.network.attach_container( + container, alias=['hassio'], ipv4=self.docker.network.supervisor) + @docker_process async def update(self, tag): """Update a supervisor docker image.""" diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index 280876a81..0ad03b5e0 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -19,13 +19,13 @@ RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") class HomeAssistant(JsonConfig): """Hass core object for handle it.""" - def __init__(self, config, loop, dock, updater): + def __init__(self, config, loop, docker, updater): """Initialize hass object.""" super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG) self.config = config self.loop = loop self.updater = updater - self.docker = DockerHomeAssistant(config, loop, dock, self) + self.docker = DockerHomeAssistant(config, loop, docker, self) async def prepare(self): """Prepare HomeAssistant object.""" diff --git a/hassio/tools.py b/hassio/tools.py index 65891cfb5..8b0b689ad 100644 --- a/hassio/tools.py +++ b/hassio/tools.py @@ -4,7 +4,6 @@ from contextlib import suppress from datetime import datetime import json import logging -import socket import re import aiohttp @@ -19,28 +18,6 @@ FREEGEOIP_URL = "https://freegeoip.io/json/" RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") -def get_local_ip(loop): - """Retrieve local IP address. - - Return a future. - """ - def local_ip(): - """Return local ip.""" - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - # Use Google Public DNS server to determine own IP - sock.connect(('8.8.8.8', 80)) - - return sock.getsockname()[0] - except socket.error: - return socket.gethostbyname(socket.gethostname()) - finally: - sock.close() - - return loop.run_in_executor(None, local_ip) - - def write_json_file(jsonfile, data): """Write a json file.""" try: diff --git a/hassio/validate.py b/hassio/validate.py index 6f50ecc98..529a23642 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -6,8 +6,8 @@ import pytz from .const import ( ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, - ATTR_API_ENDPOINT, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, - ATTR_AUDIO_INPUT, ATTR_HOMEASSISTANT, ATTR_HASSIO) + ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, + ATTR_HOMEASSISTANT, ATTR_HASSIO) NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) @@ -72,7 +72,6 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({ # pylint: disable=no-value-for-parameter SCHEMA_HASSIO_CONFIG = vol.Schema({ - vol.Optional(ATTR_API_ENDPOINT): vol.Coerce(str), vol.Optional(ATTR_TIMEZONE, default='UTC'): validate_timezone, vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): [vol.Url()], vol.Optional(ATTR_SECURITY, default=False): vol.Boolean(),