WIP: Network docker hassio (#159)

* Create hassio network layer / allow linking

* rename docker

* fix lint

* fix lint p2

* Set network options

* First version of network code

* Finish network layer

* Remove old api_endpoint stuff

* Add DNS forwarding

* Fix DNS recorder

* Fix lint p1

* Fix lint p2

* Fix lint p3

* Fix spell

* Fix ipam struct

* Fix ip to str

* Fix ip to str v2

* Fix spell

* Fix hass on host

* Fix host attach to network

* Cleanup network code

* Fix lint & add debug

* fix link

* Remove log

* Fix network

* fix reattach of supervisor

* set options

* Fix containers

* Fix remapping & add a test

* Fix dict bug

* Fix prop

* Test with run container

* Fix problem
This commit is contained in:
Pascal Vizeli 2017-08-24 14:57:13 +02:00 committed by GitHub
parent 1c915ef4cd
commit 04514a9f5c
16 changed files with 654 additions and 458 deletions

View File

@ -15,11 +15,11 @@ BUILTIN_REPOSITORIES = set((REPOSITORY_CORE, REPOSITORY_LOCAL))
class AddonManager(object): class AddonManager(object):
"""Manage addons inside HassIO.""" """Manage addons inside HassIO."""
def __init__(self, config, loop, dock): def __init__(self, config, loop, docker):
"""Initialize docker base wrapper.""" """Initialize docker base wrapper."""
self.loop = loop self.loop = loop
self.config = config self.config = config
self.dock = dock self.docker = docker
self.data = Data(config) self.data = Data(config)
self.addons = {} self.addons = {}
self.repositories = {} self.repositories = {}
@ -108,7 +108,7 @@ class AddonManager(object):
tasks = [] tasks = []
for addon_slug in add_addons: for addon_slug in add_addons:
addon = Addon( 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()) tasks.append(addon.load())
self.addons[addon_slug] = addon self.addons[addon_slug] = addon

View File

@ -37,14 +37,14 @@ MERGE_OPT = Merger([(dict, ['merge'])], ['override'], ['override'])
class Addon(object): class Addon(object):
"""Hold data for addon inside HassIO.""" """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.""" """Initialize data holder."""
self.loop = loop self.loop = loop
self.config = config self.config = config
self.data = data self.data = data
self._id = slug self._id = slug
self.docker = DockerAddon(config, loop, dock, self) self.docker = DockerAddon(config, loop, docker, self)
async def load(self): async def load(self):
"""Async initialize of object.""" """Async initialize of object."""
@ -179,8 +179,8 @@ class Addon(object):
@property @property
def ports(self): def ports(self):
"""Return ports of addon.""" """Return ports of addon."""
if self.network_mode != 'bridge' or ATTR_PORTS not in self._mesh: if self.network_mode or ATTR_PORTS not in self._mesh:
return return None
if not self.is_installed or \ if not self.is_installed or \
ATTR_NETWORK not in self.data.user[self._id]: ATTR_NETWORK not in self.data.user[self._id]:
@ -206,7 +206,7 @@ class Addon(object):
def webui(self): def webui(self):
"""Return URL to webui or None.""" """Return URL to webui or None."""
if ATTR_WEBUI not in self._mesh: if ATTR_WEBUI not in self._mesh:
return return None
webui = self._mesh[ATTR_WEBUI] webui = self._mesh[ATTR_WEBUI]
dock_port = RE_WEBUI.sub(r"\2", webui) dock_port = RE_WEBUI.sub(r"\2", webui)
@ -226,7 +226,7 @@ class Addon(object):
"""Return network mode of addon.""" """Return network mode of addon."""
if self._mesh[ATTR_HOST_NETWORK]: if self._mesh[ATTR_HOST_NETWORK]:
return 'host' return 'host'
return 'bridge' return None
@property @property
def devices(self): def devices(self):
@ -262,7 +262,7 @@ class Addon(object):
def audio_output(self): def audio_output(self):
"""Return ALSA config for output or None.""" """Return ALSA config for output or None."""
if not self.with_audio: if not self.with_audio:
return return None
setting = self.config.audio_output setting = self.config.audio_output
if self.is_installed and ATTR_AUDIO_OUTPUT in self.data.user[self._id]: if self.is_installed and ATTR_AUDIO_OUTPUT in self.data.user[self._id]:

View File

@ -2,6 +2,7 @@
import logging import logging
import os import os
import signal import signal
import shutil
from pathlib import Path from pathlib import Path
from colorlog import ColoredFormatter from colorlog import ColoredFormatter
@ -100,6 +101,7 @@ def initialize_logging():
def check_environment(): def check_environment():
"""Check if all environment are exists.""" """Check if all environment are exists."""
# check environment variables
for key in ('SUPERVISOR_SHARE', 'SUPERVISOR_NAME', for key in ('SUPERVISOR_SHARE', 'SUPERVISOR_NAME',
'HOMEASSISTANT_REPOSITORY'): 'HOMEASSISTANT_REPOSITORY'):
try: try:
@ -108,10 +110,16 @@ def check_environment():
_LOGGER.fatal("Can't find %s in env!", key) _LOGGER.fatal("Can't find %s in env!", key)
return False return False
# check docker socket
if not SOCKET_DOCKER.is_socket(): if not SOCKET_DOCKER.is_socket():
_LOGGER.fatal("Can't find docker socket!") _LOGGER.fatal("Can't find docker socket!")
return False return False
# check socat exec
if not shutil.which('socat'):
_LOGGER.fatal("Can0t find socat program!")
return False
return True return True

View File

@ -6,8 +6,8 @@ from pathlib import Path, PurePath
from .const import ( from .const import (
FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_SECURITY, ATTR_SESSIONS, FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_SECURITY, ATTR_SESSIONS,
ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_API_ENDPOINT, ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST,
ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT) ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT)
from .tools import JsonConfig from .tools import JsonConfig
from .validate import SCHEMA_HASSIO_CONFIG from .validate import SCHEMA_HASSIO_CONFIG
@ -37,16 +37,6 @@ class CoreConfig(JsonConfig):
super().__init__(FILE_HASSIO_CONFIG, SCHEMA_HASSIO_CONFIG) super().__init__(FILE_HASSIO_CONFIG, SCHEMA_HASSIO_CONFIG)
self.arch = None 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 @property
def timezone(self): def timezone(self):
"""Return system timezone.""" """Return system timezone."""

View File

@ -1,5 +1,6 @@
"""Const file for HassIO.""" """Const file for HassIO."""
from pathlib import Path from pathlib import Path
from ipaddress import ip_network
HASSIO_VERSION = '0.58' HASSIO_VERSION = '0.58'
@ -28,6 +29,10 @@ FILE_HASSIO_UPDATER = Path(HASSIO_DATA, "updater.json")
SOCKET_DOCKER = Path("/var/run/docker.sock") SOCKET_DOCKER = Path("/var/run/docker.sock")
SOCKET_HC = Path("/var/run/hassio-hc.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_VERSION = 'io.hass.version'
LABEL_ARCH = 'io.hass.arch' LABEL_ARCH = 'io.hass.arch'
LABEL_TYPE = 'io.hass.type' LABEL_TYPE = 'io.hass.type'
@ -111,7 +116,6 @@ ATTR_OUTPUT = 'output'
ATTR_DISK = 'disk' ATTR_DISK = 'disk'
ATTR_SERIAL = 'serial' ATTR_SERIAL = 'serial'
ATTR_SECURITY = 'security' ATTR_SECURITY = 'security'
ATTR_API_ENDPOINT = 'api_endpoint'
ATTR_ADDONS_CUSTOM_LIST = 'addons_custom_list' ATTR_ADDONS_CUSTOM_LIST = 'addons_custom_list'
STARTUP_INITIALIZE = 'initialize' STARTUP_INITIALIZE = 'initialize'

View File

@ -3,13 +3,12 @@ import asyncio
import logging import logging
import aiohttp import aiohttp
import docker
from .addons import AddonManager from .addons import AddonManager
from .api import RestAPI from .api import RestAPI
from .host_control import HostControl from .host_control import HostControl
from .const import ( 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_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT,
RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES, RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES,
STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS, STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS,
@ -17,12 +16,14 @@ from .const import (
from .hardware import Hardware from .hardware import Hardware
from .homeassistant import HomeAssistant from .homeassistant import HomeAssistant
from .scheduler import Scheduler from .scheduler import Scheduler
from .dock import DockerAPI
from .dock.supervisor import DockerSupervisor from .dock.supervisor import DockerSupervisor
from .dns import DNSForward
from .snapshots import SnapshotsManager from .snapshots import SnapshotsManager
from .updater import Updater from .updater import Updater
from .tasks import ( from .tasks import (
hassio_update, homeassistant_watchdog, api_sessions_cleanup, addons_update) 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__) _LOGGER = logging.getLogger(__name__)
@ -40,21 +41,22 @@ class HassIO(object):
self.scheduler = Scheduler(loop) self.scheduler = Scheduler(loop)
self.api = RestAPI(config, loop) self.api = RestAPI(config, loop)
self.hardware = Hardware() self.hardware = Hardware()
self.dock = docker.DockerClient( self.docker = DockerAPI()
base_url="unix:/{}".format(str(SOCKET_DOCKER)), version='auto') self.dns = DNSForward()
# init basic docker container # init basic docker container
self.supervisor = DockerSupervisor(config, loop, self.dock, self.stop) self.supervisor = DockerSupervisor(
config, loop, self.docker, self.stop)
# init homeassistant # init homeassistant
self.homeassistant = HomeAssistant( self.homeassistant = HomeAssistant(
config, loop, self.dock, self.updater) config, loop, self.docker, self.updater)
# init HostControl # init HostControl
self.host_control = HostControl(loop) self.host_control = HostControl(loop)
# init addon system # init addon system
self.addons = AddonManager(config, loop, self.dock) self.addons = AddonManager(config, loop, self.docker)
# init snapshot system # init snapshot system
self.snapshots = SnapshotsManager( self.snapshots = SnapshotsManager(
@ -64,15 +66,12 @@ class HassIO(object):
"""Setup HassIO orchestration.""" """Setup HassIO orchestration."""
# supervisor # supervisor
if not await self.supervisor.attach(): 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() await self.supervisor.cleanup()
# set running arch # set running arch
self.config.arch = self.supervisor.arch self.config.arch = self.supervisor.arch
# set api endpoint
self.config.api_endpoint = await get_local_ip(self.loop)
# update timezone # update timezone
if self.config.timezone == 'UTC': if self.config.timezone == 'UTC':
self.config.timezone = await fetch_timezone(self.websession) self.config.timezone = await fetch_timezone(self.websession)
@ -122,6 +121,9 @@ class HassIO(object):
self.scheduler.register_task( self.scheduler.register_task(
self.snapshots.reload, RUN_RELOAD_SNAPSHOTS_TASKS, now=True) self.snapshots.reload, RUN_RELOAD_SNAPSHOTS_TASKS, now=True)
# start dns forwarding
self.loop.create_task(self.dns.start())
# start addon mark as initialize # start addon mark as initialize
await self.addons.auto_boot(STARTUP_INITIALIZE) await self.addons.auto_boot(STARTUP_INITIALIZE)
@ -136,7 +138,7 @@ class HassIO(object):
# start api # start api
await self.api.start() 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: try:
# HomeAssistant is already running / supervisor have only reboot # HomeAssistant is already running / supervisor have only reboot
@ -173,7 +175,7 @@ class HassIO(object):
# process stop tasks # process stop tasks
self.websession.close() 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.exit_code = exit_code
self.loop.stop() self.loop.stop()

40
hassio/dns.py Normal file
View File

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

View File

@ -1,324 +1,106 @@
"""Init file for HassIO docker object.""" """Init file for HassIO docker object."""
import asyncio
from contextlib import suppress from contextlib import suppress
import logging import logging
import docker import docker
from .util import docker_process from .network import DockerNetwork
from ..const import LABEL_VERSION, LABEL_ARCH from ..const import SOCKET_DOCKER
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class DockerBase(object): class DockerAPI(object):
"""Docker hassio wrapper.""" """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.""" """Initialize docker base wrapper."""
self.config = config self.docker = docker.DockerClient(
self.loop = loop base_url="unix:/{}".format(str(SOCKET_DOCKER)), version='auto')
self.dock = dock self.network = DockerNetwork(self.docker)
self.image = image
self.timeout = timeout
self.version = None
self.arch = None
self._lock = asyncio.Lock(loop=loop)
@property @property
def name(self): def images(self):
"""Return name of docker container.""" """Return api images."""
return None return self.docker.images
@property @property
def in_progress(self): def containers(self):
"""Return True if a task is in progress.""" """Return api containers."""
return self._lock.locked() return self.docker.containers
def process_metadata(self, metadata, force=False): @property
"""Read metadata and set it to object.""" def api(self):
# read image """Return api containers."""
if not self.image: return self.docker.api
self.image = metadata['Config']['Image']
# read version def run(self, image, **kwargs):
need_version = force or not self.version """"Create a docker and run it.
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. 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: try:
_LOGGER.info("Pull image %s tag %s.", self.image, tag) container = self.docker.containers.create(image, **kwargs)
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)
except docker.errors.DockerException as err: 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 return False
# clean metadata # attach network
self.version = None if not network_mode:
self.arch = None 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 # run container
@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: try:
container = self.dock.containers.get(self.name) container.start()
except docker.errors.DockerException:
return b""
try:
return container.logs(tail=100, stdout=True, stderr=True)
except docker.errors.DockerException as err: except docker.errors.DockerException as err:
_LOGGER.warning("Can't grap logs from %s -> %s", self.image, err) _LOGGER.error("Can't start %s -> %s", name, 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)
return False return False
return True return True
@docker_process def run_command(self, image, command=None, **kwargs):
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):
"""Create a temporary container and run command. """Create a temporary container and run command.
Need run inside executor. 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)

View File

@ -6,7 +6,7 @@ import shutil
import docker import docker
import requests import requests
from . import DockerBase from .interface import DockerInterface
from .util import dockerfile_template, docker_process from .util import dockerfile_template, docker_process
from ..const import ( from ..const import (
META_ADDON, MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE) 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" AUDIO_DEVICE = "/dev/snd:/dev/snd:rwm"
class DockerAddon(DockerBase): class DockerAddon(DockerInterface):
"""Docker hassio wrapper for HomeAssistant.""" """Docker hassio wrapper for HomeAssistant."""
def __init__(self, config, loop, dock, addon): def __init__(self, config, loop, api, addon):
"""Initialize docker homeassistant wrapper.""" """Initialize docker homeassistant wrapper."""
super().__init__( super().__init__(
config, loop, dock, image=addon.image, timeout=addon.timeout) config, loop, api, image=addon.image, timeout=addon.timeout)
self.addon = addon self.addon = addon
@property @property
@ -73,13 +73,10 @@ class DockerAddon(DockerBase):
return None return None
@property @property
def mapping(self): def network_mapping(self):
"""Return hosts mapping.""" """Return hosts mapping."""
if not self.addon.use_hassio_api:
return None
return { return {
'hassio': self.config.api_endpoint, 'homeassistant': self.docker.network.gateway,
} }
@property @property
@ -139,29 +136,26 @@ class DockerAddon(DockerBase):
if not self.addon.write_options(): if not self.addon.write_options():
return False return False
try: ret = self.docker.run(
self.dock.containers.run( self.image,
self.image, name=self.name,
name=self.name, hostname=self.hostname,
hostname=self.hostname, detach=True,
detach=True, network_mode=self.addon.network_mode,
network_mode=self.addon.network_mode, ports=self.addon.ports,
ports=self.addon.ports, extra_hosts=self.network_mapping,
extra_hosts=self.mapping, devices=self.devices,
devices=self.devices, cap_add=self.addon.privileged,
cap_add=self.addon.privileged, environment=self.environment,
environment=self.environment, volumes=self.volumes,
volumes=self.volumes, tmpfs=self.tmpfs
tmpfs=self.tmpfs )
)
except docker.errors.DockerException as err: if ret:
_LOGGER.error("Can't run %s -> %s", self.image, err) _LOGGER.info("Start docker addon %s with version %s",
return False self.image, self.version)
_LOGGER.info( return ret
"Start docker addon %s with version %s", self.image, self.version)
return True
def _install(self, tag): def _install(self, tag):
"""Pull docker image or build it. """Pull docker image or build it.
@ -202,7 +196,7 @@ class DockerAddon(DockerBase):
build_tag = "{}:{}".format(self.image, tag) build_tag = "{}:{}".format(self.image, tag)
_LOGGER.info("Start build %s on %s", build_tag, build_dir) _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, path=str(build_dir), tag=build_tag, pull=True,
forcerm=True forcerm=True
) )
@ -231,7 +225,7 @@ class DockerAddon(DockerBase):
Need run inside executor. Need run inside executor.
""" """
try: try:
image = self.dock.api.get_image(self.image) image = self.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 return False
@ -259,9 +253,9 @@ class DockerAddon(DockerBase):
""" """
try: try:
with tar_file.open("rb") as read_tar: 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) 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)

View File

@ -1,22 +1,19 @@
"""Init file for HassIO docker object.""" """Init file for HassIO docker object."""
from contextlib import suppress
import logging import logging
import docker from .interface import DockerInterface
from . import DockerBase
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
HASS_DOCKER_NAME = 'homeassistant' HASS_DOCKER_NAME = 'homeassistant'
class DockerHomeAssistant(DockerBase): class DockerHomeAssistant(DockerInterface):
"""Docker hassio wrapper for HomeAssistant.""" """Docker hassio wrapper for HomeAssistant."""
def __init__(self, config, loop, dock, data): def __init__(self, config, loop, api, data):
"""Initialize docker homeassistant wrapper.""" """Initialize docker homeassistant wrapper."""
super().__init__(config, loop, dock, image=data.image) super().__init__(config, loop, api, image=data.image)
self.data = data self.data = data
@property @property
@ -47,71 +44,52 @@ class DockerHomeAssistant(DockerBase):
# cleanup # cleanup
self._stop() self._stop()
try: ret = self.docker.run(
self.dock.containers.run( self.image,
self.image, name=self.name,
name=self.name, hostname=self.name,
hostname=self.name, detach=True,
detach=True, privileged=True,
privileged=True, devices=self.devices,
devices=self.devices, network_mode='host',
network_mode='host', environment={
environment={ 'HASSIO': self.docker.network.supervisor,
'HASSIO': self.config.api_endpoint, 'TZ': self.config.timezone,
'TZ': self.config.timezone, },
}, volumes={
volumes={ str(self.config.path_extern_config):
str(self.config.path_extern_config): {'bind': '/config', 'mode': 'rw'},
{'bind': '/config', 'mode': 'rw'}, str(self.config.path_extern_ssl):
str(self.config.path_extern_ssl): {'bind': '/ssl', 'mode': 'ro'},
{'bind': '/ssl', 'mode': 'ro'}, str(self.config.path_extern_share):
str(self.config.path_extern_share): {'bind': '/share', 'mode': 'rw'},
{'bind': '/share', 'mode': 'rw'}, }
} )
)
except docker.errors.DockerException as err: if ret:
_LOGGER.error("Can't run %s -> %s", self.image, err) _LOGGER.info("Start homeassistant %s with version %s",
return False self.image, self.version)
_LOGGER.info( return ret
"Start homeassistant %s with version %s", self.image, self.version)
return True
def _execute_command(self, command): 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.
""" """
_LOGGER.info("Run command '%s' on %s", command, self.image) return self.docker.run_command(
try: self.image,
container = self.dock.containers.run( command,
self.image, detach=True,
command=command, stdout=True,
detach=True, stderr=True,
stdout=True, environment={
stderr=True, 'TZ': self.config.timezone,
environment={ },
'TZ': self.config.timezone, volumes={
}, str(self.config.path_extern_config):
volumes={ {'bind': '/config', 'mode': 'ro'},
str(self.config.path_extern_config): str(self.config.path_extern_ssl):
{'bind': '/config', 'mode': 'ro'}, {'bind': '/ssl', '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)

325
hassio/dock/interface.py Normal file
View File

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

73
hassio/dock/network.py Normal file
View File

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

View File

@ -2,19 +2,21 @@
import logging import logging
import os import os
from . import DockerBase import docker
from .interface import DockerInterface
from .util import docker_process from .util import docker_process
from ..const import RESTART_EXIT_CODE from ..const import RESTART_EXIT_CODE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class DockerSupervisor(DockerBase): class DockerSupervisor(DockerInterface):
"""Docker hassio wrapper for HomeAssistant.""" """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.""" """Initialize docker base wrapper."""
super().__init__(config, loop, dock, image=image) super().__init__(config, loop, api, image=image)
self.stop_callback = stop_callback self.stop_callback = stop_callback
@property @property
@ -22,6 +24,28 @@ class DockerSupervisor(DockerBase):
"""Return name of docker container.""" """Return name of docker container."""
return os.environ['SUPERVISOR_NAME'] 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 @docker_process
async def update(self, tag): async def update(self, tag):
"""Update a supervisor docker image.""" """Update a supervisor docker image."""

View File

@ -19,13 +19,13 @@ RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
class HomeAssistant(JsonConfig): class HomeAssistant(JsonConfig):
"""Hass core object for handle it.""" """Hass core object for handle it."""
def __init__(self, config, loop, dock, updater): def __init__(self, config, loop, docker, updater):
"""Initialize hass object.""" """Initialize hass object."""
super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG) super().__init__(FILE_HASSIO_HOMEASSISTANT, SCHEMA_HASS_CONFIG)
self.config = config self.config = config
self.loop = loop self.loop = loop
self.updater = updater self.updater = updater
self.docker = DockerHomeAssistant(config, loop, dock, self) self.docker = DockerHomeAssistant(config, loop, docker, self)
async def prepare(self): async def prepare(self):
"""Prepare HomeAssistant object.""" """Prepare HomeAssistant object."""

View File

@ -4,7 +4,6 @@ from contextlib import suppress
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
import socket
import re import re
import aiohttp import aiohttp
@ -19,28 +18,6 @@ FREEGEOIP_URL = "https://freegeoip.io/json/"
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") 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): def write_json_file(jsonfile, data):
"""Write a json file.""" """Write a json file."""
try: try:

View File

@ -6,8 +6,8 @@ import pytz
from .const import ( from .const import (
ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD,
ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE,
ATTR_API_ENDPOINT, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
ATTR_AUDIO_INPUT, ATTR_HOMEASSISTANT, ATTR_HASSIO) ATTR_HOMEASSISTANT, ATTR_HASSIO)
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) 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 # pylint: disable=no-value-for-parameter
SCHEMA_HASSIO_CONFIG = vol.Schema({ SCHEMA_HASSIO_CONFIG = vol.Schema({
vol.Optional(ATTR_API_ENDPOINT): vol.Coerce(str),
vol.Optional(ATTR_TIMEZONE, default='UTC'): validate_timezone, vol.Optional(ATTR_TIMEZONE, default='UTC'): validate_timezone,
vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): [vol.Url()], vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): [vol.Url()],
vol.Optional(ATTR_SECURITY, default=False): vol.Boolean(), vol.Optional(ATTR_SECURITY, default=False): vol.Boolean(),