f116d93
# -*- coding: utf-8 -*-
f116d93
f116d93
f116d93
# This program is free software; you can redistribute it and/or modify
f116d93
# it under the terms of the GNU General Public License as published by
f116d93
# the Free Software Foundation; version 2 of the License.
f116d93
#
f116d93
# This program is distributed in the hope that it will be useful,
f116d93
# but WITHOUT ANY WARRANTY; without even the implied warranty of
f116d93
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
f116d93
# GNU Library General Public License for more details.
f116d93
#
f116d93
# You should have received a copy of the GNU General Public License
d6dc269
# along with this program; if not, see <https://gnu.org/licenses/>.
f116d93
f116d93
f116d93
import os
f116d93
import time
f116d93
import pipes
f116d93
import random
f116d93
import shutil
f116d93
f116d93
import productmd.treeinfo
Adam Miller 5fa5fcd
from productmd.images import Image
f116d93
from kobo.threads import ThreadPool, WorkerThread
660c8bc
from kobo.shortcuts import run, relative_path
f116d93
e58c78f
from pungi.wrappers import iso
07e90f0
from pungi.wrappers.createrepo import CreaterepoWrapper
07e90f0
from pungi.wrappers.kojiwrapper import KojiWrapper
80fa723
from pungi.phases.base import PhaseBase, PhaseLoggerMixin
3e1a6ed
from pungi.util import (makedirs, get_volid, get_arch_variant_data, failable,
3e1a6ed
                        get_file_size, get_mtime)
b62b468
from pungi.media_split import MediaSplitter, convert_media_size
07e90f0
from pungi.compose_metadata.discinfo import read_discinfo, write_discinfo
f116d93
f37a14f
from .. import createiso
f37a14f
f116d93
80fa723
class CreateisoPhase(PhaseLoggerMixin, PhaseBase):
f116d93
    name = "createiso"
f116d93
f116d93
    def __init__(self, compose):
80fa723
        super(CreateisoPhase, self).__init__(compose)
80fa723
        self.pool = ThreadPool(logger=self.logger)
f116d93
df40000
    def _find_rpms(self, path):
df40000
        """Check if there are some RPMs in the path."""
df40000
        for _, _, files in os.walk(path):
df40000
            for fn in files:
df40000
                if fn.endswith(".rpm"):
df40000
                    return True
df40000
        return False
df40000
df40000
    def _is_bootable(self, variant, arch):
df40000
        if arch == "src":
df40000
            return False
df40000
        if variant.type != "variant":
df40000
            return False
f9a6c84
        return self.compose.conf["bootable"]
df40000
f116d93
    def run(self):
f9a6c84
        symlink_isos_to = self.compose.conf.get("symlink_isos_to")
f9a6c84
        disc_type = self.compose.conf['disc_types'].get('dvd', 'dvd')
7ce5c76
        deliverables = []
f116d93
f116d93
        commands = []
13871b6
        for variant in self.compose.get_variants(types=["variant", "layered-product", "optional"]):
258d716
            if variant.is_empty:
258d716
                continue
f116d93
            for arch in variant.arches + ["src"]:
1f313b3
                skip_iso = get_arch_variant_data(self.compose.conf, "createiso_skip", arch, variant)
1f313b3
                if skip_iso == [True]:
80fa723
                    self.logger.info("Skipping createiso for %s.%s due to config option" % (variant, arch))
1f313b3
                    continue
1f313b3
72f9819
                volid = get_volid(self.compose, arch, variant, disc_type=disc_type)
f116d93
                os_tree = self.compose.paths.compose.os_tree(arch, variant)
f116d93
f116d93
                iso_dir = self.compose.paths.compose.iso_dir(arch, variant, symlink_to=symlink_isos_to)
f116d93
                if not iso_dir:
f116d93
                    continue
f116d93
df40000
                if not self._find_rpms(os_tree):
80fa723
                    self.logger.warn("No RPMs found for %s.%s, skipping ISO"
80fa723
                                     % (variant.uid, arch))
f116d93
                    continue
f116d93
84fcc00
                bootable = self._is_bootable(variant, arch)
84fcc00
80fa723
                split_iso_data = split_iso(self.compose, arch, variant, no_split=bootable,
80fa723
                                           logger=self.logger)
f116d93
                disc_count = len(split_iso_data)
f116d93
f116d93
                for disc_num, iso_data in enumerate(split_iso_data):
f116d93
                    disc_num += 1
f116d93
df40000
                    filename = self.compose.get_image_name(
df40000
                        arch, variant, disc_type=disc_type, disc_num=disc_num)
df40000
                    iso_path = self.compose.paths.compose.iso_path(
df40000
                        arch, variant, filename, symlink_to=symlink_isos_to)
f116d93
                    if os.path.isfile(iso_path):
80fa723
                        self.logger.warn("Skipping mkisofs, image already exists: %s" % iso_path)
f116d93
                        continue
7ce5c76
                    deliverables.append(iso_path)
f116d93
df40000
                    graft_points = prepare_iso(self.compose, arch, variant,
df40000
                                               disc_num=disc_num, disc_count=disc_count,
df40000
                                               split_iso_data=iso_data)
f116d93
f116d93
                    cmd = {
f116d93
                        "iso_path": iso_path,
f116d93
                        "bootable": bootable,
f116d93
                        "cmd": [],
f116d93
                        "label": "",  # currently not used
f116d93
                        "disc_num": disc_num,
f116d93
                        "disc_count": disc_count,
f116d93
                    }
f116d93
f116d93
                    if os.path.islink(iso_dir):
df40000
                        cmd["mount"] = os.path.abspath(os.path.join(os.path.dirname(iso_dir),
df40000
                                                                    os.readlink(iso_dir)))
df40000
f37a14f
                    opts = createiso.CreateIsoOpts(
f37a14f
                        output_dir=iso_dir,
f37a14f
                        iso_name=filename,
f37a14f
                        volid=volid,
f37a14f
                        graft_points=graft_points,
f37a14f
                        arch=arch,
f37a14f
                        supported=self.compose.supported,
f37a14f
                    )
f116d93
df40000
                    if bootable:
f37a14f
                        opts = opts._replace(buildinstall_method=self.compose.conf['buildinstall_method'])
f116d93
f9a6c84
                    if self.compose.conf['create_jigdo']:
Jon Disnard 6e773f8
                        jigdo_dir = self.compose.paths.compose.jigdo_dir(arch, variant)
f37a14f
                        opts = opts._replace(jigdo_dir=jigdo_dir, os_tree=os_tree)
df40000
f37a14f
                    script_file = os.path.join(self.compose.paths.work.tmp_dir(arch, variant),
f37a14f
                                               'createiso-%s.sh' % filename)
f37a14f
                    with open(script_file, 'w') as f:
f37a14f
                        createiso.write_script(opts, f)
f37a14f
                    cmd['cmd'] = ['bash', script_file]
a6b673d
                    commands.append((cmd, variant, arch))
f116d93
3e1a6ed
        if self.compose.notifier:
3e1a6ed
            self.compose.notifier.send('createiso-targets', deliverables=deliverables)
7ce5c76
a6b673d
        for (cmd, variant, arch) in commands:
f116d93
            self.pool.add(CreateIsoThread(self.pool))
a6b673d
            self.pool.queue_put((self.compose, cmd, variant, arch))
f116d93
f116d93
        self.pool.start()
f116d93
f116d93
    def stop(self, *args, **kwargs):
f116d93
        PhaseBase.stop(self, *args, **kwargs)
f116d93
        if self.skip():
f116d93
            return
f116d93
f116d93
f116d93
class CreateIsoThread(WorkerThread):
3e1a6ed
    def fail(self, compose, cmd, variant, arch):
80fa723
        self.pool.log_error("CreateISO failed, removing ISO: %s" % cmd["iso_path"])
f116d93
        try:
f116d93
            # remove incomplete ISO
f116d93
            os.unlink(cmd["iso_path"])
660c8bc
            # TODO: remove jigdo & template
f116d93
        except OSError:
f116d93
            pass
3e1a6ed
        if compose.notifier:
3e1a6ed
            compose.notifier.send('createiso-imagefail',
3e1a6ed
                                  file=cmd['iso_path'],
3e1a6ed
                                  arch=arch,
3e1a6ed
                                  variant=str(variant))
f116d93
f116d93
    def process(self, item, num):
a6b673d
        compose, cmd, variant, arch = item
463088d
        can_fail = compose.can_fail(variant, arch, 'iso')
80fa723
        with failable(compose, can_fail, variant, arch, 'iso', logger=self.pool._logger):
3e1a6ed
            self.worker(compose, cmd, variant, arch, num)
a6b673d
3e1a6ed
    def worker(self, compose, cmd, variant, arch, num):
f116d93
        mounts = [compose.topdir]
f116d93
        if "mount" in cmd:
f116d93
            mounts.append(cmd["mount"])
f116d93
f9a6c84
        runroot = compose.conf["runroot"]
3e1a6ed
        bootable = cmd['bootable']
df40000
        log_file = compose.paths.log.log_file(
3e1a6ed
            arch, "createiso-%s" % os.path.basename(cmd["iso_path"]))
f116d93
df40000
        msg = "Creating ISO (arch: %s, variant: %s): %s" % (
3e1a6ed
            arch, variant, os.path.basename(cmd["iso_path"]))
f116d93
        self.pool.log_info("[BEGIN] %s" % msg)
f116d93
f116d93
        if runroot:
f116d93
            # run in a koji build root
f37a14f
            packages = ["coreutils", "genisoimage", "isomd5sum"]
f9a6c84
            if compose.conf['create_jigdo']:
f37a14f
                packages.append('jigdo')
df40000
            extra_packages = {
3e1a6ed
                'lorax': ['lorax'],
df40000
                'buildinstall': ['anaconda'],
df40000
            }
f116d93
            if bootable:
df40000
                packages.extend(extra_packages[compose.conf["buildinstall_method"]])
f116d93
f9a6c84
            runroot_channel = compose.conf.get("runroot_channel")
f116d93
            runroot_tag = compose.conf["runroot_tag"]
f116d93
f116d93
            # get info about build arches in buildroot_tag
02e55b6
            koji_wrapper = KojiWrapper(compose.conf["koji_profile"])
02e55b6
            koji_proxy = koji_wrapper.koji_proxy
f116d93
            tag_info = koji_proxy.getTag(runroot_tag)
1881bf7
            if not tag_info:
1881bf7
                raise RuntimeError('Tag "%s" does not exist.' % runroot_tag)
f116d93
            tag_arches = tag_info["arches"].split(" ")
f116d93
3e1a6ed
            build_arch = arch
3e1a6ed
            if not bootable:
f116d93
                if "x86_64" in tag_arches:
f116d93
                    # assign non-bootable images to x86_64 if possible
3e1a6ed
                    build_arch = "x86_64"
3e1a6ed
                elif build_arch == "src":
f116d93
                    # pick random arch from available runroot tag arches
3e1a6ed
                    build_arch = random.choice(tag_arches)
f116d93
df40000
            koji_cmd = koji_wrapper.get_runroot_cmd(
3e1a6ed
                runroot_tag, build_arch, cmd["cmd"],
df40000
                channel=runroot_channel, use_shell=True, task_id=True,
4ea1916
                packages=packages, mounts=mounts,
4ea1916
                weight=compose.conf['runroot_weights'].get('createiso')
4ea1916
            )
f116d93
f116d93
            # avoid race conditions?
f116d93
            # Kerberos authentication failed: Permission denied in replay cache code (-1765328215)
f116d93
            time.sleep(num * 3)
f116d93
f116d93
            output = koji_wrapper.run_runroot_cmd(koji_cmd, log_file=log_file)
f116d93
            if output["retcode"] != 0:
3e1a6ed
                self.fail(compose, cmd, variant, arch)
df40000
                raise RuntimeError("Runroot task failed: %s. See %s for more details."
df40000
                                   % (output["task_id"], log_file))
f116d93
f116d93
        else:
f116d93
            # run locally
f116d93
            try:
f116d93
                run(cmd["cmd"], show_cmd=True, logfile=log_file)
f116d93
            except:
3e1a6ed
                self.fail(compose, cmd, variant, arch)
f116d93
                raise
f116d93
f116d93
        img = Image(compose.im)
3e1a6ed
        img.path = cmd["iso_path"].replace(compose.paths.compose.topdir(), '').lstrip('/')
3e1a6ed
        img.mtime = get_mtime(cmd["iso_path"])
3e1a6ed
        img.size = get_file_size(cmd["iso_path"])
3e1a6ed
        img.arch = arch
f116d93
        # XXX: HARDCODED
f116d93
        img.type = "dvd"
f116d93
        img.format = "iso"
f116d93
        img.disc_number = cmd["disc_num"]
f116d93
        img.disc_count = cmd["disc_count"]
f116d93
        img.bootable = cmd["bootable"]
3e1a6ed
        img.subvariant = variant.uid
cd805a1
        img.implant_md5 = iso.get_implanted_md5(cmd["iso_path"], logger=compose._logger)
b476545
        setattr(img, 'can_fail', compose.can_fail(variant, arch, 'iso'))
4e3d87e
        setattr(img, 'deliverable', 'iso')
f116d93
        try:
f116d93
            img.volume_id = iso.get_volume_id(cmd["iso_path"])
f116d93
        except RuntimeError:
f116d93
            pass
f78709a
        if arch == "src":
f78709a
            for variant_arch in variant.arches:
f78709a
                compose.im.add(variant.uid, variant_arch, img)
f78709a
        else:
f78709a
            compose.im.add(variant.uid, arch, img)
f116d93
        # TODO: supported_iso_bit
f116d93
        # add: boot.iso
f116d93
f116d93
        self.pool.log_info("[DONE ] %s" % msg)
3e1a6ed
        if compose.notifier:
3e1a6ed
            compose.notifier.send('createiso-imagedone',
3e1a6ed
                                  file=cmd['iso_path'],
3e1a6ed
                                  arch=arch,
3e1a6ed
                                  variant=str(variant))
f116d93
f116d93
80fa723
def split_iso(compose, arch, variant, no_split=False, logger=None):
b62b468
    """
b62b468
    Split contents of the os/ directory for given tree into chunks fitting on ISO.
f116d93
b62b468
    All files from the directory are taken except for possible boot.iso image.
b62b468
    Files added in extra_files phase are put on all disks.
84fcc00
84fcc00
    If `no_split` is set, we will pretend that the media is practically
84fcc00
    infinite so that everything goes on single disc. A warning is printed if
84fcc00
    the size is bigger than configured.
b62b468
    """
80fa723
    if not logger:
80fa723
        logger = compose._logger
f9a6c84
    media_size = compose.conf['iso_size']
f9a6c84
    media_reserve = compose.conf['split_iso_reserve']
84fcc00
    split_size = convert_media_size(media_size) - convert_media_size(media_reserve)
84fcc00
    real_size = 10**20 if no_split else split_size
b62b468
80fa723
    ms = MediaSplitter(real_size, compose, logger=logger)
f116d93
f116d93
    os_tree = compose.paths.compose.os_tree(arch, variant)
f116d93
    extra_files_dir = compose.paths.work.extra_files_dir(arch, variant)
f116d93
f116d93
    # scan extra files to mark them "sticky" -> they'll be on all media after split
f116d93
    extra_files = set()
f116d93
    for root, dirs, files in os.walk(extra_files_dir):
f116d93
        for fn in files:
f116d93
            path = os.path.join(root, fn)
f116d93
            rel_path = relative_path(path, extra_files_dir.rstrip("/") + "/")
f116d93
            extra_files.add(rel_path)
f116d93
f116d93
    packages = []
f116d93
    all_files = []
f116d93
    all_files_ignore = []
f116d93
f116d93
    ti = productmd.treeinfo.TreeInfo()
f116d93
    ti.load(os.path.join(os_tree, ".treeinfo"))
f116d93
    boot_iso_rpath = ti.images.images.get(arch, {}).get("boot.iso", None)
f116d93
    if boot_iso_rpath:
f116d93
        all_files_ignore.append(boot_iso_rpath)
80fa723
    logger.debug("split_iso all_files_ignore = %s" % ", ".join(all_files_ignore))
f116d93
f116d93
    for root, dirs, files in os.walk(os_tree):
f116d93
        for dn in dirs[:]:
f116d93
            repo_dir = os.path.join(root, dn)
f116d93
            if repo_dir == os.path.join(compose.paths.compose.repository(arch, variant), "repodata"):
f116d93
                dirs.remove(dn)
f116d93
f116d93
        for fn in files:
f116d93
            path = os.path.join(root, fn)
f116d93
            rel_path = relative_path(path, os_tree.rstrip("/") + "/")
f116d93
            sticky = rel_path in extra_files
f116d93
            if rel_path in all_files_ignore:
80fa723
                logger.info("split_iso: Skipping %s" % rel_path)
f116d93
                continue
a8d7f8a
            if root.startswith(compose.paths.compose.packages(arch, variant)):
f116d93
                packages.append((path, os.path.getsize(path), sticky))
f116d93
            else:
f116d93
                all_files.append((path, os.path.getsize(path), sticky))
f116d93
f116d93
    for path, size, sticky in all_files + packages:
f116d93
        ms.add_file(path, size, sticky)
f116d93
84fcc00
    result = ms.split()
84fcc00
    if no_split and result[0]['size'] > split_size:
80fa723
        logger.warn('ISO for %s.%s does not fit on single media! '
80fa723
                    'It is %s bytes too big. (Total size: %s B)'
80fa723
                    % (variant.uid, arch,
80fa723
                       result[0]['size'] - split_size,
80fa723
                       result[0]['size']))
84fcc00
    return result
f116d93
f116d93
f116d93
def prepare_iso(compose, arch, variant, disc_num=1, disc_count=None, split_iso_data=None):
f116d93
    tree_dir = compose.paths.compose.os_tree(arch, variant)
0e237db
    filename = compose.get_image_name(arch, variant, disc_num=disc_num)
0e237db
    iso_dir = compose.paths.work.iso_dir(arch, filename)
f116d93
f116d93
    # modify treeinfo
f116d93
    ti_path = os.path.join(tree_dir, ".treeinfo")
f116d93
    ti = productmd.treeinfo.TreeInfo()
f116d93
    ti.load(ti_path)
f116d93
    ti.media.totaldiscs = disc_count or 1
f116d93
    ti.media.discnum = disc_num
f116d93
f116d93
    # remove boot.iso from all sections
f116d93
    paths = set()
f116d93
    for platform in ti.images.images:
f116d93
        if "boot.iso" in ti.images.images[platform]:
f116d93
            paths.add(ti.images.images[platform].pop("boot.iso"))
f116d93
f116d93
    # remove boot.iso from checksums
f116d93
    for i in paths:
f116d93
        if i in ti.checksums.checksums.keys():
f116d93
            del ti.checksums.checksums[i]
f116d93
f116d93
    # make a copy of isolinux/isolinux.bin, images/boot.img - they get modified when mkisofs is called
f116d93
    for i in ("isolinux/isolinux.bin", "images/boot.img"):
f116d93
        src_path = os.path.join(tree_dir, i)
f116d93
        dst_path = os.path.join(iso_dir, i)
f116d93
        if os.path.exists(src_path):
f116d93
            makedirs(os.path.dirname(dst_path))
f116d93
            shutil.copy2(src_path, dst_path)
f116d93
f116d93
    if disc_count > 1:
f116d93
        # remove repodata/repomd.xml from checksums, create a new one later
f116d93
        if "repodata/repomd.xml" in ti.checksums.checksums:
f116d93
            del ti.checksums.checksums["repodata/repomd.xml"]
f116d93
f116d93
        # rebuild repodata
f9a6c84
        createrepo_c = compose.conf["createrepo_c"]
90918dc
        createrepo_checksum = compose.conf["createrepo_checksum"]
f116d93
        repo = CreaterepoWrapper(createrepo_c=createrepo_c)
f116d93
f116d93
        file_list = "%s-file-list" % iso_dir
f116d93
        packages_dir = compose.paths.compose.packages(arch, variant)
f116d93
        file_list_content = []
f116d93
        for i in split_iso_data["files"]:
f116d93
            if not i.endswith(".rpm"):
f116d93
                continue
f116d93
            if not i.startswith(packages_dir):
f116d93
                continue
f116d93
            rel_path = relative_path(i, tree_dir.rstrip("/") + "/")
f116d93
            file_list_content.append(rel_path)
f116d93
f116d93
        if file_list_content:
f116d93
            # write modified repodata only if there are packages available
f116d93
            run("cp -a %s/repodata %s/" % (pipes.quote(tree_dir), pipes.quote(iso_dir)))
f116d93
            open(file_list, "w").write("\n".join(file_list_content))
f116d93
            cmd = repo.get_createrepo_cmd(tree_dir, update=True, database=True, skip_stat=True, pkglist=file_list, outputdir=iso_dir, workers=3, checksum=createrepo_checksum)
f116d93
            run(cmd)
f116d93
            # add repodata/repomd.xml back to checksums
19a7394
            ti.checksums.add("repodata/repomd.xml", "sha256", root_dir=iso_dir)
f116d93
f116d93
    new_ti_path = os.path.join(iso_dir, ".treeinfo")
f116d93
    ti.dump(new_ti_path)
f116d93
f116d93
    # modify discinfo
f116d93
    di_path = os.path.join(tree_dir, ".discinfo")
f116d93
    data = read_discinfo(di_path)
f116d93
    data["disc_numbers"] = [disc_num]
f116d93
    new_di_path = os.path.join(iso_dir, ".discinfo")
f116d93
    write_discinfo(new_di_path, **data)
f116d93
f116d93
    if not disc_count or disc_count == 1:
e58c78f
        data = iso.get_graft_points([tree_dir, iso_dir])
f116d93
    else:
e58c78f
        data = iso.get_graft_points([iso._paths_from_list(tree_dir, split_iso_data["files"]), iso_dir])
f116d93
f116d93
    # TODO: /content /graft-points
f116d93
    gp = "%s-graft-points" % iso_dir
e58c78f
    iso.write_graft_points(gp, data, exclude=["*/lost+found", "*/boot.iso"])
f116d93
    return gp