#40 Add image-build support for docker and qcow
Closed 8 years ago by lkocman. Opened 8 years ago by lkocman.
https://pagure.io/forks/lkocman/pungi.git dockadockadocka  into  master

add image-build support
Lubos Kocman • 8 years ago  
bin/pungi-koji
file modified
+6 -1
@@ -207,6 +207,8 @@

      extrafiles_phase = pungi.phases.ExtraFilesPhase(compose, pkgset_phase)

      createiso_phase = pungi.phases.CreateisoPhase(compose)

      liveimages_phase = pungi.phases.LiveImagesPhase(compose)

+     docker_phase = pungi.phases.DockerPhase(compose)

+     qcow2_phase = pungi.phases.QCOW2Phase(compose)

      test_phase = pungi.phases.TestPhase(compose)

  

      # check if all config options are set
@@ -275,9 +277,12 @@

      # CREATEISO and LIVEIMAGES phases

      createiso_phase.start()

      liveimages_phase.start()

- 

+     docker_phase.start()

+     qcow2_phase.start()

      createiso_phase.stop()

      liveimages_phase.stop()

+     docker_phase.stop()

+     qcow2_phase.stop()

  

      # merge checksum files

      for variant in compose.get_variants(types=["variant", "layered-product"]):

pungi/paths.py
file modified
+83
@@ -25,6 +25,21 @@

  

  from pungi.util import makedirs

  

+ def translate_path(compose, path):

+     """

+     @param compose - required for access to config

+     @param path

+     """

+     normpath = os.path.normpath(path)

+     mapping = compose.conf.get("translate_paths", [])

+ 

+     for prefix, newvalue in mapping:

+         prefix = os.path.normpath(prefix)

+         if normpath.startswith(prefix):

+             # don't call os.path.normpath on result since that would break http:// -> http:/ and so on

+             return normpath.replace(prefix, newvalue, 1) # replace only 1 occurance

+ 

+     return normpath

  

  class Paths(object):

      def __init__(self, compose):
@@ -292,6 +307,34 @@

          path = os.path.join(path, file_name)

          return path

  

+     def image_build_dir(self, arch, variant, create_dir=True):

+         """

+         @param arch

+         @param variant

+         @param create_dir=True

+ 

+         Examples:

+             work/x86_64/Server/image-build

+         """

+         path = os.path.join(self.topdir(arch, create_dir=create_dir), variant.uid, "image-build")

+         if create_dir:

+             makedirs(path)

+         return path

+ 

+     def image_build_conf(self, arch, variant, image_name, image_type, create_dir=True):

+         """

+         @param arch

+         @param variant

+         @param image-name

+         @param image-type (e.g docker)

+         @param create_dir=True

+ 

+         Examples:

+             work/x86_64/Server/image-build/docker_rhel-server-docker.cfg

+         """

+         path = os.path.join(self.image_build_dir(arch, variant), "%s_%s.cfg" % (image_type, image_name))

+         return path

+ 

  

  class ComposePaths(object):

      def __init__(self, compose):
@@ -493,6 +536,46 @@

          result = os.path.join(path, file_name)

          return result

  

+     def image_dir(self, arch, variant, image_type, symlink_to=None, create_dir=True, relative=False):

+         """

+         Examples:

+             compose/Server/x86_64/images

+             None

+         @param arch

+         @param variant

+         @param image_type - all images goes to images so no effect at all atm

+         @param symlink_to=None

+         @param create_dir=True

+         @param relative=False

+         """

+         # skip optional, addons and src architecture

+         if variant.type != "variant":

+             return None

+         if arch == "src":

+             return None

+ 

+         path = os.path.join(self.topdir(arch, variant, create_dir=create_dir, relative=relative), "images")

+         if symlink_to:

+             topdir = self.compose.topdir.rstrip("/") + "/"

+             relative_dir = path[len(topdir):]

+             target_dir = os.path.join(symlink_to, self.compose.compose_id, relative_dir)

+             if create_dir and not relative:

+                 makedirs(target_dir)

+             try:

+                 os.symlink(target_dir, path)

+             except OSError as ex:

+                 if ex.errno != errno.EEXIST:

+                     raise

+                 msg = "Symlink pointing to '%s' expected: %s" % (target_dir, path)

+                 if not os.path.islink(path):

+                     raise RuntimeError(msg)

+                 if os.path.abspath(os.readlink(path)) != target_dir:

+                     raise RuntimeError(msg)

+         else:

+             if create_dir and not relative:

+                 makedirs(path)

+         return path

+ 

      def jigdo_dir(self, arch, variant, create_dir=True, relative=False):

          """

          Examples:

pungi/phases/__init__.py
file modified
+2
@@ -25,4 +25,6 @@

  from extra_files import ExtraFilesPhase  # noqa

  from createiso import CreateisoPhase  # noqa

  from live_images import LiveImagesPhase  # noqa

+ from docker import DockerPhase  # noqa

+ from qcow2 import QCOW2Phase  # noqa

  from test import TestPhase  # noqa

pungi/phases/docker.py
file added
+8
@@ -0,0 +1,8 @@

+ # -*- coding: utf-8 -*-

+ 

+ 

+ from image_build import ImageBuildPhase

+ 

+ class DockerPhase(ImageBuildPhase):

+     name = "docker"

+     allowed_suffixes = ["tar.gz", "tar.xf"]

pungi/phases/image_build.py
file added
+170
@@ -0,0 +1,170 @@

+ # -*- coding: utf-8 -*-

+ 

+ 

+ import os

+ import re

+ import shutil

+ import time

+ import pipes

+ 

+ from pungi.util import get_arch_variant_data

+ from pungi.phases.base import PhaseBase

+ from pungi.paths import translate_path

+ from pungi.wrappers.kojiwrapper import KojiWrapper

+ from pungi.wrappers.iso import IsoWrapper

+ from kobo.shortcuts import run, read_checksum_file

+ from kobo.threads import ThreadPool, WorkerThread

+ from productmd.images import Image

+ 

+ def http_from_nfs(repo):

+     return re.sub(r"^/mnt/redhat/", "http://download.lab.bos.redhat.com/", repo)

+ 

+ class ImageBuildPhase(PhaseBase):

+     """Generic ImageBuild class which is supposed to be inherited"""

+     name = "override_me" # docker, qcow2 ...

+     config_section = "image_build"

+     allowed_suffixes = ["override_me"] # tar.xz, tar.gz ... necessary evil to match brew output filenames

+ 

+     def __init__(self, compose):

+         PhaseBase.__init__(self, compose)

+         self.pool = ThreadPool(logger=self.compose._logger)

+ 

+     def skip(self):

+         if PhaseBase.skip(self):

+             return True

+         config = self.compose.conf.get(self.config_section)

+         if not config:

+             return True

+         else:

+             #[('^Server$', {'x86_64': [{'name': 'rhel-server-docker', 'format': 'docker', ...}]}),]

+             for variant_pattern, data in config:

+                 for arch, images in data.iteritems():

+                     for image in images:

+                         if image['format'] == self.name: # docker, qcow2

+                             return False # don't skip phase if coressponding image was found

+             return True

+ 

+     def run(self):

+         for arch in self.compose.get_arches(): # src will be skipped

+             for variant in self.compose.get_variants(arch=arch):

+                 image_build_data = get_arch_variant_data(self.compose.conf, self.config_section, arch, variant)

+                 for image_conf in image_build_data:

+                     image_conf["arches"] = arch # passed to get_image_build_cmd as dict

+                     image_conf["variant"] = variant # ^

+                     image_conf["install_tree"] = translate_path(self.compose, self.compose.paths.compose.os_tree(arch, variant)) # ^

+                     if image_conf.has_key("repos") and not isinstance(image_conf["repos"], str):

+                         image_conf["repos"] = ",".join(image_conf["repos"]) # supply repos as str separated by , instead of list

+                     cmd = {

+                         "image_conf": image_conf,

+                         "conf_file": self.compose.paths.work.image_build_conf(image_conf["arches"], image_conf['variant'], image_name=image_conf['name'], image_type=image_conf["format"]),

+                         "image_dir": self.compose.paths.compose.image_dir(arch, variant, image_type=image_conf['format']),

+                         "relative_image_dir": self.compose.paths.compose.image_dir(arch, variant, image_type=image_conf['format'], create_dir=False, relative=True),

+                         "allowed_suffixes": self.allowed_suffixes

+                     }

+                     self.pool.add(CreateImageBuildThread(self.pool))

+                     self.pool.queue_put((self.compose, cmd))

+         self.pool.start()

+ 

+     def stop(self, *args, **kwargs):

+         PhaseBase.stop(self, *args, **kwargs)

+         if self.skip():

+             return

+ 

+ class CreateImageBuildThread(WorkerThread):

+     def fail(self, compose, cmd):

+         compose.log_error("CreateImageBuild failed.")

+ 

+     def process(self, item, num):

+         compose, cmd = item

+         mounts = [compose.topdir]

+         if "mount" in cmd:

+             mounts.append(cmd["mount"])

+         runroot = compose.conf.get("runroot", False)

+         log_file = compose.paths.log.log_file(cmd["image_conf"]["arches"], "imagebuild-%s-%s-%s" % (cmd["image_conf"]["arches"], cmd["image_conf"]["variant"], cmd["image_conf"]["format"]))

+         msg = "Creating %s image (arch: %s, variant: %s)" % (cmd["image_conf"]["format"], cmd["image_conf"]["arches"], cmd["image_conf"]["variant"])

+         self.pool.log_info("[BEGIN] %s" % msg)

+ 

+         koji_wrapper = KojiWrapper(compose.conf["koji_profile"])

+         # paths module doesn't hold compose object, so we have to generate path here

+ 

+         # writes conf file for koji image-build

+         self.pool.log_info("Writing image-build config for %s.%s into %s" % (cmd["image_conf"]["variant"], cmd["image_conf"]["arches"], cmd["conf_file"]))

+         koji_cmd = koji_wrapper.get_image_build_cmd(cmd['image_conf'], conf_file_dest=cmd["conf_file"], wait=True, scratch=False)

+ 

+         # avoid race conditions?

+         # Kerberos authentication failed: Permission denied in replay cache code (-1765328215)

+         time.sleep(num * 3)

+ 

+         output = koji_wrapper.run_create_image_cmd(koji_cmd, log_file=log_file)

+         self.pool.log_debug("build-image outputs: %s" % (output))

+         if output["retcode"] != 0:

+             self.fail(compose, cmd)

+             raise RuntimeError("ImageBuild task failed: %s. See %s for more details." % (output["task_id"], log_file))

+         # copy image to images/

+         image_paths = []

+         for filename in koji_wrapper.get_image_path(output["task_id"]):

+             for suffix in cmd['allowed_suffixes']:

+                 if filename.endswith(suffix):

+                     image_paths.append({'filename' :filename, 'suffix': suffix}) # used to determine format

+         if len(image_paths) != 1:

+             self.pool.log_error("Expected to find exactly one %s file  in output of koji task %s. Got '%s'" % (", ".join(cmd['allowed_suffixes']), output["task_id"], len(image_paths)))

+             self.fail(compose, cmd)

+ 

+         image_path  = image_paths[0]['filename']

+         image_suffix  = image_paths[0]['suffix']

+         # let's not change filename from brew tasks

+         image_dest = os.path.join(cmd["image_dir"], os.path.basename(image_path))

+         self.pool.log_debug("Copying %s -> %s" % (image_path, image_dest))

+         shutil.copy2(image_path, image_dest)

+ 

+         iso = IsoWrapper(logger=compose._logger) # required for checksums only

+         checksum_cmd = ["cd %s" % pipes.quote(os.path.dirname(image_dest))]

+         checksum_cmd.extend(iso.get_checksum_cmds(os.path.basename(image_dest)))

+         checksum_cmd = " && ".join(checksum_cmd)

+ 

+         if runroot:

+             packages = ["coreutils", "genisoimage", "isomd5sum", "jigdo", "strace", "lsof"]

+             runroot_channel = compose.conf.get("runroot_channel", None)

+             runroot_tag = compose.conf["runroot_tag"]

+             koji_cmd = koji_wrapper.get_runroot_cmd(runroot_tag, cmd["image_conf"]["arches"], checksum_cmd, channel=runroot_channel, use_shell=True, task_id=True, packages=packages, mounts=mounts)

+ 

+             # avoid race conditions?

+             # Kerberos authentication failed: Permission denied in replay cache code (-1765328215)

+             time.sleep(num * 3)

+ 

+             output = koji_wrapper.run_runroot_cmd(koji_cmd, log_file=log_file)

+             if output["retcode"] != 0:

+                 self.fail(compose, cmd)

+                 raise RuntimeError("Runroot task failed: %s. See %s for more details." % (output["task_id"], log_file))

+ 

+         else:

+             # run locally

+             try:

+                 run(checksum_cmd, show_cmd=True, logfile=log_file)

+             except:

+                 self.fail(compose, cmd)

+                 raise

+ 

+         # Update image manifest

+         img = Image(compose.im)

+         img.type = cmd["image_conf"]["format"] #  image/manifest format vs productmd/format

+         img.format = image_suffix

+         img.path = os.path.join(cmd["relative_image_dir"], os.path.basename(image_dest))

+         img.mtime = int(os.stat(image_dest).st_mtime)

+         img.size = os.path.getsize(image_dest)

+         img.arch = cmd["image_conf"]["arches"]

+         img.disc_number = 1 # We don't expect multiple disks

+         img.disc_count = 1

+         for checksum_type in ("md5", "sha1", "sha256"):

+             checksum_path = image_dest + ".%sSUM" % checksum_type.upper()

+             checksum_value = None

+             if os.path.isfile(checksum_path):

+                 checksum_value, image_name = read_checksum_file(checksum_path)[0]

+                 if image_name != os.path.basename(img.path):

+                     raise ValueError("Image name doesn't match checksum: %s" % checksum_path)

+             img.add_checksum(compose.paths.compose.topdir(), checksum_type=checksum_type, checksum_value=checksum_value)

+         img.bootable = False

+         # named keywords due portability (old productmd used arch, variant ... while new one uses variant, arch

+         compose.im.add(variant=cmd["image_conf"]["variant"].uid, arch=cmd["image_conf"]["arches"], image=img)

+ 

+         self.pool.log_info("[DONE ] %s" % msg)

pungi/phases/qcow2.py
file added
+8
@@ -0,0 +1,8 @@

+ # -*- coding: utf-8 -*-

+ 

+ 

+ from image_build import ImageBuildPhase

+ 

+ class QCOW2Phase(ImageBuildPhase):

+     name = "qcow2"

+     allowed_suffixes = ["qcow2",]

pungi/wrappers/kojiwrapper.py
file modified
+36 -5
@@ -22,6 +22,7 @@

  import koji

  import rpmUtils.arch

  from kobo.shortcuts import run

+ from ConfigParser import ConfigParser

  

  

  class KojiWrapper(object):
@@ -97,6 +98,35 @@

          }

          return result

  

+     def get_image_build_cmd(self, config_options, conf_file_dest, wait=True, scratch=False):

+         """

+         @param config_options

+         @param conf_file_dest -  a destination in compose workdir for the conf file to be written

+         @param wait=True

+         @param scratch=False

+         """

+         # Usage: brew image-build [options] <name> <version> <target> <install-tree-url> <arch> [<arch>...]

+         sub_command = "image-build"

+         # The minimum set of options

+         min_options = ("name", "version", "target", "install_tree", "arches", "format", "kickstart", "ksurl", "distro")

+         assert set(min_options).issubset(set(config_options.keys())), "image-build requires at least %s got '%s'" % (", ".join(min_options), config_options)

+         cfg_parser = ConfigParser()

+         cfg_parser.add_section(sub_command)

+         for option, value in config_options.iteritems():

+             cfg_parser.set(sub_command, option, value)

+ 

+         fd = open(conf_file_dest, "w")

+         cfg_parser.write(fd)

+         fd.close()

+ 

+         cmd = [self.executable, sub_command, "--config=%s" % conf_file_dest]

+         if wait:

+             cmd.append("--wait")

+         if scratch:

+             cmd.append("--scratch")

+ 

+         return cmd

+ 

      def get_create_image_cmd(self, name, version, target, arch, ks_file, repos, image_type="live", image_format=None, release=None, wait=True, archive=False):

          # Usage: koji spin-livecd [options] <name> <version> <target> <arch> <kickstart-file>

          # Usage: koji spin-appliance [options] <name> <version> <target> <arch> <kickstart-file>
@@ -160,12 +190,14 @@

  

      def run_create_image_cmd(self, command, log_file=None):

          # spin-{livecd,appliance} is blocking by default -> you probably want to run it in a thread

- 

-         retcode, output = run(command, can_fail=True, logfile=log_file)

+         try:

+             retcode, output = run(command, can_fail=True, logfile=log_file)

+         except RuntimeError, e:

+             raise RuntimeError("%s. %s failed with '%s'" % (e, command, output))

  

          match = re.search(r"Created task: (\d+)", output)

          if not match:

-             raise RuntimeError("Could not find task ID in output")

+             raise RuntimeError("Could not find task ID in output. Command '%s' returned '%s'." % (" ".join(command), output))

  

          result = {

              "retcode": retcode,
@@ -176,7 +208,6 @@

  

      def get_image_path(self, task_id):

          result = []

-         # XXX: hardcoded URL

          koji_proxy = self.koji_module.ClientSession(self.koji_module.config.server)

          task_info_list = []

          task_info_list.append(koji_proxy.getTaskInfo(task_id, request=True))
@@ -185,7 +216,7 @@

          # scan parent and child tasks for certain methods

          task_info = None

          for i in task_info_list:

-             if i["method"] in ("createAppliance", "createLiveCD"):

+             if i["method"] in ("createAppliance", "createLiveCD", 'createImage'):

                  task_info = i

                  break