#!/usr/bin/python3
#
# Copyright (C) 2019 Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
import argparse
import logging as log
import os
import shutil
import subprocess
import tempfile
import sys

from pylorax.mount import IsoMountpoint
from pylorax.treebuilder import udev_escape

# Constants for efimode (MACBOOT implies EFIBOOT)
NO_EFI = 0
EFIBOOT = 1
MACBOOT = 2

class Tool():
    """A class to check for executables and required files"""
    tools = []
    paths = {}
    requirements = []
    arches = []

    def __init__(self):
        # If there are arches listed it must be running on one of them
        if self.arches and os.uname().machine not in self.arches:
            log.debug("%s is not supported by this tool", os.uname().machine)
            raise RuntimeError("%s not supported" % os.uname().machine)

        # Check the system to see if the tools are available and record their paths
        for e in self.tools:
            self.paths[e] = shutil.which(e)
            if not self.paths[e]:
                log.debug("No executable %s found in PATH", e)
                raise RuntimeError("Missing executable")

        # Check to make sure all the required files are available
        for f in self.requirements:
            if not os.path.exists(f):
                log.debug("Missing file %s", f)
                raise RuntimeError("Missing requirement %s" % f)


class MkefibootTool(Tool):
    """Create the efiboot.img needed for EFI booting"""
    tools = ["mkefiboot"]

    def run(self, isodir, tmpdir, product="Fedora"):
        cmd = ["mkefiboot", "--label=ANACONDA"]
        if log.root.level < log.INFO:
            cmd.append("--debug")
        cmd.extend([os.path.join(tmpdir, "EFI/BOOT"), os.path.join(tmpdir, "images/efiboot.img")])
        log.debug(" ".join(cmd))
        try:
            subprocess.check_output(cmd)
        except subprocess.CalledProcessError as e:
            log.error(str(e))
            raise RuntimeError("Running mkefiboot")


class MkmacbootTool(MkefibootTool):
    """Create the macboot.img needed to EFI boot on Mac hardware"""
    tools = ["mkefiboot"]
    # NOTE: Order is important
    requirements = ["/usr/share/pixmaps/bootloader/fedora.icns",
                    "/usr/share/pixmaps/bootloader/fedora-media.vol"]

    def run(self, isodir, tmpdir, product="Fedora"):
        cmd = ["mkefiboot", "--label", "ANACONDA",
               "--apple", "--icon", self.requirements[0],
               "--diskname", self.requirements[1],
               "--product", product]
        if log.root.level < log.INFO:
            cmd.append("--debug")
        cmd.extend([os.path.join(tmpdir, "EFI/BOOT"), os.path.join(tmpdir, "images/macboot.img")])
        log.debug(" ".join(cmd))
        try:
            subprocess.check_output(cmd)
        except subprocess.CalledProcessError as e:
            log.error(str(e))
            raise RuntimeError("Running mkefiboot --apple")

        # images/macboot.img always exists with images/efiboot.img, already in grafts list
        return []


class MakeISOTool(Tool):
    """Class to hold details for specific iso creation tools"""
    def check_file_sizes(self, grafts):
        """Return True any file exceeds 4GiB"""
        for src, _ in grafts:
            if os.path.isdir(src):
                for top, dirs, files in os.walk(src):
                    for f in files + dirs:
                        if os.stat(os.path.join(top,f)).st_size >= 4*1024**3:
                            return True
            else:
                if os.stat(src).st_size >= 4*1024**3:
                    return True

        return False

    def _exec(self, cmd, grafts, output_iso, efimode=NO_EFI, implantmd5=True):
        """Add the grafts and run the command and then implant the md5 checksums"""
        cmd.append("-graft-points")

        for src, dest in grafts:
            cmd.append("%s=%s" % (dest, src))

        log.debug(" ".join(cmd))
        try:
            subprocess.check_output(cmd)
        except subprocess.CalledProcessError as e:
            log.error(str(e))
            raise RuntimeError("New ISO creation failed")

        if efimode > NO_EFI:
            cmd = ["isohybrid", "--uefi"]
            if efimode == MACBOOT:
                cmd.append("--mac")
            if log.root.level < log.INFO:
                cmd.append("--verbose")
            cmd.append(output_iso)

            log.debug(" ".join(cmd))
            try:
                subprocess.check_output(cmd)
            except subprocess.CalledProcessError as e:
                log.error(str(e))
                raise RuntimeError("Hybrid ISO creation failed")

        if not implantmd5:
            return

        cmd = ["implantisomd5", output_iso]
        log.debug(" ".join(cmd))
        try:
            subprocess.check_output(cmd)
        except subprocess.CalledProcessError as e:
            log.error(str(e))
            raise RuntimeError("implantisomd5 failed")


class Mkisofs_aarch64(MakeISOTool):
    """Use the mkisofs tool to create the final iso (aarch64)"""
    tools = ["mkisofs", "implantisomd5"]
    arches = ["aarch64", "arm"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["mkisofs", "-o", output_iso, "-R", "-J", "-V", volume_name,
               "-T"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")
        if efimode > NO_EFI:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/efiboot.img", "-no-emul-boot"])
        if efimode > EFIBOOT:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/macboot.img", "-no-emul-boot"])

        if self.check_file_sizes(grafts):
            cmd.append("-allow-limited-size")

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, implantmd5=True)


class Mkisofs_ppc(MakeISOTool):
    """Use the mkisofs tool to create the final iso (ppc)"""
    tools = ["mkisofs"]
    requirements = ["/usr/share/lorax/templates.d/99-generic/config_files/ppc/mapping"]
    arches = ["ppc"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["mkisofs", "-o", output_iso, "-R", "-J", "-V", volume_name,
               "-U", "-T", "-part", "-hfs", "-r", "-l", "-sysid", "PPC",
               "-chrp-boot", "-no-desktop", "-allow-multidot",
               "-map", self.requirements[0], "-hfs-bless", "boot/grub/powerpc-ieee1275"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")

        if self.check_file_sizes(grafts):
            cmd.append("-allow-limited-size")

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, efimode, implantmd5=True)


class Mkisofs_ppc64le(MakeISOTool):
    """Use the mkisofs tool to create the final iso (ppc64le)"""
    tools = ["mkisofs"]
    requirements = ["/usr/share/lorax/templates.d/99-generic/config_files/ppc/mapping"]
    arches = ["ppc64le"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["mkisofs", "-o", output_iso, "-R", "-J", "-V", volume_name,
               "-T", "-part", "-hfs", "-r", "-l", "-sysid", "PPC",
               "-chrp-boot", "-no-desktop", "-allow-multidot",
               "-map", self.requirements[0]]
        if log.root.level < log.INFO:
            cmd.append("--verbose")

        if self.check_file_sizes(grafts):
            cmd.append("-allow-limited-size")

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, efimode, implantmd5=True)


class Mkisofs_s390(MakeISOTool):
    """Use the mkisofs tool to create the final iso (s390)"""
    tools = ["mkisofs"]
    requirements = []
    arches = ["s390", "s390x"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["mkisofs", "-o", output_iso, "-R", "-J", "-V", volume_name,
               "-b", "images/cdboot.img",
               "-c", "images/boot.cat",
               "-boot-load-size", "4", "-no-emul-boot"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")

        if self.check_file_sizes(grafts):
            cmd.append("-allow-limited-size")

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, efimode, implantmd5=True)


class Mkisofs_x86_64(MakeISOTool):
    """Use the mkisofs tool to create the final iso (x86_64)"""
    tools = ["mkisofs", "isohybrid"]
    requirements = []
    arches = ["x86_64", "i386"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["mkisofs", "-o", output_iso, "-R", "-J", "-V", volume_name,
               "-b", "isolinux/isolinux.bin",
               "-c", "isolinux/boot.cat",
               "-boot-load-size", "4", "-boot-info-table", "-no-emul-boot"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")
        if efimode > NO_EFI:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/efiboot.img", "-no-emul-boot"])
        if efimode > EFIBOOT:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/macboot.img", "-no-emul-boot"])

        if self.check_file_sizes(grafts):
            cmd.append("-allow-limited-size")

        # Create the iso, implant the md5 checksums, and create hybrid iso
        self._exec(cmd, grafts, output_iso, efimode, implantmd5=True)


class Xorrisofs_aarch64(MakeISOTool):
    """Use the xorrisofs tool to create the final iso (aarch64)"""
    tools = ["xorrisofs", "implantisomd5"]
    requirements = []
    arches = ["aarch64", "arm"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode):
        cmd = ["xorrisofs", "-o", output_iso,
               "-R", "-J", "-V", volume_name]
        if log.root.level < log.INFO:
            cmd.append("--verbose")
        if efimode >= EFIBOOT:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/efiboot.img", "-no-emul-boot"])
        if efimode == MACBOOT:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/macboot.img", "-no-emul-boot"])

        if self.check_file_sizes(grafts):
            cmd.extend(["-iso-level", "3"])

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, implantmd5=True)


class Xorrisofs_ppc64le(MakeISOTool):
    """Use the xorrisofs tool to create the final iso (ppc64)"""
    tools = ["xorrisofs", "implantisomd5"]
    requirements = []
    arches = ["ppc64le"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["xorrisofs", "-o", output_iso,
               "-R", "-J", "-V", volume_name,
               "-U", "-r", "-l", "-sysid", "PPC",
               "-A", volume_name, "-chrp-boot"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")

        if self.check_file_sizes(grafts):
            cmd.extend(["-iso-level", "3"])

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, efimode=NO_EFI, implantmd5=True)


class Xorrisofs_s390(MakeISOTool):
    """Use the xorrisofs tool to create the final iso (s390)"""
    tools = ["xorrisofs", "implantisomd5"]
    requirements = []
    arches = ["s390", "s390x"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["xorrisofs", "-o", output_iso,
               "-R", "-J", "-V", volume_name,
               "-b", "images/cdboot.img", "-c", "images/boot.cat",
               "-boot-load-size", "4", "-no-emul-boot"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")

        if self.check_file_sizes(grafts):
            cmd.extend(["-iso-level", "3"])

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, efimode=NO_EFI, implantmd5=True)


class Xorrisofs_x86_64(MakeISOTool):
    """Use the xorrisofs tool to create the final iso"""
    tools = ["xorrisofs", "implantisomd5"]
    requirements = ["/usr/share/syslinux/isohdpfx.bin"]
    arches = ["x86_64", "i386"]

    def run(self, tmpdir, grafts, volume_name, output_iso, efimode=NO_EFI):
        cmd = ["xorrisofs", "-o", output_iso,
               "-R", "-J", "-V", volume_name,
               "-isohybrid-mbr", self.requirements[0],
               "-b", "isolinux/isolinux.bin",
               "-c", "isolinux/boot.cat",
               "-boot-load-size", "4", "-boot-info-table", "-no-emul-boot"]
        if log.root.level < log.INFO:
            cmd.append("--verbose")
        if efimode >= EFIBOOT:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/efiboot.img", "-no-emul-boot", "-isohybrid-gpt-basdat"])
        if efimode == MACBOOT:
            cmd.extend(["-eltorito-alt-boot", "-e", "images/macboot.img", "-no-emul-boot", "-isohybrid-gpt-hfsplus"])

        if self.check_file_sizes(grafts):
            cmd.extend(["-iso-level", "3"])

        # Create the iso and implant the md5 checksums
        self._exec(cmd, grafts, output_iso, efimode=NO_EFI, implantmd5=True)


# xorrisofs based classes
XorrisofsTools = [Xorrisofs_aarch64, Xorrisofs_ppc64le, Xorrisofs_s390, Xorrisofs_x86_64]

# mkisofs based classes
MkisofsTools = [Mkisofs_aarch64, Mkisofs_ppc, Mkisofs_ppc64le, Mkisofs_s390, Mkisofs_x86_64]


class MakeKickstartISO():
    def __init__(self, ks, input_iso, output_iso, add_paths, cmdline="", volid=None):
        self.ks = ks
        self.input_iso = input_iso
        self.output_iso = output_iso
        self.add_paths = add_paths
        self.isotool = None
        self._cmdline = cmdline
        self.label = ""
        self.iso = None
        self.mkmacboot = None
        self.efimode = NO_EFI
        self.grafts = []

        errors = False
        for f in [ks, input_iso] + add_paths:
            if not os.path.exists(f):
                log.error("%s is missing", f)
                errors = True

        if errors:
            raise RuntimeError("Missing Files")

        # Mount the iso so we can gather information from it
        try:
            self.iso = IsoMountpoint(self.input_iso)
            self.label = self.iso.label if volid is None else volid
            log.info("Volume Id = %s", self.label)

            if os.path.exists(os.path.join(self.iso.mount_dir, "images/efiboot.img")):
                self.efimode = EFIBOOT
                self.mkefiboot = MkefibootTool()
            if os.path.exists(os.path.join(self.iso.mount_dir, "images/macboot.img")):
                self.efimode = MACBOOT
                self.mkmacboot = MkmacbootTool()

            for t in XorrisofsTools + MkisofsTools:
                try:
                    self.isotool = t()
                    break
                except RuntimeError:
                    continue
            if not self.isotool:
                raise RuntimeError("No suitable iso creation tool found.")

            log.info("Using %s to create the new iso", self.isotool.tools[0])
        except:
            if self.iso:
                self.iso.umount()
            raise

    @property
    def escaped_label(self):
        """Return the escaped iso volume label"""
        return udev_escape(self.label)

    def close(self):
        if self.iso:
            self.iso.umount()

    @property
    def add_args(self,):
        """Return the arguments to add to the config file kernel cmdline"""
        return "inst.ks=hd:LABEL=%s:/%s %s" % (self.escaped_label, os.path.basename(self.ks), self._cmdline)

    def remove_generated_files(self, grafts):
        """Remove the files generated by the iso creation tools"""
        catalog_files = ["boot.cat", "boot.catalog", "TRANS.TBL"]
        for src, _ in grafts:
            if not os.path.isdir(src):
                continue
            for f in catalog_files:
                if os.path.exists(os.path.join(src, f)):
                    os.unlink(os.path.join(src, f))

    def run_mkefiboot(self, isodir, tmpdir):
        self.mkefiboot.run(isodir, tmpdir)
        if self.efimode == MACBOOT:
            self.mkmacboot.run(isodir, tmpdir)

    def edit_configs(self, isodir, tmpdir):
        """Find and edit any configuration files

        Add the inst.ks= argument plus extra cmdline arguments
        """
        # Note that some of these may not exist, depending on the arch being used
        self._edit_isolinux(isodir, tmpdir)
        self._edit_efi(isodir, tmpdir)
        self._edit_ppc(isodir, tmpdir)
        self._edit_s390(isodir, tmpdir)

    def _edit_isolinux(self, isodir, tmpdir):
        """Copy the isolinux.cfg file and add the cmdline args"""

        orig_cfg = os.path.join(isodir, "isolinux/isolinux.cfg")
        if not os.path.exists(orig_cfg):
            log.warning("No isolinux/isolinux.cfg file found")
            return []

        # Edit the config file
        with open(orig_cfg, "r") as in_fp:
            with open(os.path.join(tmpdir, "isolinux/isolinux.cfg"), "w") as out_fp:
                escaped_iso_label = udev_escape(self.iso.label)
                for line in in_fp:
                    if escaped_iso_label in line:
                        line = line.replace(escaped_iso_label, self.escaped_label)
                    out_fp.write(line.rstrip("\n"))
                    if "append" in line:
                        out_fp.write(" "+self.add_args)
                    out_fp.write("\n")

    def _edit_efi(self, isodir, tmpdir):
        """Copy the efi config files and add the cmdline args"""
        # At least one of these must be present
        efi_cfgs = ["EFI/BOOT/grub.cfg", "EFI/BOOT/BOOT.conf"]

        if not os.path.exists(os.path.join(isodir, "EFI")):
            log.warning("No EFI directory file found")
            return []

        found_cfg = False
        for cfg in efi_cfgs:
            orig_cfg = os.path.join(isodir, cfg)
            if not os.path.exists(orig_cfg):
                continue

            dest_cfg = os.path.join(tmpdir, cfg)
            with open(orig_cfg, "r") as in_fp:
                with open(dest_cfg, "w") as out_fp:
                    escaped_iso_label = udev_escape(self.iso.label)
                    for line in in_fp:
                        if escaped_iso_label in line:
                            line = line.replace(escaped_iso_label, self.escaped_label)
                        if line.strip().startswith("search"):
                            line = line.replace(self.iso.label, self.label)
                        out_fp.write(line.rstrip("\n"))
                        # Some start with linux (aarch64), others with linuxefi (x86_64)
                        if line.strip().startswith("linux"):
                            out_fp.write(" "+self.add_args)
                        out_fp.write("\n")
            found_cfg = True

        if not found_cfg:
            raise RuntimeError("ISO is missing the EFI config files")

    def _edit_ppc(self, isodir, tmpdir):
        """Edit the boot/grub/grub.cfg file, adding the kickstart and extra arguments"""
        orig_cfg = os.path.join(isodir, "boot/grub/grub.cfg")
        if not os.path.exists(orig_cfg):
            log.warning("No boot/grub/grub.cfg file found")
            return []

        # Edit the config file
        with open(orig_cfg, "r") as in_fp:
            with open(os.path.join(tmpdir, "boot/grub/grub.cfg"), "w") as out_fp:
                escaped_iso_label = udev_escape(self.iso.label)
                for line in in_fp:
                    if escaped_iso_label in line:
                        line = line.replace(escaped_iso_label, self.escaped_label)
                    out_fp.write(line.rstrip("\n"))
                    if line.strip().startswith("linux "):
                        out_fp.write(" "+self.add_args)
                    out_fp.write("\n")

    def _edit_s390(self, isodir, tmpdir):
        """Edit the images/generic.prm file, adding the kickstart and extra arguments"""
        orig_cfg = os.path.join(isodir, "images/generic.prm")
        if not os.path.exists(orig_cfg):
            log.warning("No images/generic.prm file found")
            return []

        # Append to the config file
        with open(os.path.join(tmpdir, "images/generic.prm"), "a") as out_fp:
            out_fp.write(self.add_args+"\n")

    def run(self):
        """Modify the ISO"""
        try:
            # Make a temporary directory to hold modified files
            with tempfile.TemporaryDirectory(prefix="mkksiso-") as tmpdir:
                # Copy over the top level directories and populate grafts
                skip_iso = ["boot.cat", "boot.catalog", "TRANS.TBL"]
                for f in [f for f in os.listdir(self.iso.mount_dir) if f not in skip_iso]:
                    if os.path.isdir(os.path.join(self.iso.mount_dir, f)):
                        shutil.copytree(os.path.join(self.iso.mount_dir, f), os.path.join(tmpdir, f))
                    else:
                        shutil.copy2(os.path.join(self.iso.mount_dir, f), os.path.join(tmpdir, f))
                    self.grafts.append((os.path.join(tmpdir, f), f))

                # Copy and edit the configuration files
                self.edit_configs(self.iso.mount_dir, tmpdir)

                # Run the mkefiboot tool on the edited EFI directory, add the new files to the grafts
                self.run_mkefiboot(self.iso.mount_dir, tmpdir)

                # Add the kickstart to grafts
                self.grafts.extend([(self.ks, os.path.basename(self.ks))])

                # Add the extra files to grafts
                self.grafts.extend([(f, os.path.basename(f)) for f in self.add_paths])

                # Remove files that will be regenerated by the iso creation process
                self.remove_generated_files(self.grafts)

                log.info("grafts = %s", self.grafts)
                self.isotool.run(tmpdir, self.grafts, self.label, self.output_iso, self.efimode)
        finally:
            self.close()


def setup_args():
    """ Return argparse.Parser object of cmdline."""
    parser = argparse.ArgumentParser(description="Add a kickstart and files to an iso")

    parser.add_argument("-a", "--add", action="append", dest="add_paths", default=[],
                        type=os.path.abspath,
                        help="File or directory to add to ISO (may be used multiple times)")
    parser.add_argument("-c", "--cmdline", dest="cmdline", metavar="CMDLINE", default="",
                        help="Arguments to add to kernel cmdline")
    parser.add_argument("--debug", action="store_const", const=log.DEBUG,
                        dest="loglevel", default=log.INFO,
                        help="print debugging info")

    parser.add_argument("ks", type=os.path.abspath, help="Kickstart to add to the ISO")
    parser.add_argument("input_iso", type=os.path.abspath, help="ISO to modify")
    parser.add_argument("output_iso", type=os.path.abspath, help="Full pathname of iso to be created")
    parser.add_argument("-V", "--volid", dest="volid", help="Set the ISO volume id, defaults to input's", default=None)
    args = parser.parse_args()

    return args


def main():
    args = setup_args()
    log.basicConfig(format='%(levelname)s:%(message)s', level=args.loglevel)

    if os.getuid() != 0:
        log.error("You must run this as root, it needs to mount the iso and run mkefiboot")
        sys.exit(-1)

    try:
        app = MakeKickstartISO(args.ks, args.input_iso, args.output_iso,
                               args.add_paths, args.cmdline, args.volid)
        app.run()
    except RuntimeError as e:
        log.error(str(e))

if __name__ == '__main__':
    main()
