Compare commits

..

5 Commits
0.7 ... 0.9

Author SHA1 Message Date
Pascal Vizeli
30243c39e6 Update version.json 2017-04-20 17:05:19 +02:00
Pascal Vizeli
d285fd4ad4 Fix handling with docker container (#7)
* Fix handling with docker container

* Fix lint

* update version

* fix lint v2

* fix signal handling

* fix log output
2017-04-20 14:59:03 +02:00
Pascal Vizeli
7a0b9cc1ac Update version_beta.json 2017-04-20 09:50:58 +02:00
Pascal Vizeli
cc63008a86 Add selfupdate task (#6)
Add an optional extended description…
2017-04-19 17:51:48 +02:00
Pascal Vizeli
f9c7371140 Extend addons options to allow lists (#5)
Extend addons options to allow lists
2017-04-19 17:07:24 +02:00
17 changed files with 262 additions and 98 deletions

View File

@@ -1,7 +1,6 @@
"""Main file for HassIO."""
import asyncio
import logging
import signal
import sys
import hassio.bootstrap as bootstrap
@@ -25,12 +24,7 @@ if __name__ == "__main__":
_LOGGER.info("Start Hassio task")
loop.call_soon_threadsafe(loop.create_task, hassio.start())
try:
loop.add_signal_handler(
signal.SIGTERM, lambda: loop.create_task(hassio.stop()))
except ValueError:
_LOGGER.warning("Could not bind to SIGTERM")
loop.call_soon_threadsafe(bootstrap.reg_signal, loop, hassio)
loop.run_forever()
loop.close()

View File

@@ -47,7 +47,7 @@ class AddonManager(AddonsData):
tasks = []
for addon in self.list_removed:
_LOGGER.info("Old addon %s found")
tasks.append(self.loop.create_task(self.dockers[addon].remove()))
tasks.append(self.loop.create_task(self.uninstall(addon)))
if tasks:
await asyncio.wait(tasks, loop=self.loop)

View File

@@ -5,11 +5,12 @@ import glob
import voluptuous as vol
from voluptuous.humanize import humanize_error
from .validate import validate_options, SCHEMA_ADDON_CONFIG
from ..const import (
FILE_HASSIO_ADDONS, ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON,
ATTR_STARTUP, ATTR_BOOT, ATTR_MAP_SSL, ATTR_MAP_CONFIG, ATTR_OPTIONS,
ATTR_PORTS, STARTUP_ONCE, STARTUP_AFTER, STARTUP_BEFORE, BOOT_AUTO,
BOOT_MANUAL, DOCKER_REPO, ATTR_INSTALLED, ATTR_SCHEMA, ATTR_IMAGE)
ATTR_PORTS, BOOT_AUTO, DOCKER_REPO, ATTR_INSTALLED, ATTR_SCHEMA,
ATTR_IMAGE, ATTR_MAP_HASSIO)
from ..config import Config
from ..tools import read_json_file, write_json_file
@@ -17,32 +18,6 @@ _LOGGER = logging.getLogger(__name__)
ADDONS_REPO_PATTERN = "{}/*/config.json"
V_STR = 'str'
V_INT = 'int'
V_FLOAT = 'float'
V_BOOL = 'bool'
# pylint: disable=no-value-for-parameter
SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_VERSION): vol.Coerce(str),
vol.Required(ATTR_SLUG): vol.Coerce(str),
vol.Required(ATTR_DESCRIPTON): vol.Coerce(str),
vol.Required(ATTR_STARTUP):
vol.In([STARTUP_BEFORE, STARTUP_AFTER, STARTUP_ONCE]),
vol.Required(ATTR_BOOT):
vol.In([BOOT_AUTO, BOOT_MANUAL]),
vol.Optional(ATTR_PORTS): dict,
vol.Required(ATTR_MAP_CONFIG): vol.Boolean(),
vol.Required(ATTR_MAP_SSL): vol.Boolean(),
vol.Required(ATTR_OPTIONS): dict,
vol.Required(ATTR_SCHEMA): {
vol.Coerce(str): vol.In([V_STR, V_INT, V_FLOAT, V_BOOL])
},
vol.Optional(ATTR_IMAGE): vol.Match(r"\w*/\w*"),
})
class AddonsData(Config):
"""Hold data for addons inside HassIO."""
@@ -56,6 +31,8 @@ class AddonsData(Config):
def read_addons_repo(self):
"""Read data from addons repository."""
self._addons_data = {}
self._read_addons_folder(self.config.path_addons_repo)
self._read_addons_folder(self.config.path_addons_custom)
@@ -213,6 +190,10 @@ class AddonsData(Config):
"""Return True if ssl map is needed."""
return self._addons_data[addon][ATTR_MAP_SSL]
def need_hassio(self, addon):
"""Return True if hassio map is needed."""
return self._addons_data[addon][ATTR_MAP_HASSIO]
def path_data(self, addon):
"""Return addon data path inside supervisor."""
return "{}/{}".format(
@@ -229,35 +210,21 @@ class AddonsData(Config):
def write_addon_options(self, addon):
"""Return True if addon options is written to data."""
return write_json_file(
self.path_addon_options(addon), self.get_options(addon))
schema = self.get_schema(addon)
options = self.get_options(addon)
try:
schema(options)
return write_json_file(self.path_addon_options(addon), options)
except vol.Invalid as ex:
_LOGGER.error("Addon %s have wrong options -> %s", addon,
humanize_error(options, ex))
return False
def get_schema(self, addon):
"""Create a schema for addon options."""
raw_schema = self._addons_data[addon][ATTR_SCHEMA]
def validate(struct):
"""Validate schema."""
options = {}
for key, value in struct.items():
if key not in raw_schema:
raise vol.Invalid("Unknown options {}.".format(key))
typ = raw_schema[key]
try:
if typ == V_STR:
options[key] = str(value)
elif typ == V_INT:
options[key] = int(value)
elif typ == V_FLOAT:
options[key] = float(value)
elif typ == V_BOOL:
options[key] = vol.Boolean()(value)
except TypeError:
raise vol.Invalid(
"Type error for {}.".format(key)) from None
return options
schema = vol.Schema(vol.All(dict(), validate))
schema = vol.Schema(vol.All(dict, validate_options(raw_schema)))
return schema

113
hassio/addons/validate.py Normal file
View File

@@ -0,0 +1,113 @@
"""Validate addons options schema."""
import voluptuous as vol
from ..const import (
ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_STARTUP,
ATTR_BOOT, ATTR_MAP_SSL, ATTR_MAP_CONFIG, ATTR_OPTIONS,
ATTR_PORTS, STARTUP_ONCE, STARTUP_AFTER, STARTUP_BEFORE, BOOT_AUTO,
BOOT_MANUAL, ATTR_SCHEMA, ATTR_IMAGE, ATTR_MAP_HASSIO)
V_STR = 'str'
V_INT = 'int'
V_FLOAT = 'float'
V_BOOL = 'bool'
V_EMAIL = 'email'
V_URL = 'url'
ADDON_ELEMENT = vol.In([V_STR, V_INT, V_FLOAT, V_BOOL, V_EMAIL, V_URL])
# pylint: disable=no-value-for-parameter
SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_VERSION): vol.Coerce(str),
vol.Required(ATTR_SLUG): vol.Coerce(str),
vol.Required(ATTR_DESCRIPTON): vol.Coerce(str),
vol.Required(ATTR_STARTUP):
vol.In([STARTUP_BEFORE, STARTUP_AFTER, STARTUP_ONCE]),
vol.Required(ATTR_BOOT):
vol.In([BOOT_AUTO, BOOT_MANUAL]),
vol.Optional(ATTR_PORTS): dict,
vol.Optional(ATTR_MAP_CONFIG, default=False): vol.Boolean(),
vol.Optional(ATTR_MAP_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_MAP_HASSIO, default=False): vol.Boolean(),
vol.Required(ATTR_OPTIONS): dict,
vol.Required(ATTR_SCHEMA): {
vol.Coerce(str): vol.Any(ADDON_ELEMENT, [
vol.Any(ADDON_ELEMENT, {vol.Coerce(str): ADDON_ELEMENT})
])
},
vol.Optional(ATTR_IMAGE): vol.Match(r"\w*/\w*"),
})
def validate_options(raw_schema):
"""Validate schema."""
def validate(struct):
"""Create schema validator for addons options."""
options = {}
# read options
for key, value in struct.items():
if key not in raw_schema:
raise vol.Invalid("Unknown options {}.".format(key))
typ = raw_schema[key]
try:
if isinstance(typ, list):
# nested value
options[key] = _nested_validate(typ[0], value)
else:
# normal value
options[key] = _single_validate(typ, value)
except (IndexError, KeyError):
raise vol.Invalid(
"Type error for {}.".format(key)) from None
return options
return validate
# pylint: disable=no-value-for-parameter
def _single_validate(typ, value):
"""Validate a single element."""
try:
if typ == V_STR:
return str(value)
elif typ == V_INT:
return int(value)
elif typ == V_FLOAT:
return float(value)
elif typ == V_BOOL:
return vol.Boolean()(value)
elif typ == V_EMAIL:
return vol.Email()(value)
elif typ == V_URL:
return vol.Url()(value)
raise vol.Invalid("Fatal error for {}.".format(value))
except TypeError:
raise vol.Invalid(
"Type {} error for {}.".format(typ, value)) from None
def _nested_validate(typ, data_list):
"""Validate nested items."""
options = []
for element in data_list:
# dict list
if isinstance(typ, dict):
c_options = {}
for c_key, c_value in element.items():
if c_key not in typ:
raise vol.Invalid(
"Unknown nested options {}.".format(c_key))
c_options[c_key] = _single_validate(typ[c_key], c_value)
options.append(c_options)
# normal list
else:
options.append(_single_validate(typ, element))
return options

View File

@@ -3,6 +3,7 @@ import asyncio
import logging
import voluptuous as vol
from voluptuous.humanize import humanize_error
from .util import api_process, api_validate
from ..const import (
@@ -88,6 +89,14 @@ class APIAddons(object):
if await self.addons.state(addon) == STATE_STARTED:
raise RuntimeError("Addon is already running")
# validate options
try:
schema = self.addons.get_schema(addon)
options = self.addons.get_options(addon)
schema(options)
except vol.Invalid as ex:
raise RuntimeError(humanize_error(options, ex)) from None
return await asyncio.shield(
self.addons.start(addon), loop=self.loop)

View File

@@ -2,6 +2,7 @@
import logging
import os
import stat
import signal
from colorlog import ColoredFormatter
@@ -81,3 +82,24 @@ def check_environment():
return False
return True
def reg_signal(loop, hassio):
"""Register SIGTERM, SIGKILL to stop system."""
try:
loop.add_signal_handler(
signal.SIGTERM, lambda: loop.create_task(hassio.stop()))
except (ValueError, RuntimeError):
_LOGGER.warning("Could not bind to SIGTERM")
try:
loop.add_signal_handler(
signal.SIGHUP, lambda: loop.create_task(hassio.stop()))
except (ValueError, RuntimeError):
_LOGGER.warning("Could not bind to SIGHUP")
try:
loop.add_signal_handler(
signal.SIGINT, lambda: loop.create_task(hassio.stop()))
except (ValueError, RuntimeError):
_LOGGER.warning("Could not bind to SIGINT")

View File

@@ -22,6 +22,8 @@ ADDONS_CUSTOM = "{}/addons_custom"
UPSTREAM_BETA = 'upstream_beta'
API_ENDPOINT = 'api_endpoint'
class Config(object):
"""Hold all config data."""
@@ -78,6 +80,16 @@ class CoreConfig(Config):
return False
@property
def api_endpoint(self):
"""Return IP address of api endpoint."""
return self._data[API_ENDPOINT]
@api_endpoint.setter
def api_endpoint(self, value):
"""Store IP address of api endpoint."""
self._data[API_ENDPOINT] = value
@property
def upstream_beta(self):
"""Return True if we run in beta upstream."""
@@ -117,10 +129,15 @@ class CoreConfig(Config):
"""Actual version of hassio."""
return self._data.get(HASSIO_CURRENT)
@property
def path_hassio_docker(self):
"""Return hassio data path extern for docker."""
return os.environ['SUPERVISOR_SHARE']
@property
def path_config_docker(self):
"""Return config path extern for docker."""
return HOMEASSISTANT_CONFIG.format(os.environ['SUPERVISOR_SHARE'])
return HOMEASSISTANT_CONFIG.format(self.path_hassio_docker)
@property
def path_config(self):
@@ -130,7 +147,7 @@ class CoreConfig(Config):
@property
def path_ssl_docker(self):
"""Return SSL path extern for docker."""
return HASSIO_SSL.format(os.environ['SUPERVISOR_SHARE'])
return HASSIO_SSL.format(self.path_hassio_docker)
@property
def path_ssl(self):
@@ -155,4 +172,4 @@ class CoreConfig(Config):
@property
def path_addons_data_docker(self):
"""Return root addon data folder extern for docker."""
return ADDONS_DATA.format(os.environ['SUPERVISOR_SHARE'])
return ADDONS_DATA.format(self.path_hassio_docker)

View File

@@ -1,5 +1,5 @@
"""Const file for HassIO."""
HASSIO_VERSION = '0.7'
HASSIO_VERSION = '0.9'
URL_HASSIO_VERSION = \
'https://raw.githubusercontent.com/pvizeli/hassio/master/version.json'
@@ -13,6 +13,7 @@ DOCKER_REPO = "pvizeli"
HASSIO_SHARE = "/data"
RUN_UPDATE_INFO_TASKS = 28800
RUN_UPDATE_SUPERVISOR_TASKS = 29100
RUN_RELOAD_ADDONS_TASKS = 28800
RESTART_EXIT_CODE = 100
@@ -42,6 +43,7 @@ ATTR_BOOT = 'boot'
ATTR_PORTS = 'ports'
ATTR_MAP_CONFIG = 'map_config'
ATTR_MAP_SSL = 'map_ssl'
ATTR_MAP_HASSIO = 'map_hassio'
ATTR_OPTIONS = 'options'
ATTR_INSTALLED = 'installed'
ATTR_STATE = 'state'

View File

@@ -11,11 +11,11 @@ from .api import RestAPI
from .host_controll import HostControll
from .const import (
SOCKET_DOCKER, RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS,
STARTUP_AFTER, STARTUP_BEFORE)
RUN_UPDATE_SUPERVISOR_TASKS, STARTUP_AFTER, STARTUP_BEFORE)
from .scheduler import Scheduler
from .dock.homeassistant import DockerHomeAssistant
from .dock.supervisor import DockerSupervisor
from .tools import get_arch_from_image
from .tools import get_arch_from_image, get_local_ip
_LOGGER = logging.getLogger(__name__)
@@ -52,6 +52,9 @@ class HassIO(object):
await self.supervisor.attach()
await self.supervisor.cleanup()
# set api endpoint
self.config.api_endpoint = await get_local_ip(self.loop)
# hostcontroll
host_info = await self.host_controll.info()
if host_info:
@@ -72,7 +75,7 @@ class HassIO(object):
# schedule update info tasks
self.scheduler.register_task(
self.config.fetch_update_infos, RUN_UPDATE_INFO_TASKS,
first_run=True)
now=True)
# first start of supervisor?
if not await self.homeassistant.exists():
@@ -85,12 +88,17 @@ class HassIO(object):
# schedule addon update task
self.scheduler.register_task(
self.addons.relaod, RUN_RELOAD_ADDONS_TASKS, first_run=True)
self.addons.relaod, RUN_RELOAD_ADDONS_TASKS, now=True)
# schedule self update task
self.scheduler.register_task(
self._hassio_update, RUN_UPDATE_SUPERVISOR_TASKS)
async def start(self):
"""Start HassIO orchestration."""
# start api
await self.api.start()
_LOGGER.info("Start hassio api on %s", self.config.api_endpoint)
# HomeAssistant is already running / supervisor have only reboot
if await self.homeassistant.is_running():
@@ -108,6 +116,10 @@ class HassIO(object):
async def stop(self, exit_code=0):
"""Stop a running orchestration."""
# don't process scheduler anymore
self.scheduler.stop()
# process stop task pararell
tasks = [self.websession.close(), self.api.stop()]
await asyncio.wait(tasks, loop=self.loop)
@@ -129,3 +141,12 @@ class HassIO(object):
# store version
_LOGGER.info("HomeAssistant docker now installed.")
async def _hassio_update(self):
"""Check and run update of supervisor hassio."""
if self.config.current_hassio == self.supervisor.version:
return
_LOGGER.info(
"Found new HassIO version %s.", self.config.current_hassio)
await self.supervisor.update(self.config.current_hassio)

View File

@@ -138,8 +138,6 @@ class DockerBase(object):
return False
async with self._lock:
_LOGGER.info("Run docker image %s with version %s",
self.image, self.version)
return await self.loop.run_in_executor(None, self._run)
def _run(self):
@@ -167,6 +165,8 @@ class DockerBase(object):
if not self.container:
return
_LOGGER.info("Stop %s docker application.", self.image)
self.container.reload()
if self.container.status == 'running':
with suppress(docker.errors.DockerException):

View File

@@ -52,6 +52,11 @@ class DockerAddon(DockerBase):
self.config.path_ssl_docker: {
'bind': '/ssl', 'mode': 'rw'
}})
if self.addons_data.need_hassio(self.addon):
volumes.update({
self.config.path_hassio_docker: {
'bind': '/hassio', 'mode': 'rw'
}})
try:
self.container = self.dock.containers.run(
@@ -60,15 +65,15 @@ class DockerAddon(DockerBase):
detach=True,
network_mode='bridge',
ports=self.addons_data.get_ports(self.addon),
restart_policy={
"Name": "on-failure",
"MaximumRetryCount": 10,
},
volumes=volumes,
)
self.version = get_version_from_env(
self.container.attrs['Config']['Env'])
_LOGGER.info("Start docker addon %s with version %s",
self.image, self.version)
except docker.errors.DockerException as err:
_LOGGER.error("Can't run %s -> %s", self.image, err)
return False

View File

@@ -4,7 +4,7 @@ import logging
import docker
from . import DockerBase
from ..tools import get_version_from_env, get_local_ip
from ..tools import get_version_from_env
_LOGGER = logging.getLogger(__name__)
@@ -31,8 +31,6 @@ class DockerHomeAssistant(DockerBase):
if self._is_running():
return
api_endpoint = get_local_ip(self.loop)
# cleanup old container
self._stop()
@@ -43,12 +41,8 @@ class DockerHomeAssistant(DockerBase):
detach=True,
privileged=True,
network_mode='host',
restart_policy={
"Name": "always",
"MaximumRetryCount": 10,
},
environment={
'HASSIO': api_endpoint,
'HASSIO': self.config.api_endpoint,
},
volumes={
self.config.path_config_docker:
@@ -59,6 +53,10 @@ class DockerHomeAssistant(DockerBase):
self.version = get_version_from_env(
self.container.attrs['Config']['Env'])
_LOGGER.info("Start docker addon %s with version %s",
self.image, self.version)
except docker.errors.DockerException as err:
_LOGGER.error("Can't run %s -> %s", self.image, err)
return False

View File

@@ -37,6 +37,9 @@ class DockerSupervisor(DockerBase):
if await self.loop.run_in_executor(None, self._install, tag):
self.config.hassio_cleanup = old_version
self.loop.create_task(self.hassio.stop(RESTART_EXIT_CODE))
return True
return False
async def cleanup(self):
"""Check if old supervisor version exists and cleanup."""

View File

@@ -16,9 +16,14 @@ class Scheduler(object):
"""Initialize task schedule."""
self.loop = loop
self._data = {}
self._stop = False
def stop(self):
"""Stop to execute tasks in scheduler."""
self._stop = True
def register_task(self, coro_callback, seconds, repeat=True,
first_run=False):
now=False):
"""Schedule a coroutine.
The coroutien need to be a callback without arguments.
@@ -34,7 +39,7 @@ class Scheduler(object):
self._data[idx] = opts
# schedule task
if first_run:
if now:
self._run_task(idx)
else:
task = self.loop.call_later(seconds, self._run_task, idx)
@@ -46,6 +51,10 @@ class Scheduler(object):
"""Run a scheduled task."""
data = self._data.pop(idx)
# stop execute tasks
if self._stop:
return
self.loop.create_task(data[CALL]())
if data[REPEAT]:

View File

@@ -55,19 +55,23 @@ def get_version_from_env(env_list):
def get_local_ip(loop):
"""Retrieve local IP address.
Need run inside executor.
Return a future.
"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
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))
# 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 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):

View File

@@ -1,5 +1,5 @@
{
"hassio_tag": "0.7",
"hassio_tag": "0.9",
"homeassistant_tag": "0.42.3",
"resinos_version": "0.3",
"resinhup_version": "0.1"

View File

@@ -1,5 +1,5 @@
{
"hassio_tag": "0.7",
"hassio_tag": "0.9",
"homeassistant_tag": "0.42.3",
"resinos_version": "0.3",
"resinhup_version": "0.1"