diff --git a/nsfarm/lxd/container.py b/nsfarm/lxd/container.py index 2ebf4dc8075f0309c30cff226d53b506ea1a3318..e067d88ab0602b8977550fce632ebc3b045733a6 100644 --- a/nsfarm/lxd/container.py +++ b/nsfarm/lxd/container.py @@ -1,21 +1,13 @@ -"""Containers and images management. +"""Containers management. """ import os -import time import typing -import itertools -import pathlib -import hashlib import logging import pexpect -import pylxd from .. import cli from .connection import LXDConnection -from .device import NetInterface - -IMAGE_INIT_PATH = "/nsfarm-init.sh" # Where we deploy initialization script for image - -IMGS_DIR = pathlib.Path(__file__).parents[2] / "imgs" +from .image import Image +from .device import Device logger = logging.getLogger(__package__) @@ -25,163 +17,50 @@ class Container: """ # TODO log syslog somehow - def __init__(self, lxd_connection: LXDConnection, img_name: str, devices: typing.List[NetInterface] = (), + def __init__(self, lxd_connection: LXDConnection, image: typing.Union[str, Image], devices: typing.List[Device] = (), internet: bool = True): + self.image_name = image.name if isinstance(image, Image) else image self._lxd = lxd_connection - self._name = img_name self._internet = internet self._devices = tuple(devices) - self._dir_path = IMGS_DIR / img_name - self._file_path = self._dir_path.with_suffix(self._dir_path.suffix + ".sh") - self._logger = logging.getLogger(f"{__package__}[{img_name}]") - # Verify existence of image definition - if not self._file_path.is_file(): - raise Exception(f"There seems to be no file describing image: {self._file_path}") - if not self._dir_path.is_dir(): - self._dir_path = None - # Get parent - with open(self._file_path) as file: - # This reads second line of file while initial hash removed - parent = next(itertools.islice(file, 1, 2))[1:].strip() - self._parent = None - if parent.startswith("nsfarm:"): - self._parent = Container(lxd_connection, parent[7:]) - elif parent.startswith("images:"): - self._parent = self._lxd.images.images.get_by_alias(parent[7:]) - else: - raise Exception(f"The file has parent from unknown source: {parent}: {self.fpath}") - # Calculate identity hash and generate image name - self._hash = self.__identity_hash() - self._image_alias = f"nsfarm/{self._name}/{self._hash}" - # Some empty handles - self._lxd_image = None - self._lxd_container = None - def __identity_hash(self): - md5sum = hashlib.md5() - # Parent - if isinstance(self._parent, Container): - md5sum.update(self._parent.hash.encode()) - else: - md5sum.update(self._parent.fingerprint.encode()) - # File defining container - with open(self._file_path, "rb") as file: - md5sum.update(file.read()) - # Additional nodes from directory - if self._dir_path: - nodes = [path for path in self._dir_path.iterdir() if path.is_dir()] - while nodes: - node = nodes.pop() - path = self._dir_path / node - md5sum.update(str(node).encode()) - if path.is_dir(): - nodes += [path for path in node.iterdir() if path.is_dir()] - elif path.is_file(): - # For plain file include content - with open(path, "rb") as file: - md5sum.update(file.read()) - elif path.is_link(): - # For link include its target as well - md5sum.update(str(path.resolve()).encode()) - return md5sum.hexdigest() + self._logger = logging.getLogger(f"{__package__}[{self.image_name}]") + self.image = image if isinstance(image, Image) else Image(lxd_connection, image) - def prepare_image(self): - """Prepare image for this container if not already prepared. - - You can call this explicitly if you want to prepapre image but this method is automatically called when you - atempt to prepare container so you don't have to do it. - """ - if self._lxd_image: - return - if self._lxd.local.images.exists(self._image_alias, alias=True): - self._lxd_image = self._lxd.local.images.get_by_alias(self._image_alias) - return - # We do not have appropriate image so prepare it - logger.warning("Bootstrapping image: %s", self._image_alias) - image_source = { - 'type': 'image', - } - if isinstance(self._parent, Container): - # We have NSFarm image to base on - self._parent.prepare_image() - image_source["alias"] = self._parent._image_alias - else: - # We have to pull it from images - image_source["mode"] = "pull" - image_source["server"] = self._lxd.IMAGES_SOURCE - image_source["alias"] = self._parent.fingerprint - container_name = f"nsfarm-bootstrap-{self._name}-{self._hash}" - try: - container = self._lxd.local.containers.create({ - 'name': container_name, - 'profiles': ['nsfarm-root', 'nsfarm-internet'], - 'source': image_source - }, wait=True) - except pylxd.exceptions.LXDAPIException as elxd: - # TODO found other way to match reason - if not str(elxd).endswith("This container already exists"): - raise - logger.warning("Other instance is already bootsrapping image probably. " - "Waiting for following container to go away: %s", container_name) - while self._lxd.local.containers.exists(container_name): - time.sleep(1) - self.prepare_image() # possibly get created image or try again - return - try: - # TODO log boostrap process - # Copy script and files to container - with open(self._file_path) as file: - container.files.put(IMAGE_INIT_PATH, file.read(), mode=700) - if self._dir_path: - container.files.recursive_put(self._dir_path, "/") - # Run script to bootstrap image - container.start(wait=True) - try: - res = container.execute([IMAGE_INIT_PATH]) - if res.exit_code != 0: - # TODO more appropriate exception and possibly use stderr and stdout - raise Exception(f"Image initialization failed: {res}") - container.files.delete(IMAGE_INIT_PATH) # Remove init script - finally: - container.stop(wait=True) - # Create and configure image - self._lxd_image = container.publish(wait=True) - self._lxd_image.add_alias(self._image_alias, f"NSFarm image: {self._name}") - finally: - container.delete() + self.lxd_container = None def prepare(self): """Create and start container for this object. """ - if self._lxd_container is not None: + if self.lxd_container is not None: return - self.prepare_image() + self.image.prepare() + # Collect profiles to be assigned to container - profiles = ['nsfarm-root', ] + profiles = [self._lxd.ROOT_PROFILE, ] if self._internet: - profiles.append('nsfarm-internet') - # Collect devices to attach + profiles.append(self._lxd.INTERNET_PROFILE) + # Collect devices to attach to container devices = dict() for device in self._devices: devices.update(device.acquire(self)) + # Create and start container - self._lxd_container = self._lxd.local.containers.create({ + self.lxd_container = self._lxd.local.containers.create({ 'name': self._container_name(), 'ephemeral': True, 'profiles': profiles, 'devices': devices, 'source': { 'type': 'image', - 'alias': self._image_alias, + 'alias': self.image.alias(), }, }, wait=True) - self._lxd_container.start(wait=True) - self._logger.debug("Container prepared: %s", self._lxd_container.name) - # TODO we could somehow just let it create it and return from this method and wait later on when we realy need - # container. + self.lxd_container.start(wait=True) + logger.debug("Container prepared: %s", self.lxd_container.name) def _container_name(self, prefix="nsfarm"): - name = f"{prefix}-{self._name}-{os.getpid()}" + name = f"{prefix}-{self.image_name}-{os.getpid()}" if self._lxd.local.containers.exists(name): i = 1 while self._lxd.local.containers.exists(f"{name}-{i}"): @@ -194,25 +73,25 @@ class Container: This is intended to be called as a cleanup handler. Please call it when you are removing this container. """ - if self._lxd_container is None: + if self.lxd_container is None: return # No cleanup is required - self._logger.debug("Removing container: %s", self._lxd_container.name) + logger.debug("Removing container: %s", self.lxd_container.name) # First freeze and remove devices - self._lxd_container.freeze(wait=True) - self._lxd_container.devices = dict() - self._lxd_container.save() + self.lxd_container.freeze(wait=True) + self.lxd_container.devices = dict() + self.lxd_container.save() for device in self._devices: device.release(self) # Now stop container (Note: container is ephemeral so it is removed automatically after stop) - self._lxd_container.stop() - self._lxd_container = None + self.lxd_container.stop() + self.lxd_container = None def pexpect(self, command=("/bin/sh",)): """Returns pexpect handle for command running in container. """ - assert self._lxd_container is not None + assert self.lxd_container is not None self._logger.debug("Running command: %s", command) - pexp = pexpect.spawn('lxc', ["exec", self._lxd_container.name] + list(command)) + pexp = pexpect.spawn('lxc', ["exec", self.lxd_container.name] + list(command)) pexp.logfile_read = cli.PexpectLogging(logging.getLogger(self._logger.name + str(command))) return pexp @@ -222,43 +101,23 @@ class Container: def __exit__(self, etype, value, traceback): self.cleanup() - - @property - def image_name(self): - """Name of NSFarm image this container is initialized for. - """ - return self._name @property - def name(self): + def name(self) -> typing.Union[str, None]: """Name of container if prepared, otherwise None. """ - if self._lxd_container is None: + if self.lxd_container is None: return None - return self._lxd_container.name + return self.lxd_container.name @property - def internet(self): + def internet(self) -> bool: """If host internet connection should be accessible in this instance. """ return self._internet @property - def devices(self): + def devices(self) -> typing.Tuple[Device]: """List of passed devices from host. """ - return self._devices - - @property - def hash(self): - """Identifying Hash of container. - - This is unique identifier generated from container sources and used to check if image can be reused or not. - """ - return self._hash - - @property - def image_alias(self): - """Alias of image for this container. - """ - return self._image_alias + return tuple(self._devices) diff --git a/nsfarm/lxd/image.py b/nsfarm/lxd/image.py new file mode 100644 index 0000000000000000000000000000000000000000..c87866eeaf3bc7267c9b02b17b471b550de598b3 --- /dev/null +++ b/nsfarm/lxd/image.py @@ -0,0 +1,165 @@ +"""Images management. +""" +import time +import itertools +import functools +import pathlib +import hashlib +import logging +import pylxd +from .connection import LXDConnection + +logger = logging.getLogger(__package__) + + +class Image: + """Generic Image handle. + """ + IMAGE_INIT_PATH = "/nsfarm-init.sh" # Where we deploy initialization script for image + IMGS_DIR = pathlib.Path(__file__).parents[2] / "imgs" + + def __init__(self, lxd_connection: LXDConnection, img_name: str): + self.name = img_name + self._lxd = lxd_connection + self._dir_path = self.IMGS_DIR / img_name + self._file_path = self._dir_path.with_suffix(self._dir_path.suffix + ".sh") + + self.lxd_image = None + + # Verify existence of image definition + if not self._file_path.is_file(): + # TODO better exception object + raise Exception(f"There seems to be no file describing image: {self._file_path}") + if not self._dir_path.is_dir(): + self._dir_path = None + + # Get parent + with open(self._file_path) as file: + # This reads second line of file while initial hash removed + parent = next(itertools.islice(file, 1, 2))[1:].strip() + self._parent = None + if parent.startswith("nsfarm:"): + self._parent = Image(lxd_connection, parent[7:]) + elif parent.startswith("images:"): + self._parent = self._lxd.images.images.get_by_alias(parent[7:]) + else: + # TODO better exception object + raise Exception(f"The file has parent from unknown source: {parent}: {self._file_path}") + + @functools.lru_cache(maxsize=1) + def hash(self) -> str: + """Identifying Hash for latest image. + + This is unique identifier generated from image sources and used to check if image can be reused or not. + """ + md5sum = hashlib.md5() + # Parent + if isinstance(self._parent, Image): + md5sum.update(self._parent.hash().encode()) + else: + md5sum.update(self._parent.fingerprint.encode()) + # File defining container + with open(self._file_path, "rb") as file: + md5sum.update(file.read()) + # Additional nodes from directory + if self._dir_path: + nodes = [path for path in self._dir_path.iterdir() if path.is_dir()] + while nodes: + node = nodes.pop() + path = self._dir_path / node + md5sum.update(str(node).encode()) + if path.is_dir(): + nodes += [path for path in node.iterdir() if path.is_dir()] + elif path.is_file(): + # For plain file include content + with open(path, "rb") as file: + md5sum.update(file.read()) + elif path.is_link(): + # For link include its target as well + md5sum.update(str(path.resolve()).encode()) + return md5sum.hexdigest() + + def alias(self, img_hash: str = None) -> str: + """Alias for latest image. This is name used to identify image in LXD. + + img_hash: specific hash (not latest one) to generate alias for. + """ + if img_hash is None: + img_hash = self.hash() + return f"nsfarm/{self.name}/{img_hash}" + + def is_prepared(self, img_hash: str = None) -> bool: + """Check if image we need is prepared. + + img_hash: specific hash (not latest one) to be checked. + """ + return self._lxd.local.images.exists(self.alias(img_hash), alias=True) + + def prepare(self): + """Prepare image. It creates it if necessary and populates lxd_image attribute. + """ + if self.lxd_image is not None: + return + if self.is_prepared(self.hash()): + self.lxd_image = self._lxd.local.images.get_by_alias(self.alias()) + return + + logger.warning("Bootstrapping image: %s", self.alias()) + + image_source = { + 'type': 'image', + } + if isinstance(self._parent, Image): + # We have NSFarm image to base on + self._parent.prepare() + image_source["alias"] = self._parent.alias() + else: + # We have to pull it from images + image_source["mode"] = "pull" + image_source["server"] = self._lxd.IMAGES_SOURCE + image_source["alias"] = self._parent.fingerprint + + container_name = f"nsfarm-bootstrap-{self.name}-{self.hash()}" + + try: + container = self._lxd.local.containers.create({ + 'name': container_name, + 'profiles': ['nsfarm-root', 'nsfarm-internet'], + 'source': image_source + }, wait=True) + except pylxd.exceptions.LXDAPIException as elxd: + if not str(elxd).endswith("already exists"): + raise + logger.warning("Other instance is already bootsrapping image probably. " + "Waiting for following container to go away: %s", container_name) + while self._lxd.local.containers.exists(container_name): + time.sleep(1) + self.prepare() # possibly get created image or try again + return + + try: + self._deploy_files(container) + self._run_bootstrap(container) + # Create and configure image + self.lxd_image = container.publish(wait=True) + self.lxd_image.add_alias(self.alias(), f"NSFarm image: {self.name}") + finally: + container.delete() + + def _deploy_files(self, container): + with open(self._file_path) as file: + container.files.put(self.IMAGE_INIT_PATH, file.read(), mode=700) + if self._dir_path: + container.files.recursive_put(self._dir_path, "/") + + def _run_bootstrap(self, container): + # TODO log boostrap process + container.start(wait=True) + try: + res = container.execute([self.IMAGE_INIT_PATH]) + if res.exit_code != 0: + # TODO more appropriate exception and possibly use stderr and stdout + raise Exception(f"Image initialization failed: {res}") + container.files.delete(self.IMAGE_INIT_PATH) # Remove init script + finally: + container.stop(wait=True) diff --git a/nsfarm/lxd/utils.py b/nsfarm/lxd/utils.py index 8f770e7c321f2d482475b21a413b5d469a1b8d9e..0c4b750f9380cee48f5352b4189e32172cf3cb73 100644 --- a/nsfarm/lxd/utils.py +++ b/nsfarm/lxd/utils.py @@ -5,7 +5,8 @@ import logging from datetime import datetime import dateutil.parser from .connection import LXDConnection -from .container import Container, IMGS_DIR +from .container import Container +from .image import Image logger = logging.getLogger(__package__) @@ -42,7 +43,7 @@ def all_images(): This collects all *.sh files in imgs directory in root of nsfarm project. """ - return (imgf[:-3] for imgf in os.listdir(IMGS_DIR) if imgf.endswith(".sh")) + return (imgf[:-3] for imgf in os.listdir(Image.IMGS_DIR) if imgf.endswith(".sh")) def bootstrap(imgs=None): @@ -56,5 +57,5 @@ def bootstrap(imgs=None): connection = LXDConnection() for img in all_images() if imgs is None else imgs: logger.info("Trying to bootstrap: %s", img) - Container(connection, img).prepare_image() + Image(connection, img).prepare() return success