diff --git a/hassio/addons/data.py b/hassio/addons/data.py index 11589831e..4627434b2 100644 --- a/hassio/addons/data.py +++ b/hassio/addons/data.py @@ -14,7 +14,7 @@ from ..const import ( FILE_HASSIO_ADDONS, ATTR_NAME, ATTR_VERSION, ATTR_SLUG, ATTR_DESCRIPTON, ATTR_STARTUP, ATTR_BOOT, ATTR_MAP, ATTR_OPTIONS, ATTR_PORTS, BOOT_AUTO, DOCKER_REPO, ATTR_SCHEMA, ATTR_IMAGE, MAP_CONFIG, MAP_SSL, MAP_ADDONS, - MAP_BACKUP, ATTR_REPOSITORY, ATTR_URL, ATTR_ARCH) + MAP_BACKUP, ATTR_REPOSITORY, ATTR_URL, ATTR_ARCH, ATTR_LOCATON) from ..config import Config from ..tools import read_json_file, write_json_file @@ -109,6 +109,7 @@ class AddonsData(Config): # store addon_config[ATTR_REPOSITORY] = repository + addon_config[ATTR_LOCATON] = str(addon.parent) self._addons_cache[addon_slug] = addon_config except OSError: @@ -303,13 +304,31 @@ class AddonsData(Config): def get_image(self, addon): """Return image name of addon.""" addon_data = self._system_data.get( - addon, self._addons_cache.get(addon)) + addon, self._addons_cache.get(addon) + ) - if ATTR_IMAGE not in addon_data: + # core repository + if addon_data[ATTR_REPOSITORY] == REPOSITORY_CORE: return "{}/{}-addon-{}".format( DOCKER_REPO, self.arch, addon_data[ATTR_SLUG]) - return addon_data[ATTR_IMAGE].format(arch=self.arch) + # Repository with dockerhub images + if ATTR_IMAGE in addon_data: + return addon_data[ATTR_IMAGE].format(arch=self.arch) + + # Local build addon + if addon_data[ATTR_REPOSITORY] == REPOSITORY_LOCAL: + return "local/{}-addon-{}".format(self.arch, addon_data[ATTR_SLUG]) + + _LOGGER.error("No image for %s", addon) + + def need_build(self, addon): + """Return True if this addon need a local build.""" + addon_data = self._system_data.get( + addon, self._addons_cache.get(addon) + ) + return addon_data[ATTR_REPOSITORY] == REPOSITORY_LOCAL \ + and not addon_data.get(ATTR_IMAGE) def map_config(self, addon): """Return True if config map is needed.""" @@ -333,12 +352,16 @@ class AddonsData(Config): def path_extern_data(self, addon): """Return addon data path external for docker.""" - return str(PurePath(self.config.path_extern_addons_data, addon)) + return PurePath(self.config.path_extern_addons_data, addon) def path_addon_options(self, addon): """Return path to addons options.""" return Path(self.path_data(addon), "options.json") + def path_addon_location(self, addon): + """Return path to this addon.""" + return Path(self._addons_cache[addon][ATTR_LOCATON]) + def write_addon_options(self, addon): """Return True if addon options is written to data.""" schema = self.get_schema(addon) diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index b55b63f59..89a37cdcf 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -42,6 +42,11 @@ def initialize_system_data(websession): config.path_addons_git) config.path_addons_git.mkdir(parents=True) + if not config.path_addons_build.is_dir(): + _LOGGER.info("Create Home-Assistant addon build folder %s", + config.path_addons_build) + config.path_addons_build.mkdir(parents=True) + # homeassistant backup folder if not config.path_backup.is_dir(): _LOGGER.info("Create Home-Assistant backup folder %s", diff --git a/hassio/config.py b/hassio/config.py index 363325e4e..fad13e98a 100644 --- a/hassio/config.py +++ b/hassio/config.py @@ -27,6 +27,7 @@ ADDONS_CORE = PurePath("addons/core") ADDONS_LOCAL = PurePath("addons/local") ADDONS_GIT = PurePath("addons/git") ADDONS_DATA = PurePath("addons/data") +ADDONS_BUILD = PurePath("addons/build") ADDONS_CUSTOM_LIST = 'addons_custom_list' BACKUP_DATA = PurePath("backup") @@ -205,7 +206,7 @@ class CoreConfig(Config): @property def path_extern_addons_local(self): """Return path for customs addons.""" - return str(PurePath(self.path_extern_hassio, ADDONS_LOCAL)) + return PurePath(self.path_extern_hassio, ADDONS_LOCAL) @property def path_addons_data(self): @@ -215,7 +216,12 @@ class CoreConfig(Config): @property def path_extern_addons_data(self): """Return root addon data folder extern for docker.""" - return str(PurePath(self.path_extern_hassio, ADDONS_DATA)) + return PurePath(self.path_extern_hassio, ADDONS_DATA) + + @property + def path_addons_build(self): + """Return root addon build folder.""" + return Path(HASSIO_SHARE, ADDONS_BUILD) @property def path_backup(self): @@ -225,7 +231,7 @@ class CoreConfig(Config): @property def path_extern_backup(self): """Return root backup data folder extern for docker.""" - return str(PurePath(self.path_extern_hassio, BACKUP_DATA)) + return PurePath(self.path_extern_hassio, BACKUP_DATA) @property def addons_repositories(self): diff --git a/hassio/const.py b/hassio/const.py index 0997d2af6..d7df6ffc4 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -67,6 +67,7 @@ ATTR_PASSWORD = 'password' ATTR_TOTP = 'totp' ATTR_INITIALIZE = 'initialize' ATTR_SESSION = 'session' +ATTR_LOCATON = 'location' STARTUP_BEFORE = 'before' STARTUP_AFTER = 'after' diff --git a/hassio/dock/addon.py b/hassio/dock/addon.py index 1c3399bc6..88ace43d7 100644 --- a/hassio/dock/addon.py +++ b/hassio/dock/addon.py @@ -1,15 +1,16 @@ """Init file for HassIO addon docker object.""" import logging +from pathlib import Path +import shutil import docker from . import DockerBase +from .util import dockerfile_template from ..tools import get_version_from_env _LOGGER = logging.getLogger(__name__) -HASS_DOCKER_NAME = 'homeassistant' - class DockerAddon(DockerBase): """Docker hassio wrapper for HomeAssistant.""" @@ -30,31 +31,31 @@ class DockerAddon(DockerBase): def volumes(self): """Generate volumes for mappings.""" volumes = { - self.addons_data.path_extern_data(self.addon): { + str(self.addons_data.path_extern_data(self.addon)): { 'bind': '/data', 'mode': 'rw' }} if self.addons_data.map_config(self.addon): volumes.update({ - self.config.path_extern_config: { + str(self.config.path_extern_config): { 'bind': '/config', 'mode': 'rw' }}) if self.addons_data.map_ssl(self.addon): volumes.update({ - self.config.path_extern_ssl: { + str(self.config.path_extern_ssl): { 'bind': '/ssl', 'mode': 'rw' }}) if self.addons_data.map_addons(self.addon): volumes.update({ - self.config.path_extern_addons_local: { + str(self.config.path_extern_addons_local): { 'bind': '/addons', 'mode': 'rw' }}) if self.addons_data.map_backup(self.addon): volumes.update({ - self.config.path_extern_backup: { + str(self.config.path_extern_backup): { 'bind': '/backup', 'mode': 'rw' }}) @@ -102,7 +103,69 @@ class DockerAddon(DockerBase): self.container = self.dock.containers.get(self.docker_name) self.version = get_version_from_env( self.container.attrs['Config']['Env']) - _LOGGER.info("Attach to image %s with version %s", - self.image, self.version) + + _LOGGER.info( + "Attach to image %s with version %s", self.image, self.version) except (docker.errors.DockerException, KeyError): pass + + def _install(self, tag): + """Pull docker image or build it. + + Need run inside executor. + """ + if self.addons_data.need_build(self.addon): + return self._build(tag) + + return super()._install(tag) + + async def build(self, tag): + """Build a docker container.""" + if self._lock.locked(): + _LOGGER.error("Can't excute build while a task is in progress") + return False + + async with self._lock: + return await self.loop.run_in_executor(None, self._build, tag) + + def _build(self, tag): + """Build a docker container. + + Need run inside executor. + """ + build_dir = Path(self.config.path_addons_build, self.addon) + try: + # prepare temporary addon build folder + try: + source = self.addons_data.path_addon_location(self.addon) + shutil.copytree(str(source), str(build_dir)) + except shutil.Error as err: + _LOGGER.error("Can't copy %s to temporary build folder -> %s", + source, build_dir) + return False + + # prepare Dockerfile + try: + dockerfile_template( + Path(build_dir, 'Dockerfile'), self.addons_data.arch, tag) + except OSError as err: + _LOGGER.error("Can't prepare dockerfile -> %s", err) + + # run docker build + try: + build_tag = "{}:{}".format(self.image, tag) + + _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) + + _LOGGER.info("Build %s done", build_tag) + image.tag(self.image, tag='latest') + except (docker.errors.DockerException, TypeError) as err: + _LOGGER.error("Can't build %s -> %s", build_tag, err) + return False + + return True + + finally: + shutil.rmtree(str(build_dir), ignore_errors=True) diff --git a/hassio/dock/homeassistant.py b/hassio/dock/homeassistant.py index 0f314bb2d..f33ea5124 100644 --- a/hassio/dock/homeassistant.py +++ b/hassio/dock/homeassistant.py @@ -45,9 +45,9 @@ class DockerHomeAssistant(DockerBase): 'HASSIO': self.config.api_endpoint, }, volumes={ - self.config.path_extern_config: + str(self.config.path_extern_config): {'bind': '/config', 'mode': 'rw'}, - self.config.path_extern_ssl: + str(self.config.path_extern_ssl): {'bind': '/ssl', 'mode': 'rw'}, }) diff --git a/hassio/dock/util.py b/hassio/dock/util.py new file mode 100644 index 000000000..b851e9b6f --- /dev/null +++ b/hassio/dock/util.py @@ -0,0 +1,32 @@ +"""HassIO docker utilitys.""" +import re + +from ..const import ARCH_AARCH64, ARCH_ARMHF, ARCH_I386, ARCH_AMD64 + + +RESIN_BASE_IMAGE = { + ARCH_ARMHF: "resin/armhf-alpine:3.5", + ARCH_AARCH64: "resin/aarch64-alpine:3.5", + ARCH_I386: "resin/i386-alpine:3.5", + ARCH_AMD64: "resin/amd64-alpine:3.5", +} + +TMPL_VERSION = re.compile(r"%%VERSION%%") +TMPL_IMAGE = re.compile(r"%%BASE_IMAGE%%") + + +def dockerfile_template(dockerfile, arch, version): + """Prepare a Hass.IO dockerfile.""" + buff = [] + resin_image = RESIN_BASE_IMAGE[arch] + + # read docker + with dockerfile.open('r') as dock_input: + for line in dock_input: + line = TMPL_VERSION.sub(version, line) + line = TMPL_IMAGE.sub(resin_image, line) + buff.append(line) + + # write docker + with dockerfile.open('w') as dock_output: + dock_output.writelines(buff)