Skip to content
Snippets Groups Projects
Verified Commit 872e0f38 authored by Karel Koci's avatar Karel Koci :metal:
Browse files

nsfarm/lxd: separate image creation from container

This now separates image and container representation to two separate
classes. For users it is almost the same but it makes structure of lxd
module much better and improves overal readability of Container class.
parent 378a6466
Branches
1 merge request!2Initial development
"""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)
"""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)
......@@ -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
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment