def _setup_dnf_builder(self): """ Set up the DNF builder. """ dnf_conf = {} if self.config.get("dnf"): dnf_conf = self.config["dnf"] dnf_runtime = None if (self.runtime_config.get("build") and self.runtime_config["build"].get("dnf")): dnf_runtime = self.runtime_config["build"].get("dnf") self.dnf_builder = DNFBuilder(self.image_path, self.log_path, self.architecture, dnf_conf, dnf_runtime)
class Builder(object): """ Build a ``systemd-nspawn``able image. :raises: sponson.image.build.BuilderError """ def __init__(self, config, runtime_config=None): """ Initialisation with the configuration data. Optional runtime configuration can be loaded, for local proxies, optimisations, name overrides, etc. :param config: configuration data :type config: dict :param runtime_config: local runtime configuration :type runtime_config: dict """ self.all_config = config self.config = config["image"] if runtime_config: self.runtime_config = runtime_config.get("image", {}) else: self.runtime_config = {} # These are required options and will not be missing self.name = self.config["name"] self.version = str(self.config["version"]) if self.version == "now": self.version = datetime.now().strftime("%Y%m%dT%H%M%S") self.config["version"] = self.version self.build = self.config["build"] # These are not required self.architecture = self.config.get("architecture") if not self.architecture: machine = platform.machine() # We can't assume every distro reports i686. if machine in ("i386", "i486", "i586", "i686"): self.architecture = "x86" else: self.architecture = "x86-64" self.all_config["image"]["architecture"] = self.architecture self.srcimage = self.config.get("srcimage") self.systemctl = self.config.get("systemctl") self.image_path = None self.config_path = None self.is_latest = False self.log_path = os.path.join(BASE_LOG_DIR, self.name, self.version) # Reusable builders that are setup with user configurations self.systemd_builder = SystemdImage(self.runtime_config) def start(self): """ Start building the image. """ logger.info("Starting") logger.info("Sanity check") self._sanity_check() logger.info("Mounting source image") self._srcimage() logger.info("Build") self._build_steps() logger.info("Clean up") self._build_clean_up() logger.info("Setup systemd unit files") self._systemctl() logger.info("Linking latest image") self._link_latest_image() logger.info("Persisting configuration") self.persist_config() logger.info("Finished") def _sanity_check(self): """ Check if the image path already exists, and if so raise an error. :raises: sponson.image.build.BuilderError """ self.image_path = os.path.join(BASE_IMAGE_DIR, self.name, self.version) self.config_path = os.path.join(BASE_CONF_DIR, self.name, "{}.yaml".format(self.version)) if os.path.exists(self.image_path): raise BuilderError("Image path already exists") try: os.makedirs(self.image_path) except PermissionError: raise BuilderError("Insufficient permissions to make image") if not os.path.exists(os.path.dirname(self.config_path)): try: os.makedirs(os.path.dirname(self.config_path)) except PermissionError: raise BuilderError("Insufficient permissions to save config") if self.srcimage: srcimage = check_if_versioned(self.srcimage) if not srcimage or not os.path.exists(srcimage): raise BuilderError("Cannot find source image") def _image_dest_path(self, dest_path): """ Create and check a path that should be relative to the image directory. :param dest_path: path in the image directory :type dest_path: str :return: full path rooted in the image directory :rtype: str """ # os.path.join throws away path arguments # if one argument is an absolute path dest = os.path.join(self.image_path, dest_path.lstrip("/")) if not dest.startswith(BASE_IMAGE_DIR): raise BuilderError("Operating outside image directory: " "{}".format(dest)) return dest def _check_if_version(self, image): """ Checks if supplied image is versioned. :param image: image with possible version to check :type image: str :return: sanitised/checked image string :rtype: str :raises: BuilderError """ version = check_if_versioned(image) if version: return version else: raise BuilderError("Cannot determine version of the image") def _setup_dnf_builder(self): """ Set up the DNF builder. """ dnf_conf = {} if self.config.get("dnf"): dnf_conf = self.config["dnf"] dnf_runtime = None if (self.runtime_config.get("build") and self.runtime_config["build"].get("dnf")): dnf_runtime = self.runtime_config["build"].get("dnf") self.dnf_builder = DNFBuilder(self.image_path, self.log_path, self.architecture, dnf_conf, dnf_runtime) def _build_steps(self): """ Iterate through the build steps on the image. """ # Setup the builders first self._setup_dnf_builder() for count, build in enumerate(self.build): try: # This should only ever be one key in the dictionary for action, args in build.items(): func_name = "_build_step_{}".format(action) if hasattr(self, func_name): f = getattr(self, func_name) logger.info("Start {}{}".format(count, func_name)) f(args) logger.info("Finish {}{}".format(count, func_name)) except KeyError: continue def _build_step_dnf(self, args): """ DNF build step :param args: dictionary of options for build step :type args: dict """ if "pkg" not in args and "pkglist" not in args: raise BuilderError("Missing required DNF build step arguments") if "pkg" in args: pkglist = [args["pkg"]] else: pkglist = args["pkglist"] try: self.dnf_builder.install(pkglist) except DNFError: logger.exception("DNF build step error") raise BuilderError("DNF build step error") def _build_step_copy(self, args): """ Copy build step. This is recursive. The ``args["dest"]`` setting is always relative to the image directory, such that ``/etc/foo/bar.conf`` is really ``/var/lib/sponson/images/{name}/{version}/etc/foo/bar.conf``. :param args: dictionary of options for build step :type args: dict """ if "src" not in args or "dest" not in args: raise BuilderError("Missing required copy build step arguments") if "srcimage" in args: # Check if the srcimage we've been given # is the name or the actual version image_vers = self._check_if_version(args["srcimage"]) mount_src = args["src"].lstrip("/") source = os.path.join(image_vers, mount_src) else: source = os.path.abspath(args["src"]) dest = self._image_dest_path(args["dest"]) safe_copy(source, dest, args.get("owner"), args.get("group"), args.get("chmod")) def _build_step_delete(self, args): """ Delete build step :param args: dictionary of options for build step :type args: dict """ if "targets" not in args: raise BuilderError("Missing required delete build step arguments") for target in args["target"]: img_target = self._image_dest_path(target) if not os.path.exists(img_target): continue if os.path.isdir(img_target): shutil.rmtree(img_target, args.get("force", False)) else: os.unlink(img_target) def _build_step_run(self, args): """ Run build step :param args: dictionary of options for build step :type args: dict """ if "command" not in args: raise BuilderError("Missing required run build step arguments") command = args["command"].format(image=self.image_path).split() container = args.get("container", True) fail = args.get("fail", True) user = args.get("user") process = BuildRunProcess(self.image_path, command, user, fail, container) if process: try: process.start() process.join() except (subprocess.CalledProcessError, multiprocessing.ProcessError): logger.exception("Process error") if fail: raise BuilderError("Run build step failed") else: if fail and process.exitcode != 0: raise BuilderError("Run build step failed") else: logger.error("Cannot create run build step") def _build_clean_up(self): """ Clean up after builders. """ if self.dnf_builder: self.dnf_builder.finish() def _srcimage(self): """ Mount source image(s) into the image. """ if self.srcimage: self.systemd_builder.mount_srcimage(self.name, self.version, self.srcimage) def _systemctl(self): """ Setup ``systemd`` unit files in the image. """ if self.systemctl: for action in ("enable", "disable", "mask"): for unit in self.systemctl.get(action, []): SystemdImage.machine_systemctl(self.image_path, action, unit) # Clear {image}/etc/machine-id, # else subsequent containers get that machine ID. machine_id = os.path.join(self.image_path, "etc", "machine-id") if os.path.exists(machine_id): os.remove(machine_id) def _link_latest_image(self): """ Link the latest version of the image to ``/latest`` """ image_name_dir = os.path.join(BASE_IMAGE_DIR, self.name) latest_image = os.path.join(image_name_dir, "latest") if not os.path.exists(latest_image): # Assume this image is the latest if the latest symlink is missing os.symlink(self.image_path, latest_image) self.is_latest = True return if not os.path.islink(latest_image): # This isn't technically fatal, but should raise a warning logger.warning( "Latest image is not a symlink, not trying to link") return self.is_latest = link_latest_image(image_name_dir) def persist_config(self): """ Write out the configuration data to a YAML file. """ configfile.write_config_file(self.all_config, self.config_path) img_sponson_dir = os.path.join(self.image_path, "etc", "sponson") os.makedirs(img_sponson_dir, exist_ok=True) img_sponson_conf = os.path.join( img_sponson_dir, "{}.{}.yaml".format(self.name, self.version)) configfile.write_config_file(self.all_config, img_sponson_conf) if self.is_latest: latest_config = os.path.join(BASE_CONF_DIR, self.name, "latest.yaml") if os.path.exists(latest_config): os.remove(latest_config) os.symlink(self.config_path, latest_config)