# kojid plugin

from __future__ import absolute_import

import glob
import os
import platform
import re
import subprocess

import six.moves.configparser

import koji
import koji.tasks
from __main__ import BuildRoot
from koji.daemon import log_output
from koji.tasks import scan_mounts
from koji.util import isSuccess, parseStatus

__all__ = ('RunRootTask',)

CONFIG_FILE = '/etc/kojid/plugins/runroot.conf'


class RunRootTask(koji.tasks.BaseTaskHandler):

    Methods = ['runroot']

    _taskWeight = 2.0

    def __init__(self, *args, **kwargs):
        self._read_config()
        return super(RunRootTask, self).__init__(*args, **kwargs)

    def _get_path_params(self, path, rw=False):
        found = False
        for mount_data in self.config['paths']:
            if path.startswith(mount_data['mountpoint']):
                found = True
                break
        if not found:
            raise koji.GenericError("bad config: missing corresponding mountpoint")
        options = []
        for o in mount_data['options'].split(','):
            if rw and o == 'ro':
                options.append('rw')
            else:
                options.append(o)
        rel_path = path[len(mount_data['mountpoint']):]
        rel_path = rel_path[1:] if rel_path.startswith('/') else rel_path
        res = (os.path.join(mount_data['path'], rel_path), path, mount_data['fstype'],
               ','.join(options))
        return res

    def _read_config(self):
        cp = koji.read_config_files(CONFIG_FILE)
        self.config = {
            'default_mounts': [],
            'safe_roots': [],
            'path_subs': [],
            'paths': [],
            'internal_dev_setup': None,
        }

        # main options
        if cp.has_option('runroot', 'internal_dev_setup'):
            self.config['internal_dev_setup'] = cp.getboolean('runroot', 'internal_dev_setup')

        # path options
        if cp.has_option('paths', 'default_mounts'):
            self.config['default_mounts'] = cp.get('paths', 'default_mounts').split(',')
        if cp.has_option('paths', 'safe_roots'):
            self.config['safe_roots'] = cp.get('paths', 'safe_roots').split(',')
        if cp.has_option('paths', 'path_subs'):
            self.config['path_subs'] = []
            for line in cp.get('paths', 'path_subs').splitlines():
                line = line.strip()
                if not line:
                    continue
                sub = line.split(',')
                if len(sub) != 2:
                    raise koji.GenericError('bad runroot substitution: %s' % sub)
                self.config['path_subs'].append(sub)

        # path section are in form 'path%d' while order is important as some
        # paths can be mounted inside other mountpoints
        path_sections = [p for p in cp.sections() if re.match(r'path\d+', p)]
        for section_name in sorted(path_sections, key=lambda x: int(x[4:])):
            try:
                self.config['paths'].append({
                    'mountpoint': cp.get(section_name, 'mountpoint'),
                    'path': cp.get(section_name, 'path'),
                    'fstype': cp.get(section_name, 'fstype'),
                    'options': cp.get(section_name, 'options'),
                })
            except six.moves.configparser.NoOptionError:
                raise koji.GenericError("bad config: missing options in %s section" % section_name)

        for path in self.config['default_mounts'] + self.config['safe_roots'] + \
                [x[0] for x in self.config['path_subs']]:
            if not path.startswith('/'):
                raise koji.GenericError(
                    "bad config: all paths (default_mounts, safe_roots, path_subs) needs to be "
                    "absolute: %s" % path)

    def handler(self, root, arch, command, keep=False, packages=[], mounts=[], repo_id=None,
                skip_setarch=False, weight=None, upload_logs=None, new_chroot=None):
        """Create a buildroot and run a command (as root) inside of it

        Command may be a string or a list.

        Returns a message indicating success if the command was successful, and
        raises an error otherwise.  Command output will be available in
        runroot.log in the task output directory on the hub.

        skip_setarch is a rough approximation of an old hack

        the keep option is not used. keeping for compatibility for now...

        upload_logs is list of absolute paths which will be uploaded for
        archiving on hub. It always consists of /tmp/runroot.log, but can be
        used for additional logs (pungi.log, etc.)
        """
        if weight is not None:
            weight = max(weight, 0.5)
            self.session.host.setTaskWeight(self.id, weight)

        # noarch is funny
        if arch == "noarch":
            # we need a buildroot arch. Pick one that:
            #  a) this host can handle
            #  b) the build tag can support
            #  c) is canonical
            host_arches = self.session.host.getHost()['arches']
            if not host_arches:
                raise koji.BuildError("No arch list for this host")
            tag_arches = self.session.getBuildConfig(root)['arches']
            if not tag_arches:
                raise koji.BuildError("No arch list for tag: %s" % root)
            # index canonical host arches
            host_arches = set([koji.canonArch(a) for a in host_arches.split()])
            # pick the first suitable match from tag's archlist
            for br_arch in tag_arches.split():
                br_arch = koji.canonArch(br_arch)
                if br_arch in host_arches:
                    # we're done
                    break
            else:
                # no overlap
                raise koji.BuildError(
                    "host does not match tag arches: %s (%s)" % (root, tag_arches))
        else:
            br_arch = arch
        if repo_id:
            repo_info = self.session.repoInfo(repo_id, strict=True)
            if repo_info['tag_name'] != root:
                raise koji.BuildError(
                    "build tag (%s) does not match repo tag (%s)" % (root, repo_info['tag_name']))
            if repo_info['state'] not in (koji.REPO_STATES['READY'], koji.REPO_STATES['EXPIRED']):
                raise koji.BuildError(
                    "repos in the %s state may not be used by runroot" %
                    koji.REPO_STATES[repo_info['state']])
        else:
            repo_info = self.session.getRepo(root)
        if not repo_info:
            # wait for it
            task_id = self.session.host.subtask(method='waitrepo',
                                                arglist=[root, None, None],
                                                parent=self.id)
            repo_info = self.wait(task_id)[task_id]
        broot = BuildRoot(self.session, self.options, root, br_arch, self.id,
                          repo_id=repo_info['id'], setup_dns=True,
                          internal_dev_setup=self.config['internal_dev_setup'])
        broot.workdir = self.workdir
        broot.init()
        rootdir = broot.rootdir()
        # workaround for rpm oddness
        for f in glob.glob(os.path.join(rootdir, '/var/lib/rpm/__db*')):
            try:
                os.unlink(f)
            except OSError:
                pass
        # update buildroot state (so that updateBuildRootList() will work)
        self.session.host.setBuildRootState(broot.id, 'BUILDING')
        try:
            if packages:
                # pkglog = '%s/%s' % (broot.resultdir(), 'packages.log')
                pkgcmd = ['--install'] + packages
                status = broot.mock(pkgcmd)
                self.session.host.updateBuildRootList(broot.id, broot.getPackageList())
                if not isSuccess(status):
                    raise koji.BuildrootError(parseStatus(status, pkgcmd))

            if isinstance(command, str):
                cmdstr = command
            else:
                # we were passed an arglist
                # we still have to run this through the shell (for redirection)
                # but we can preserve the list structure precisely with careful escaping
                cmdstr = ' '.join(["'%s'" % arg.replace("'", r"'\''") for arg in command])
            # A nasty hack to put command output into its own file until mock can be
            # patched to do something more reasonable than stuff everything into build.log
            cmdargs = ['/bin/sh', '-c',
                       "{ %s; } < /dev/null 2>&1 | /usr/bin/tee /builddir/runroot.log; exit "
                       "${PIPESTATUS[0]}" % cmdstr]

            # always mount /mnt/redhat (read-only)
            # always mount /mnt/iso (read-only)
            # also need /dev bind mount
            self.do_mounts(rootdir,
                           [self._get_path_params(x) for x in self.config['default_mounts']])
            self.do_extra_mounts(rootdir, mounts)
            mock_cmd = []
            if new_chroot:
                mock_cmd.append('--new-chroot')
            elif new_chroot is False:  # None -> no option added
                mock_cmd.append('--old-chroot')
            if skip_setarch:
                # we can't really skip it, but we can set it to the current one instead of of the
                # chroot one
                myarch = platform.uname()[5]
                mock_cmd.extend(['--arch', myarch])
            mock_cmd.extend(['--chroot', '--'])
            mock_cmd.extend(cmdargs)
            rv = broot.mock(mock_cmd)
            log_paths = ['builddir/runroot.log']
            if upload_logs is not None:
                log_paths += upload_logs
            for log_path in log_paths:
                self.uploadFile(os.path.join(rootdir, log_path))
        finally:
            # mock should umount its mounts, but it will not handle ours
            self.undo_mounts(rootdir, fatal=False)
            broot.expire()
        if isinstance(command, str):
            cmdlist = command.split()
        else:
            cmdlist = command
        cmdlist = [param for param in cmdlist if '=' not in param]
        if cmdlist:
            cmd = os.path.basename(cmdlist[0])
        else:
            cmd = '(none)'
        if isSuccess(rv):
            return '%s completed successfully' % cmd
        else:
            raise koji.BuildrootError(parseStatus(rv, cmd))

    def do_extra_mounts(self, rootdir, mounts):
        mnts = []
        for mount in mounts:
            mount = os.path.normpath(mount)
            for safe_root in self.config['safe_roots']:
                if mount.startswith(safe_root):
                    break
            else:
                # no match
                raise koji.GenericError("read-write mount point is not safe: %s" % mount)
            # normpath should have removed any .. dirs, but just in case...
            if mount.find('/../') != -1:
                raise koji.GenericError("read-write mount point is not safe: %s" % mount)

            for rep, sub in self.config['path_subs']:
                mount = mount.replace(rep, sub)

            mnts.append(self._get_path_params(mount, rw=True))
        self.do_mounts(rootdir, mnts)

    def do_mounts(self, rootdir, mounts):
        if not mounts:
            return
        self.logger.info('New runroot')
        self.logger.info("Runroot mounts: %s" % mounts)
        fn = '%s/tmp/runroot_mounts' % rootdir
        with koji._open_text_file(fn, 'at') as fslog:
            logfile = "%s/do_mounts.log" % self.workdir
            uploadpath = self.getUploadDir()
            error = None
            for dev, path, type, opts in mounts:
                if not path.startswith('/'):
                    raise koji.GenericError("invalid mount point: %s" % path)
                mpoint = "%s%s" % (rootdir, path)
                if opts is None:
                    opts = []
                else:
                    opts = opts.split(',')
                if 'bind' in opts:
                    # make sure dir exists
                    if not os.path.isdir(dev):
                        error = koji.GenericError("No such directory or mount: %s" % dev)
                        break
                    type = 'none'
                if 'bg' in opts:
                    error = koji.GenericError("bad config: background mount not allowed")
                    break
                opts = ','.join(opts)
                cmd = ['mount', '-t', type, '-o', opts, dev, mpoint]
                self.logger.info("Mount command: %r" % cmd)
                koji.ensuredir(mpoint)
                status = log_output(self.session, cmd[0], cmd, logfile, uploadpath,
                                    logerror=True, append=True)
                if not isSuccess(status):
                    error = koji.GenericError("Unable to mount %s: %s"
                                              % (mpoint, parseStatus(status, cmd)))
                    break
                fslog.write("%s\n" % mpoint)
                fslog.flush()
        if error is not None:
            self.undo_mounts(rootdir, fatal=False)
            raise error

    def undo_mounts(self, rootdir, fatal=True):
        self.logger.debug("Unmounting runroot mounts")
        mounts = set()
        fn = '%s/tmp/runroot_mounts' % rootdir
        if os.path.exists(fn):
            with koji._open_text_file(fn) as fslog:
                for line in fslog.readlines():
                    mounts.add(line.strip())
        # also, check /proc/mounts just in case
        mounts |= set(scan_mounts(rootdir))
        mounts = sorted(mounts)
        # deeper directories first
        mounts.reverse()
        failed = []
        self.logger.info("Unmounting (runroot): %s" % mounts)
        for dir in mounts:
            proc = subprocess.Popen(["umount", "-l", dir],
                                    stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            if proc.wait() != 0:
                output = proc.stdout.read()
                output += proc.stderr.read()
                failed.append("%s: %s" % (dir, output))
        if failed:
            msg = "Unable to unmount: %s" % ', '.join(failed)
            self.logger.warning(msg)
            if fatal:
                raise koji.GenericError(msg)
        else:
            # remove the mount list when everything is unmounted
            try:
                os.unlink(fn)
            except OSError:
                pass
