mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-08-31 20:00:20 +00:00
Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
02e1689dd1 | ||
![]() |
ab4d96331f | ||
![]() |
cb881cba28 | ||
![]() |
44b247f397 | ||
![]() |
8bb43daf91 | ||
![]() |
a7e65613d6 | ||
![]() |
3c04c71401 | ||
![]() |
1353d52bd1 | ||
![]() |
7701457791 | ||
![]() |
b7820bc6a6 | ||
![]() |
df66102de0 | ||
![]() |
4b308d0de1 | ||
![]() |
4448ba886b | ||
![]() |
f39006be01 | ||
![]() |
e5204eef8a | ||
![]() |
1f07d47fd6 | ||
![]() |
ba352abf0b | ||
![]() |
2bf440a744 | ||
![]() |
3b26136636 | ||
![]() |
8249f042c0 | ||
![]() |
84bbaeee5f | ||
![]() |
b7620b7adf | ||
![]() |
5a80be9fd4 | ||
![]() |
a733886803 | ||
![]() |
834fd29fab | ||
![]() |
fd1caf8aa6 | ||
![]() |
975c9e8061 | ||
![]() |
0b3c5885ec | ||
![]() |
711b63e2d0 | ||
![]() |
c7b833b5eb | ||
![]() |
fd472b3084 | ||
![]() |
dcbb6a2160 | ||
![]() |
56fa1550d2 | ||
![]() |
e1f97860ee | ||
![]() |
6ab3fe18d9 | ||
![]() |
7969f3dfd7 | ||
![]() |
6f05b90e4e | ||
![]() |
3aa53d99d7 | ||
![]() |
3525f5a02f | ||
![]() |
04514a9f5c | ||
![]() |
1c915ef4cd | ||
![]() |
b03a2c5c5f | ||
![]() |
64988b285e | ||
![]() |
5c69dca7b3 | ||
![]() |
dfda7dc748 | ||
![]() |
cb7710c23f | ||
![]() |
f9b12a2eb2 | ||
![]() |
6a7617faad | ||
![]() |
a94e6c5303 | ||
![]() |
a3209c4bde | ||
![]() |
09bba96940 | ||
![]() |
6cab017042 | ||
![]() |
d08343d040 | ||
![]() |
8ab0ed5047 | ||
![]() |
1382a7b36e | ||
![]() |
47491ca55b | ||
![]() |
261bda82db | ||
![]() |
f751b0e6fc |
9
API.md
9
API.md
@@ -283,7 +283,8 @@ Optional:
|
||||
"last_version": "LAST_VERSION",
|
||||
"devices": [""],
|
||||
"image": "str",
|
||||
"custom": "bool -> if custom image"
|
||||
"custom": "bool -> if custom image",
|
||||
"boot": "bool"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -304,6 +305,8 @@ Output is the raw Docker log.
|
||||
- POST `/homeassistant/restart`
|
||||
- POST `/homeassistant/options`
|
||||
- POST `/homeassistant/check`
|
||||
- POST `/homeassistant/start`
|
||||
- POST `/homeassistant/stop`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -435,6 +438,10 @@ Output is the raw Docker log.
|
||||
|
||||
- POST `/addons/{addon}/restart`
|
||||
|
||||
- POST `/addons/{addon}/rebuild`
|
||||
|
||||
Only supported for local build addons
|
||||
|
||||
## Host Control
|
||||
|
||||
Communicate over UNIX socket with a host daemon.
|
||||
|
@@ -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
|
||||
|
@@ -8,11 +8,12 @@ import shutil
|
||||
import tarfile
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from deepmerge import Merger
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from .validate import (
|
||||
validate_options, SCHEMA_ADDON_SNAPSHOT, MAP_VOLUME)
|
||||
validate_options, SCHEMA_ADDON_SNAPSHOT, RE_VOLUME)
|
||||
from ..const import (
|
||||
ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_BOOT, ATTR_MAP,
|
||||
ATTR_OPTIONS, ATTR_PORTS, ATTR_SCHEMA, ATTR_IMAGE, ATTR_REPOSITORY,
|
||||
@@ -27,21 +28,22 @@ from ..tools import write_json_file, read_json_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RE_VOLUME = re.compile(MAP_VOLUME)
|
||||
RE_WEBUI = re.compile(r"^(.*\[HOST\]:)\[PORT:(\d+)\](.*)$")
|
||||
|
||||
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."""
|
||||
@@ -104,10 +106,10 @@ class Addon(object):
|
||||
def options(self):
|
||||
"""Return options with local changes."""
|
||||
if self.is_installed:
|
||||
return {
|
||||
**self.data.system[self._id][ATTR_OPTIONS],
|
||||
**self.data.user[self._id][ATTR_OPTIONS],
|
||||
}
|
||||
return MERGE_OPT.merge(
|
||||
self.data.system[self._id][ATTR_OPTIONS],
|
||||
self.data.user[self._id][ATTR_OPTIONS],
|
||||
)
|
||||
return self.data.cache[self._id][ATTR_OPTIONS]
|
||||
|
||||
@options.setter
|
||||
@@ -176,8 +178,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.host_network or ATTR_PORTS not in self._mesh:
|
||||
return None
|
||||
|
||||
if not self.is_installed or \
|
||||
ATTR_NETWORK not in self.data.user[self._id]:
|
||||
@@ -203,7 +205,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)
|
||||
@@ -219,11 +221,9 @@ class Addon(object):
|
||||
return RE_WEBUI.sub(r"\g<1>{}\g<3>".format(real_port), webui)
|
||||
|
||||
@property
|
||||
def network_mode(self):
|
||||
"""Return network mode of addon."""
|
||||
if self._mesh[ATTR_HOST_NETWORK]:
|
||||
return 'host'
|
||||
return 'bridge'
|
||||
def host_network(self):
|
||||
"""Return True if addon run on host network."""
|
||||
return self._mesh[ATTR_HOST_NETWORK]
|
||||
|
||||
@property
|
||||
def devices(self):
|
||||
@@ -259,7 +259,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]:
|
||||
@@ -515,6 +515,27 @@ class Addon(object):
|
||||
"""
|
||||
return self.docker.logs()
|
||||
|
||||
@check_installed
|
||||
async def rebuild(self):
|
||||
"""Performe a rebuild of local build addon."""
|
||||
last_state = await self.state()
|
||||
|
||||
if not self.need_build:
|
||||
_LOGGER.error("Can't rebuild a none local build addon!")
|
||||
return False
|
||||
|
||||
# remove docker container but not addon config
|
||||
if not await self.docker.remove():
|
||||
return False
|
||||
|
||||
if not await self.docker.install(self.version_installed):
|
||||
return False
|
||||
|
||||
# restore state
|
||||
if last_state == STATE_STARTED:
|
||||
await self.docker.run()
|
||||
return True
|
||||
|
||||
@check_installed
|
||||
async def snapshot(self, tar_file):
|
||||
"""Snapshot a state of a addon."""
|
||||
@@ -545,11 +566,13 @@ class Addon(object):
|
||||
snapshot.add(self.path_data, arcname="data")
|
||||
|
||||
try:
|
||||
_LOGGER.info("Build snapshot for addon %s", self._id)
|
||||
await self.loop.run_in_executor(None, _create_tar)
|
||||
except tarfile.TarError as err:
|
||||
_LOGGER.error("Can't write tarfile %s -> %s", tar_file, err)
|
||||
return False
|
||||
|
||||
_LOGGER.info("Finish snapshot for addon %s", self._id)
|
||||
return True
|
||||
|
||||
async def restore(self, tar_file):
|
||||
@@ -604,6 +627,7 @@ class Addon(object):
|
||||
shutil.copytree(str(Path(temp, "data")), str(self.path_data))
|
||||
|
||||
try:
|
||||
_LOGGER.info("Restore data for addon %s", self._id)
|
||||
await self.loop.run_in_executor(None, _restore_data)
|
||||
except shutil.Error as err:
|
||||
_LOGGER.error("Can't restore origin data -> %s", err)
|
||||
@@ -613,4 +637,5 @@ class Addon(object):
|
||||
if data[ATTR_STATE] == STATE_STARTED:
|
||||
return await self.start()
|
||||
|
||||
_LOGGER.info("Finish restore for addon %s", self._id)
|
||||
return True
|
||||
|
60
hassio/addons/build.py
Normal file
60
hassio/addons/build.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""HassIO addons build environment."""
|
||||
from pathlib import Path
|
||||
|
||||
from .validate import SCHEMA_BUILD_CONFIG
|
||||
from ..const import ATTR_SQUASH, ATTR_BUILD_FROM, ATTR_ARGS, META_ADDON
|
||||
from ..tools import JsonConfig
|
||||
|
||||
|
||||
class AddonBuild(JsonConfig):
|
||||
"""Handle build options for addons."""
|
||||
|
||||
def __init__(self, config, addon):
|
||||
"""Initialize addon builder."""
|
||||
self.config = config
|
||||
self.addon = addon
|
||||
|
||||
super().__init__(
|
||||
Path(addon.path_location, 'build.json'), SCHEMA_BUILD_CONFIG)
|
||||
|
||||
def save(self):
|
||||
"""Ignore save function."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def base_image(self):
|
||||
"""Base images for this addon."""
|
||||
return self._data[ATTR_BUILD_FROM][self.config.arch]
|
||||
|
||||
@property
|
||||
def squash(self):
|
||||
"""Return True or False if squash is active."""
|
||||
return self._data[ATTR_SQUASH]
|
||||
|
||||
@property
|
||||
def additional_args(self):
|
||||
"""Return additional docker build arguments."""
|
||||
return self._data[ATTR_ARGS]
|
||||
|
||||
def get_docker_args(self, version):
|
||||
"""Create a dict with docker build arguments."""
|
||||
build_tag = "{}:{}".format(self.addon.image, version)
|
||||
|
||||
return {
|
||||
'path': str(self.addon.path_location),
|
||||
'tag': build_tag,
|
||||
'pull': True,
|
||||
'forcerm': True,
|
||||
'squash': self.squash,
|
||||
'labels': {
|
||||
'io.hass.version': version,
|
||||
'io.hass.arch': self.config.arch,
|
||||
'io.hass.type': META_ADDON,
|
||||
},
|
||||
'buildargs': {
|
||||
'BUILD_FROM': self.base_image,
|
||||
'BUILD_VERSION': version,
|
||||
'BUILD_ARCH': self.config.arch,
|
||||
**self.additional_args,
|
||||
}
|
||||
}
|
@@ -3,15 +3,13 @@ import copy
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from .util import extract_hash_from_path
|
||||
from .validate import (
|
||||
SCHEMA_ADDON_CONFIG, SCHEMA_ADDON_FILE, SCHEMA_REPOSITORY_CONFIG,
|
||||
MAP_VOLUME)
|
||||
SCHEMA_ADDON_CONFIG, SCHEMA_ADDON_FILE, SCHEMA_REPOSITORY_CONFIG)
|
||||
from ..const import (
|
||||
FILE_HASSIO_ADDONS, ATTR_VERSION, ATTR_SLUG, ATTR_REPOSITORY, ATTR_LOCATON,
|
||||
REPOSITORY_CORE, REPOSITORY_LOCAL, ATTR_USER, ATTR_SYSTEM)
|
||||
@@ -19,8 +17,6 @@ from ..tools import JsonConfig, read_json_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RE_VOLUME = re.compile(MAP_VOLUME)
|
||||
|
||||
|
||||
class Data(JsonConfig):
|
||||
"""Hold data for addons inside HassIO."""
|
||||
|
@@ -1,4 +1,6 @@
|
||||
"""Validate addons options schema."""
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
@@ -11,11 +13,12 @@ from ..const import (
|
||||
ATTR_USER, ATTR_STATE, ATTR_SYSTEM, STATE_STARTED, STATE_STOPPED,
|
||||
ATTR_LOCATON, ATTR_REPOSITORY, ATTR_TIMEOUT, ATTR_NETWORK,
|
||||
ATTR_AUTO_UPDATE, ATTR_WEBUI, ATTR_AUDIO, ATTR_AUDIO_INPUT,
|
||||
ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API)
|
||||
ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_BUILD_FROM, ATTR_SQUASH,
|
||||
ATTR_ARGS)
|
||||
from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_CHANNEL
|
||||
|
||||
|
||||
MAP_VOLUME = r"^(config|ssl|addons|backup|share)(?::(rw|:ro))?$"
|
||||
RE_VOLUME = re.compile(r"^(config|ssl|addons|backup|share)(?::(rw|:ro))?$")
|
||||
|
||||
V_STR = 'str'
|
||||
V_INT = 'int'
|
||||
@@ -24,8 +27,18 @@ V_BOOL = 'bool'
|
||||
V_EMAIL = 'email'
|
||||
V_URL = 'url'
|
||||
V_PORT = 'port'
|
||||
V_MATCH = 'match'
|
||||
|
||||
ADDON_ELEMENT = vol.In([V_STR, V_INT, V_FLOAT, V_BOOL, V_EMAIL, V_URL, V_PORT])
|
||||
RE_SCHEMA_ELEMENT = re.compile(
|
||||
r"^(?:"
|
||||
r"|str|bool|email|url|port"
|
||||
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
|
||||
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
|
||||
r"|match\((?P<match>.*)\)"
|
||||
r")$"
|
||||
)
|
||||
|
||||
SCHEMA_ELEMENT = vol.Match(RE_SCHEMA_ELEMENT)
|
||||
|
||||
ARCH_ALL = [
|
||||
ARCH_ARMHF, ARCH_AARCH64, ARCH_AMD64, ARCH_I386
|
||||
@@ -42,6 +55,13 @@ PRIVILEGED_ALL = [
|
||||
"SYS_RAWIO"
|
||||
]
|
||||
|
||||
BASE_IMAGE = {
|
||||
ARCH_ARMHF: "homeassistant/armhf-base:latest",
|
||||
ARCH_AARCH64: "homeassistant/aarch64-base:latest",
|
||||
ARCH_I386: "homeassistant/i386-base:latest",
|
||||
ARCH_AMD64: "homeassistant/amd64-base:latest",
|
||||
}
|
||||
|
||||
|
||||
def _simple_startup(value):
|
||||
"""Simple startup schema."""
|
||||
@@ -71,21 +91,21 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
|
||||
vol.Optional(ATTR_DEVICES): [vol.Match(r"^(.*):(.*):([rwm]{1,3})$")],
|
||||
vol.Optional(ATTR_TMPFS):
|
||||
vol.Match(r"^size=(\d)*[kmg](,uid=\d{1,4})?(,rw)?$"),
|
||||
vol.Optional(ATTR_MAP, default=[]): [vol.Match(MAP_VOLUME)],
|
||||
vol.Optional(ATTR_MAP, default=[]): [vol.Match(RE_VOLUME)],
|
||||
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
|
||||
vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
|
||||
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_HASSIO_API, default=False): vol.Boolean(),
|
||||
vol.Required(ATTR_OPTIONS): dict,
|
||||
vol.Required(ATTR_SCHEMA): vol.Any(vol.Schema({
|
||||
vol.Coerce(str): vol.Any(ADDON_ELEMENT, [
|
||||
vol.Any(ADDON_ELEMENT, {vol.Coerce(str): ADDON_ELEMENT})
|
||||
], vol.Schema({vol.Coerce(str): ADDON_ELEMENT}))
|
||||
vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [
|
||||
vol.Any(SCHEMA_ELEMENT, {vol.Coerce(str): SCHEMA_ELEMENT})
|
||||
], vol.Schema({vol.Coerce(str): SCHEMA_ELEMENT}))
|
||||
}), False),
|
||||
vol.Optional(ATTR_IMAGE): vol.Match(r"\w*/\w*"),
|
||||
vol.Optional(ATTR_IMAGE): vol.Match(r"^[\-\w{}]+/[\-\w{}]+$"),
|
||||
vol.Optional(ATTR_TIMEOUT, default=10):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=10, max=120))
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
@@ -93,7 +113,19 @@ SCHEMA_REPOSITORY_CONFIG = vol.Schema({
|
||||
vol.Required(ATTR_NAME): vol.Coerce(str),
|
||||
vol.Optional(ATTR_URL): vol.Url(),
|
||||
vol.Optional(ATTR_MAINTAINER): vol.Coerce(str),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_BUILD_CONFIG = vol.Schema({
|
||||
vol.Optional(ATTR_BUILD_FROM, default=BASE_IMAGE): vol.Schema({
|
||||
vol.In(ARCH_ALL): vol.Match(r"^[\-\w{}]+/[\-\w{}]+:[\-\w{}]+$"),
|
||||
}),
|
||||
vol.Optional(ATTR_SQUASH, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_ARGS, default={}): vol.Schema({
|
||||
vol.Coerce(str): vol.Coerce(str)
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
@@ -172,20 +204,32 @@ def _single_validate(typ, value, key):
|
||||
if value is None:
|
||||
raise vol.Invalid("Missing required option '{}'.".format(key))
|
||||
|
||||
if typ == V_STR:
|
||||
# parse extend data from type
|
||||
match = RE_SCHEMA_ELEMENT.match(typ)
|
||||
|
||||
# prepare range
|
||||
range_args = {}
|
||||
for group_name in ('i_min', 'i_max', 'f_min', 'f_max'):
|
||||
group_value = match.group(group_name)
|
||||
if group_value:
|
||||
range_args[group_name[2:]] = float(group_value)
|
||||
|
||||
if typ.startswith(V_STR):
|
||||
return str(value)
|
||||
elif typ == V_INT:
|
||||
return int(value)
|
||||
elif typ == V_FLOAT:
|
||||
return float(value)
|
||||
elif typ == V_BOOL:
|
||||
elif typ.startswith(V_INT):
|
||||
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
|
||||
elif typ.startswith(V_FLOAT):
|
||||
return vol.All(vol.Coerce(float), vol.Range(**range_args))(value)
|
||||
elif typ.startswith(V_BOOL):
|
||||
return vol.Boolean()(value)
|
||||
elif typ == V_EMAIL:
|
||||
elif typ.startswith(V_EMAIL):
|
||||
return vol.Email()(value)
|
||||
elif typ == V_URL:
|
||||
elif typ.startswith(V_URL):
|
||||
return vol.Url()(value)
|
||||
elif typ == V_PORT:
|
||||
elif typ.startswith(V_PORT):
|
||||
return NETWORK_PORT(value)
|
||||
elif typ.startswith(V_MATCH):
|
||||
return vol.Match(match.group('match'))(str(value))
|
||||
|
||||
raise vol.Invalid("Fatal error for {} type {}".format(key, typ))
|
||||
except ValueError:
|
||||
|
@@ -72,6 +72,8 @@ class RestAPI(object):
|
||||
self.webapp.router.add_post('/homeassistant/options', api_hass.options)
|
||||
self.webapp.router.add_post('/homeassistant/update', api_hass.update)
|
||||
self.webapp.router.add_post('/homeassistant/restart', api_hass.restart)
|
||||
self.webapp.router.add_post('/homeassistant/stop', api_hass.stop)
|
||||
self.webapp.router.add_post('/homeassistant/start', api_hass.start)
|
||||
self.webapp.router.add_post('/homeassistant/check', api_hass.check)
|
||||
|
||||
def register_addons(self, addons):
|
||||
@@ -94,6 +96,8 @@ class RestAPI(object):
|
||||
'/addons/{addon}/update', api_addons.update)
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/options', api_addons.options)
|
||||
self.webapp.router.add_post(
|
||||
'/addons/{addon}/rebuild', api_addons.rebuild)
|
||||
self.webapp.router.add_get('/addons/{addon}/logs', api_addons.logs)
|
||||
self.webapp.router.add_get('/addons/{addon}/logo', api_addons.logo)
|
||||
|
||||
|
@@ -121,7 +121,7 @@ class APIAddons(object):
|
||||
ATTR_DETACHED: addon.is_detached,
|
||||
ATTR_BUILD: addon.need_build,
|
||||
ATTR_NETWORK: addon.ports,
|
||||
ATTR_HOST_NETWORK: addon.network_mode == 'host',
|
||||
ATTR_HOST_NETWORK: addon.host_network,
|
||||
ATTR_PRIVILEGED: addon.privileged,
|
||||
ATTR_DEVICES: self._pretty_devices(addon),
|
||||
ATTR_LOGO: addon.with_logo,
|
||||
@@ -213,6 +213,15 @@ class APIAddons(object):
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.restart(), loop=self.loop)
|
||||
|
||||
@api_process
|
||||
def rebuild(self, request):
|
||||
"""Rebuild local build addon."""
|
||||
addon = self._extract_addon(request)
|
||||
if not addon.need_build:
|
||||
raise RuntimeError("Only local build addons are supported")
|
||||
|
||||
return asyncio.shield(addon.rebuild(), loop=self.loop)
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
||||
def logs(self, request):
|
||||
"""Return logs from addon."""
|
||||
|
@@ -7,14 +7,16 @@ import voluptuous as vol
|
||||
from .util import api_process, api_process_raw, api_validate
|
||||
from ..const import (
|
||||
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM,
|
||||
CONTENT_TYPE_BINARY)
|
||||
ATTR_BOOT, CONTENT_TYPE_BINARY)
|
||||
from ..validate import HASS_DEVICES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_OPTIONS = vol.Schema({
|
||||
vol.Optional(ATTR_DEVICES): HASS_DEVICES,
|
||||
vol.Optional(ATTR_BOOT): vol.Boolean(),
|
||||
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)),
|
||||
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
|
||||
vol.Any(None, vol.Coerce(str)),
|
||||
@@ -43,6 +45,7 @@ class APIHomeAssistant(object):
|
||||
ATTR_IMAGE: self.homeassistant.image,
|
||||
ATTR_DEVICES: self.homeassistant.devices,
|
||||
ATTR_CUSTOM: self.homeassistant.is_custom_image,
|
||||
ATTR_BOOT: self.homeassistant.boot,
|
||||
}
|
||||
|
||||
@api_process
|
||||
@@ -57,6 +60,9 @@ class APIHomeAssistant(object):
|
||||
self.homeassistant.set_custom(
|
||||
body[ATTR_IMAGE], body[ATTR_LAST_VERSION])
|
||||
|
||||
if ATTR_BOOT in body:
|
||||
self.homeassistant.boot = body[ATTR_BOOT]
|
||||
|
||||
return True
|
||||
|
||||
@api_process
|
||||
@@ -71,6 +77,16 @@ class APIHomeAssistant(object):
|
||||
return await asyncio.shield(
|
||||
self.homeassistant.update(version), loop=self.loop)
|
||||
|
||||
@api_process
|
||||
def stop(self, request):
|
||||
"""Stop homeassistant."""
|
||||
return asyncio.shield(self.homeassistant.stop(), loop=self.loop)
|
||||
|
||||
@api_process
|
||||
def start(self, request):
|
||||
"""Start homeassistant."""
|
||||
return asyncio.shield(self.homeassistant.run(), loop=self.loop)
|
||||
|
||||
@api_process
|
||||
def restart(self, request):
|
||||
"""Restart homeassistant."""
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -6,15 +6,13 @@ 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)
|
||||
from .tools import JsonConfig
|
||||
ATTR_PASSWORD, ATTR_TOTP, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST,
|
||||
ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_LAST_BOOT)
|
||||
from .tools import JsonConfig, parse_datetime
|
||||
from .validate import SCHEMA_HASSIO_CONFIG
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATETIME_FORMAT = "%Y%m%d %H:%M:%S"
|
||||
|
||||
HOMEASSISTANT_CONFIG = PurePath("homeassistant")
|
||||
|
||||
HASSIO_SSL = PurePath("ssl")
|
||||
@@ -28,6 +26,8 @@ BACKUP_DATA = PurePath("backup")
|
||||
SHARE_DATA = PurePath("share")
|
||||
TMP_DATA = PurePath("tmp")
|
||||
|
||||
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
|
||||
|
||||
|
||||
class CoreConfig(JsonConfig):
|
||||
"""Hold all core config data."""
|
||||
@@ -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."""
|
||||
@@ -58,6 +48,22 @@ class CoreConfig(JsonConfig):
|
||||
self._data[ATTR_TIMEZONE] = value
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def last_boot(self):
|
||||
"""Return last boot datetime."""
|
||||
boot_str = self._data.get(ATTR_LAST_BOOT, DEFAULT_BOOT_TIME)
|
||||
|
||||
boot_time = parse_datetime(boot_str)
|
||||
if not boot_time:
|
||||
return datetime.utcfromtimestamp(1)
|
||||
return boot_time
|
||||
|
||||
@last_boot.setter
|
||||
def last_boot(self, value):
|
||||
"""Set last boot datetime."""
|
||||
self._data[ATTR_LAST_BOOT] = value.isoformat()
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def path_hassio(self):
|
||||
"""Return hassio data path."""
|
||||
@@ -201,14 +207,14 @@ class CoreConfig(JsonConfig):
|
||||
def security_sessions(self):
|
||||
"""Return api sessions."""
|
||||
return {
|
||||
session: datetime.strptime(until, DATETIME_FORMAT) for
|
||||
session: parse_datetime(until) for
|
||||
session, until in self._data[ATTR_SESSIONS].items()
|
||||
}
|
||||
|
||||
def add_security_session(self, session, valid):
|
||||
"""Set the a new session."""
|
||||
self._data[ATTR_SESSIONS].update(
|
||||
{session: valid.strftime(DATETIME_FORMAT)}
|
||||
{session: valid.isoformat()}
|
||||
)
|
||||
self.save()
|
||||
|
||||
|
@@ -1,7 +1,8 @@
|
||||
"""Const file for HassIO."""
|
||||
from pathlib import Path
|
||||
from ipaddress import ip_network
|
||||
|
||||
HASSIO_VERSION = '0.57'
|
||||
HASSIO_VERSION = '0.64'
|
||||
|
||||
URL_HASSIO_VERSION = ('https://raw.githubusercontent.com/home-assistant/'
|
||||
'hassio/{}/version.json')
|
||||
@@ -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'
|
||||
@@ -50,12 +55,14 @@ ATTR_DATE = 'date'
|
||||
ATTR_ARCH = 'arch'
|
||||
ATTR_HOSTNAME = 'hostname'
|
||||
ATTR_TIMEZONE = 'timezone'
|
||||
ATTR_ARGS = 'args'
|
||||
ATTR_OS = 'os'
|
||||
ATTR_TYPE = 'type'
|
||||
ATTR_SOURCE = 'source'
|
||||
ATTR_FEATURES = 'features'
|
||||
ATTR_ADDONS = 'addons'
|
||||
ATTR_VERSION = 'version'
|
||||
ATTR_LAST_BOOT = 'last_boot'
|
||||
ATTR_LAST_VERSION = 'last_version'
|
||||
ATTR_BETA_CHANNEL = 'beta_channel'
|
||||
ATTR_NAME = 'name'
|
||||
@@ -111,7 +118,8 @@ ATTR_OUTPUT = 'output'
|
||||
ATTR_DISK = 'disk'
|
||||
ATTR_SERIAL = 'serial'
|
||||
ATTR_SECURITY = 'security'
|
||||
ATTR_API_ENDPOINT = 'api_endpoint'
|
||||
ATTR_BUILD_FROM = 'build_from'
|
||||
ATTR_SQUASH = 'squash'
|
||||
ATTR_ADDONS_CUSTOM_LIST = 'addons_custom_list'
|
||||
|
||||
STARTUP_INITIALIZE = 'initialize'
|
||||
|
@@ -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,11 +138,11 @@ 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
|
||||
if await self.homeassistant.is_running():
|
||||
if self.hardware.last_boot == self.config.last_boot:
|
||||
_LOGGER.info("HassIO reboot detected")
|
||||
return
|
||||
|
||||
@@ -151,11 +153,15 @@ class HassIO(object):
|
||||
await self.addons.auto_boot(STARTUP_SERVICES)
|
||||
|
||||
# run HomeAssistant
|
||||
await self.homeassistant.run()
|
||||
if self.homeassistant.boot:
|
||||
await self.homeassistant.run()
|
||||
|
||||
# start addon mark as application
|
||||
await self.addons.auto_boot(STARTUP_APPLICATION)
|
||||
|
||||
# store new last boot
|
||||
self.config.last_boot = self.hardware.last_boot
|
||||
|
||||
finally:
|
||||
# schedule homeassistant watchdog
|
||||
self.scheduler.register_task(
|
||||
@@ -173,7 +179,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()
|
||||
|
40
hassio/dns.py
Normal file
40
hassio/dns.py
Normal 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-RECVFROM:53,fork UDP-SENDTO: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")
|
@@ -1,324 +1,108 @@
|
||||
"""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)
|
||||
container = self.docker.containers.create(image, **kwargs)
|
||||
except docker.errors.DockerException as err:
|
||||
_LOGGER.error("Can't create container from %s -> %s", name, 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
|
||||
# attach network
|
||||
if not network_mode:
|
||||
alias = [hostname] if hostname else None
|
||||
if self.network.attach_container(container, alias=alias):
|
||||
self.network.detach_default_bridge(container)
|
||||
else:
|
||||
obj_data = self.dock.containers.get(self.name).attrs
|
||||
except docker.errors.DockerException:
|
||||
return False
|
||||
_LOGGER.warning("Can't attach %s to hassio-net!", name)
|
||||
|
||||
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.
|
||||
"""
|
||||
# run container
|
||||
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.start()
|
||||
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.dock.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.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)
|
||||
|
@@ -1,28 +1,27 @@
|
||||
"""Init file for HassIO addon docker object."""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
import docker
|
||||
import requests
|
||||
|
||||
from . import DockerBase
|
||||
from .util import dockerfile_template, docker_process
|
||||
from .interface import DockerInterface
|
||||
from .util import docker_process
|
||||
from ..addons.build import AddonBuild
|
||||
from ..const import (
|
||||
META_ADDON, MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE)
|
||||
MAP_CONFIG, MAP_SSL, MAP_ADDONS, MAP_BACKUP, MAP_SHARE)
|
||||
|
||||
_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
|
||||
@@ -64,6 +63,18 @@ class DockerAddon(DockerBase):
|
||||
return devices
|
||||
return None
|
||||
|
||||
@property
|
||||
def ports(self):
|
||||
"""Filter None from addon ports."""
|
||||
if not self.addon.ports:
|
||||
return None
|
||||
|
||||
return {
|
||||
container_port: host_port
|
||||
for container_port, host_port in self.addon.ports.items()
|
||||
if host_port
|
||||
}
|
||||
|
||||
@property
|
||||
def tmpfs(self):
|
||||
"""Return tmpfs for docker add-on."""
|
||||
@@ -73,15 +84,19 @@ 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
|
||||
def network_mode(self):
|
||||
"""Return network mode for addon."""
|
||||
if self.addon.host_network:
|
||||
return 'host'
|
||||
return None
|
||||
|
||||
@property
|
||||
def volumes(self):
|
||||
"""Generate volumes for mappings."""
|
||||
@@ -139,29 +154,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.network_mode,
|
||||
ports=self.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.
|
||||
@@ -178,45 +190,21 @@ class DockerAddon(DockerBase):
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
build_dir = Path(self.config.path_tmp, self.addon.slug)
|
||||
build_env = AddonBuild(self.config, self.addon)
|
||||
|
||||
_LOGGER.info("Start build %s:%s", self.image, tag)
|
||||
try:
|
||||
# prepare temporary addon build folder
|
||||
try:
|
||||
source = self.addon.path_location
|
||||
shutil.copytree(str(source), str(build_dir))
|
||||
except shutil.Error as err:
|
||||
_LOGGER.error("Can't copy %s to temporary build folder -> %s",
|
||||
source, err)
|
||||
return False
|
||||
image = self.docker.images.build(**build_env.get_docker_args(tag))
|
||||
|
||||
# prepare Dockerfile
|
||||
try:
|
||||
dockerfile_template(
|
||||
Path(build_dir, 'Dockerfile'), self.config.arch,
|
||||
tag, META_ADDON)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't prepare dockerfile -> %s", err)
|
||||
image.tag(self.image, tag='latest')
|
||||
self.process_metadata(image.attrs, force=True)
|
||||
|
||||
# run docker build
|
||||
try:
|
||||
build_tag = "{}:{}".format(self.image, tag)
|
||||
except (docker.errors.DockerException) as err:
|
||||
_LOGGER.error("Can't build %s:%s -> %s", self.image, tag, err)
|
||||
return False
|
||||
|
||||
_LOGGER.info("Start build %s on %s", build_tag, build_dir)
|
||||
image = self.dock.images.build(
|
||||
path=str(build_dir), tag=build_tag, pull=True)
|
||||
|
||||
image.tag(self.image, tag='latest')
|
||||
self.process_metadata(image.attrs, force=True)
|
||||
|
||||
except (docker.errors.DockerException, TypeError) as err:
|
||||
_LOGGER.error("Can't build %s -> %s", build_tag, err)
|
||||
return False
|
||||
|
||||
_LOGGER.info("Build %s done", build_tag)
|
||||
return True
|
||||
|
||||
finally:
|
||||
shutil.rmtree(str(build_dir), ignore_errors=True)
|
||||
_LOGGER.info("Build %s:%s done", self.image, tag)
|
||||
return True
|
||||
|
||||
@docker_process
|
||||
def export_image(self, path):
|
||||
@@ -229,7 +217,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
|
||||
@@ -257,9 +245,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)
|
||||
|
@@ -1,22 +1,21 @@
|
||||
"""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 +46,68 @@ 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)
|
||||
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'},
|
||||
}
|
||||
)
|
||||
|
||||
def is_initialize(self):
|
||||
"""Return True if docker container exists."""
|
||||
return self.loop.run_in_executor(None, self._is_initialize)
|
||||
|
||||
def _is_initialize(self):
|
||||
"""Return True if docker container exists.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
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'},
|
||||
}
|
||||
)
|
||||
self.docker.containers.get(self.name)
|
||||
except docker.errors.DockerException:
|
||||
return False
|
||||
|
||||
# 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 True
|
||||
|
327
hassio/dock/interface.py
Normal file
327
hassio/dock/interface.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""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
|
||||
|
||||
def logs(self):
|
||||
"""Return docker logs of container.
|
||||
|
||||
Return a Future.
|
||||
"""
|
||||
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()
|
89
hassio/dock/network.py
Normal file
89
hassio/dock/network.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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 None
|
||||
|
||||
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
|
||||
|
||||
def detach_default_bridge(self, container):
|
||||
"""Detach default docker bridge.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
try:
|
||||
default_network = self.docker.networks.get('bridge')
|
||||
default_network.disconnect(container)
|
||||
|
||||
except docker.errors.NotFound:
|
||||
return
|
||||
|
||||
except docker.errors.APIError as err:
|
||||
_LOGGER.warning(
|
||||
"Can't disconnect container from default -> %s", err)
|
@@ -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."""
|
||||
|
@@ -1,48 +1,8 @@
|
||||
"""HassIO docker utilitys."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from ..const import ARCH_AARCH64, ARCH_ARMHF, ARCH_I386, ARCH_AMD64
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HASSIO_BASE_IMAGE = {
|
||||
ARCH_ARMHF: "homeassistant/armhf-base:latest",
|
||||
ARCH_AARCH64: "homeassistant/aarch64-base:latest",
|
||||
ARCH_I386: "homeassistant/i386-base:latest",
|
||||
ARCH_AMD64: "homeassistant/amd64-base:latest",
|
||||
}
|
||||
|
||||
TMPL_IMAGE = re.compile(r"%%BASE_IMAGE%%")
|
||||
|
||||
|
||||
def dockerfile_template(dockerfile, arch, version, meta_type):
|
||||
"""Prepare a Hass.IO dockerfile."""
|
||||
buff = []
|
||||
hassio_image = HASSIO_BASE_IMAGE[arch]
|
||||
custom_image = re.compile(r"^#{}:FROM".format(arch))
|
||||
|
||||
# read docker
|
||||
with dockerfile.open('r') as dock_input:
|
||||
for line in dock_input:
|
||||
line = TMPL_IMAGE.sub(hassio_image, line)
|
||||
line = custom_image.sub("FROM", line)
|
||||
buff.append(line)
|
||||
|
||||
# add metadata
|
||||
buff.append(create_metadata(version, arch, meta_type))
|
||||
|
||||
# write docker
|
||||
with dockerfile.open('w') as dock_output:
|
||||
dock_output.writelines(buff)
|
||||
|
||||
|
||||
def create_metadata(version, arch, meta_type):
|
||||
"""Generate docker label layer for hassio."""
|
||||
return ('LABEL io.hass.version="{}" '
|
||||
'io.hass.arch="{}" '
|
||||
'io.hass.type="{}"').format(version, arch, meta_type)
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
def docker_process(method):
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""Read hardware info from system."""
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -15,6 +16,9 @@ RE_CARDS = re.compile(r"(\d+) \[(\w*) *\]: (.*\w)")
|
||||
ASOUND_DEVICES = Path("/proc/asound/devices")
|
||||
RE_DEVICES = re.compile(r"\[.*(\d+)- (\d+).*\]: ([\w ]*)")
|
||||
|
||||
PROC_STAT = Path("/proc/stat")
|
||||
RE_BOOT_TIME = re.compile(r"btime (\d+)")
|
||||
|
||||
|
||||
class Hardware(object):
|
||||
"""Represent a interface to procfs, sysfs and udev."""
|
||||
@@ -85,3 +89,21 @@ class Hardware(object):
|
||||
continue
|
||||
|
||||
return audio_list
|
||||
|
||||
@property
|
||||
def last_boot(self):
|
||||
"""Return last boot time."""
|
||||
try:
|
||||
with PROC_STAT.open("r") as stat_file:
|
||||
stats = stat_file.read()
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't read stat data -> %s", err)
|
||||
return
|
||||
|
||||
# parse stat file
|
||||
found = RE_BOOT_TIME.search(stats)
|
||||
if not found:
|
||||
_LOGGER.error("Can't found last boot time!")
|
||||
return
|
||||
|
||||
return datetime.utcfromtimestamp(int(found.group(1)))
|
||||
|
@@ -6,7 +6,7 @@ import re
|
||||
|
||||
from .const import (
|
||||
FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION,
|
||||
ATTR_VERSION)
|
||||
ATTR_VERSION, ATTR_BOOT)
|
||||
from .dock.homeassistant import DockerHomeAssistant
|
||||
from .tools import JsonConfig, convert_to_ascii
|
||||
from .validate import SCHEMA_HASS_CONFIG
|
||||
@@ -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."""
|
||||
@@ -73,6 +73,17 @@ class HomeAssistant(JsonConfig):
|
||||
self._data[ATTR_DEVICES] = value
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def boot(self):
|
||||
"""Return True if home-assistant boot is enabled."""
|
||||
return self._data[ATTR_BOOT]
|
||||
|
||||
@boot.setter
|
||||
def boot(self, value):
|
||||
"""Set home-assistant boot options."""
|
||||
self._data[ATTR_BOOT] = value
|
||||
self.save()
|
||||
|
||||
def set_custom(self, image, version):
|
||||
"""Set a custom image for homeassistant."""
|
||||
# reset
|
||||
@@ -98,6 +109,9 @@ class HomeAssistant(JsonConfig):
|
||||
_LOGGER.warning("Fails install landingpage, retry after 60sec")
|
||||
await asyncio.sleep(60, loop=self.loop)
|
||||
|
||||
# run landingpage after installation
|
||||
await self.docker.run()
|
||||
|
||||
async def install(self):
|
||||
"""Install a landingpage."""
|
||||
_LOGGER.info("Setup HomeAssistant")
|
||||
@@ -112,13 +126,16 @@ class HomeAssistant(JsonConfig):
|
||||
_LOGGER.warning("Error on install HomeAssistant. Retry in 60sec")
|
||||
await asyncio.sleep(60, loop=self.loop)
|
||||
|
||||
# store version
|
||||
# finishing
|
||||
_LOGGER.info("HomeAssistant docker now installed")
|
||||
if self.boot:
|
||||
await self.docker.run()
|
||||
await self.docker.cleanup()
|
||||
|
||||
async def update(self, version=None):
|
||||
"""Update HomeAssistant version."""
|
||||
version = version or self.last_version
|
||||
running = await self.docker.is_running()
|
||||
|
||||
if version == self.docker.version:
|
||||
_LOGGER.warning("Version %s is already installed", version)
|
||||
@@ -127,7 +144,8 @@ class HomeAssistant(JsonConfig):
|
||||
try:
|
||||
return await self.docker.update(version)
|
||||
finally:
|
||||
await self.docker.run()
|
||||
if running:
|
||||
await self.docker.run()
|
||||
|
||||
def run(self):
|
||||
"""Run HomeAssistant docker.
|
||||
@@ -164,6 +182,13 @@ class HomeAssistant(JsonConfig):
|
||||
"""
|
||||
return self.docker.is_running()
|
||||
|
||||
def is_initialize(self):
|
||||
"""Return True if a docker container is exists.
|
||||
|
||||
Return a coroutine.
|
||||
"""
|
||||
return self.docker.is_initialize()
|
||||
|
||||
@property
|
||||
def in_progress(self):
|
||||
"""Return True if a task is in progress."""
|
||||
|
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -66,6 +66,11 @@ def homeassistant_watchdog(loop, homeassistant):
|
||||
"""Create scheduler task for montoring running state."""
|
||||
async def _homeassistant_watchdog():
|
||||
"""Check running state and start if they is close."""
|
||||
# if Home-Assistant is active
|
||||
if not await homeassistant.is_initialize():
|
||||
return
|
||||
|
||||
# If Home-Assistant is running
|
||||
if homeassistant.in_progress or await homeassistant.is_running():
|
||||
return
|
||||
|
||||
|
@@ -1,14 +1,14 @@
|
||||
"""Tools file for HassIO."""
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import pytz
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
@@ -18,27 +18,15 @@ 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)
|
||||
# Copyright (c) Django Software Foundation and individual contributors.
|
||||
# All rights reserved.
|
||||
# https://github.com/django/django/blob/master/LICENSE
|
||||
DATETIME_RE = re.compile(
|
||||
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
|
||||
r'[T ](?P<hour>\d{1,2}):(?P<minute>\d{1,2})'
|
||||
r'(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?'
|
||||
r'(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$'
|
||||
)
|
||||
|
||||
|
||||
def write_json_file(jsonfile, data):
|
||||
@@ -76,6 +64,42 @@ def convert_to_ascii(raw):
|
||||
return RE_STRING.sub("", raw.decode())
|
||||
|
||||
|
||||
# Copyright (c) Django Software Foundation and individual contributors.
|
||||
# All rights reserved.
|
||||
# https://github.com/django/django/blob/master/LICENSE
|
||||
def parse_datetime(dt_str):
|
||||
"""Parse a string and return a datetime.datetime.
|
||||
|
||||
This function supports time zone offsets. When the input contains one,
|
||||
the output uses a timezone with a fixed offset from UTC.
|
||||
Raises ValueError if the input is well formatted but not a valid datetime.
|
||||
Returns None if the input isn't well formatted.
|
||||
"""
|
||||
match = DATETIME_RE.match(dt_str)
|
||||
if not match:
|
||||
return None
|
||||
kws = match.groupdict() # type: Dict[str, Any]
|
||||
if kws['microsecond']:
|
||||
kws['microsecond'] = kws['microsecond'].ljust(6, '0')
|
||||
tzinfo_str = kws.pop('tzinfo')
|
||||
|
||||
tzinfo = None # type: Optional[dt.tzinfo]
|
||||
if tzinfo_str == 'Z':
|
||||
tzinfo = pytz.utc
|
||||
elif tzinfo_str is not None:
|
||||
offset_mins = int(tzinfo_str[-2:]) if len(tzinfo_str) > 3 else 0
|
||||
offset_hours = int(tzinfo_str[1:3])
|
||||
offset = timedelta(hours=offset_hours, minutes=offset_mins)
|
||||
if tzinfo_str[0] == '-':
|
||||
offset = -offset
|
||||
tzinfo = timezone(offset)
|
||||
else:
|
||||
tzinfo = None
|
||||
kws = {k: int(v) for k, v in kws.items() if v is not None}
|
||||
kws['tzinfo'] = tzinfo
|
||||
return datetime(**kws)
|
||||
|
||||
|
||||
class JsonConfig(object):
|
||||
"""Hass core object for handle it."""
|
||||
|
||||
@@ -99,6 +123,8 @@ class JsonConfig(object):
|
||||
except vol.Invalid as ex:
|
||||
_LOGGER.error("Can't parse %s -> %s",
|
||||
self._file, humanize_error(self._data, ex))
|
||||
# reset data to default
|
||||
self._data = self._schema({})
|
||||
|
||||
def save(self):
|
||||
"""Store data to config file."""
|
||||
|
@@ -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, ATTR_BOOT, ATTR_LAST_BOOT)
|
||||
|
||||
|
||||
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
|
||||
@@ -55,8 +55,10 @@ DOCKER_PORTS = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=no-value-for-parameter
|
||||
SCHEMA_HASS_CONFIG = vol.Schema({
|
||||
vol.Optional(ATTR_DEVICES, default=[]): HASS_DEVICES,
|
||||
vol.Optional(ATTR_BOOT, default=True): vol.Boolean(),
|
||||
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Coerce(str),
|
||||
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
|
||||
})
|
||||
@@ -72,8 +74,8 @@ 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_LAST_BOOT): vol.Coerce(str),
|
||||
vol.Optional(ATTR_ADDONS_CUSTOM_LIST, default=[]): [vol.Url()],
|
||||
vol.Optional(ATTR_SECURITY, default=False): vol.Boolean(),
|
||||
vol.Optional(ATTR_TOTP): vol.Coerce(str),
|
||||
|
Submodule home-assistant-polymer updated: 1aa387a72f...9b9cba86c2
3
setup.py
3
setup.py
@@ -47,6 +47,7 @@ setup(
|
||||
'pyotp',
|
||||
'pyqrcode',
|
||||
'pytz',
|
||||
'pyudev'
|
||||
'pyudev',
|
||||
'deepmerge'
|
||||
]
|
||||
)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"hassio": "0.56",
|
||||
"homeassistant": "0.51.2",
|
||||
"resinos": "1.0",
|
||||
"hassio": "0.64",
|
||||
"homeassistant": "0.54",
|
||||
"resinos": "1.1",
|
||||
"resinhup": "0.3",
|
||||
"generic": "0.3",
|
||||
"cluster": "0.1"
|
||||
|
Reference in New Issue
Block a user