#!/usr/bin/python
# Note that the shebang above is ignored when run in policy
# See lib/packages.cf `package_module yum` use of the
# `interpreter` attribute to use cfengine-selected-python.

import sys
import os
import subprocess
import re


rpm_cmd = os.environ.get("CFENGINE_TEST_RPM_CMD", "/bin/rpm")
rpm_quiet_option = ["--quiet"]
rpm_output_format = (
    "Name=%{name}\nVersion=%{epoch}:%{version}-%{release}\nArchitecture=%{arch}\n"
)

yum_cmd = os.environ.get("CFENGINE_TEST_YUM_CMD", "/usr/bin/yum")
yum_options = ["--quiet", "-y"]

NULLFILE = open(os.devnull, "w")


redirection_is_broken_cached = -1


def redirection_is_broken():
    # Older versions of Python have a bug where it is impossible to redirect
    # stderr using subprocess, and any attempt at redirecting *anything*, not
    # necessarily stderr, will result in it being closed instead. This is very
    # bad, because RPM may then open its RPM database on file descriptor 2
    # (stderr), and will cause it to output error messages directly into the
    # database file. Fortunately "stdout=subprocess.PIPE" doesn't have the bug,
    # and that's good, because it would have been much more tricky to solve.
    global redirection_is_broken_cached
    if redirection_is_broken_cached == -1:
        cmd_line = [sys.executable, sys.argv[0], "internal-test-stderr"]
        if subprocess.call(cmd_line, stdout=sys.stderr) == 0:
            redirection_is_broken_cached = 0
        else:
            redirection_is_broken_cached = 1

    return redirection_is_broken_cached


def subprocess_Popen(cmd, stdout=None, stderr=None):
    if (
        not redirection_is_broken()
        or (stdout is None and stderr is None)
        or stdout == subprocess.PIPE
        or stderr == subprocess.PIPE
    ):
        return subprocess.Popen(cmd, stdout=stdout, stderr=stderr)

    old_stdout_fd = -1
    old_stderr_fd = -1

    if stdout is not None:
        old_stdout_fd = os.dup(1)
        os.dup2(stdout.fileno(), 1)

    if stderr is not None:
        old_stderr_fd = os.dup(2)
        os.dup2(stderr.fileno(), 2)

    result = subprocess.Popen(cmd)

    if old_stdout_fd >= 0:
        os.dup2(old_stdout_fd, 1)
        os.close(old_stdout_fd)

    if old_stderr_fd >= 0:
        os.dup2(old_stderr_fd, 2)
        os.close(old_stderr_fd)

    return result


def subprocess_call(cmd, stdout=None, stderr=None):
    process = subprocess_Popen(cmd, stdout, stderr)
    return process.wait()


def get_package_data():
    pkg_string = ""
    for line in sys.stdin:
        if line.startswith("File="):
            pkg_string = line.split("=", 1)[1].rstrip()
            # Don't break, we need to exhaust stdin.

    if not pkg_string:
        return 1

    if pkg_string.startswith("/"):
        # Absolute file.
        sys.stdout.write("PackageType=file\n")
        sys.stdout.flush()
        process = subprocess_Popen(
            [rpm_cmd, "--qf", rpm_output_format, "-qp", pkg_string],
            stdout=subprocess.PIPE,
        )
        (stdoutdata, _) = process.communicate()

        if process.returncode != 0:
            return process.returncode

        for line in stdoutdata.decode("utf-8").splitlines():
            sys.stdout.write(line.replace("(none):", "") + "\n")

        return 0
    elif re.search("[:,]", pkg_string):
        # Contains an illegal symbol.
        sys.stdout.write(line + "ErrorMessage: Package string with illegal format\n")
        return 1
    else:
        sys.stdout.write("PackageType=repo\n")
        sys.stdout.write("Name=" + pkg_string + "\n")
        return 0


def list_installed():
    # Ignore everything.
    sys.stdin.readlines()

    process = subprocess_Popen(
        [rpm_cmd, "-qa", "--qf", rpm_output_format], stdout=subprocess.PIPE
    )
    (stdoutdata, _) = process.communicate()

    if process.returncode != 0:
        return process.returncode

    for line in stdoutdata.decode("utf-8").splitlines():
        sys.stdout.write(line.replace("(none):", "") + "\n")

    return 0


def list_updates(online):
    global yum_options
    for line in sys.stdin:
        line = line.strip()
        if line.startswith("options="):
            option = line[len("options=") :]
            if option.startswith("-"):
                yum_options.append(option)
            elif option.startswith("enablerepo=") or option.startswith("disablerepo="):
                yum_options.append("--" + option)

    online_flag = []
    if not online:
        online_flag = ["-C"]

    process = subprocess_Popen(
        [yum_cmd] + yum_options + online_flag + ["check-update"], stdout=subprocess.PIPE
    )
    (stdoutdata, _) = process.communicate()
    # analyze return code from `yum check-update`:
    # 0 means no updates
    # 1 means there was an error
    # 100 means that there are available updates
    if process.returncode == 1 and not online:
        # If we get an error when listing local updates, try again using the
        # online method, so that the cache is generated
        process = subprocess_Popen(
            [yum_cmd] + yum_options + ["check-update"], stdout=subprocess.PIPE
        )
        (stdoutdata, _) = process.communicate()
    if process.returncode != 100:
        # either there were no updates or error happened
        # Nothing to do for us here anyway
        return process.returncode
    lastline = ""
    for line in stdoutdata.decode("utf-8").splitlines():
        # Combine multiline entries into one line. A line without at least three
        # space separated fields gets combined with the next line, if that line
        # starts with a space.
        if lastline and (len(line) == 0 or not line[0].isspace()):
            # Line does not start with a space. No combination.
            lastline = ""

        line = lastline + line
        match = re.match(r"^\S+\s+\S+\s+\S+", line)
        if match is None:
            # Keep line
            lastline = line
            continue

        lastline = ""
        match = re.match(
            r"^(?P<name>\S+)\.(?P<arch>[^.\s]+)\s+(?P<version>\S+)\s+\S+\s*$", line
        )
        if match is not None:
            sys.stdout.write("Name=" + match.group("name") + "\n")
            sys.stdout.write("Version=" + match.group("version") + "\n")
            sys.stdout.write("Architecture=" + match.group("arch") + "\n")

    return 0


# Returns a pair:
# List 1: Contains arguments for a single command line.
# List 2: Contains arguments for multiple command lines (see comments in
#         repo_install()).
def one_package_argument(name, arch, version, is_yum_install):
    args = []
    archs = []
    exists = False

    if arch:
        archs.append(arch)

    if is_yum_install:
        process = subprocess_Popen(
            [rpm_cmd, "--qf", "%{arch}\n", "-q", name], stdout=subprocess.PIPE
        )
        existing_archs = [line.decode("utf-8").rstrip() for line in process.stdout]
        process.wait()
        if process.returncode == 0 and existing_archs:
            exists = True
            if not arch:
                # Here we have no specified architecture and we are
                # installing.  If we have existing versions, operate
                # on those, instead of the platform default.
                archs += existing_archs

    version_suffix = ""
    if version:
        version_suffix = "-" + version

    if archs:
        args += [name + version_suffix + "." + arch for arch in archs]
    else:
        args.append(name + version_suffix)

    if exists and version:
        return [], args
    else:
        return args, []


# Returns a pair:
# List 1: Contains arguments for a single command line.
# List 2: Contains arguments for multiple command lines (see comments in
#         repo_install()). This is a list of lists, where the logic is:
#           list
#             |             +---- package1:amd64    -+
#             +- sublist ---+                        +--- Do these together
#             |             +---- package1:i386     -+
#             |
#             |
#             |             +---- package2:amd64    -+
#             +- sublist ---+                        +--- And these together
#                           +---- package2:i386     -+
def package_arguments_builder(is_yum_install):
    name = ""
    version = ""
    arch = ""
    single_cmd_args = []  # List of arguments
    multi_cmd_args = []  # List of lists of arguments
    old_name = ""
    for line in sys.stdin:
        line = line.strip()
        if line.startswith("options="):
            option = line[len("options=") :]
            if option.startswith("-"):
                yum_options.append(option)
            elif option.startswith("enablerepo=") or option.startswith("disablerepo="):
                yum_options.append("--" + option)
        if line.startswith("Name="):
            if name:
                # Each new "Name=" triggers a new entry.
                single_list, multi_list = one_package_argument(
                    name, arch, version, is_yum_install
                )
                single_cmd_args += single_list
                if name == old_name:
                    # Packages that differ only by architecture should be
                    # processed together
                    multi_cmd_args[-1] += multi_list
                elif multi_list:
                    # Otherwise we process them individually.
                    multi_cmd_args += [multi_list]

                version = ""
                arch = ""

            old_name = name
            name = line.split("=", 1)[1].rstrip()

        elif line.startswith("Version="):
            version = line.split("=", 1)[1].rstrip()

        elif line.startswith("Architecture="):
            arch = line.split("=", 1)[1].rstrip()

    if name:
        single_list, multi_list = one_package_argument(
            name, arch, version, is_yum_install
        )
        single_cmd_args += single_list
        if name == old_name:
            # Packages that differ only by architecture should be
            # processed together
            multi_cmd_args[-1] += multi_list
        elif multi_list:
            # Otherwise we process them individually.
            multi_cmd_args += [multi_list]

    return single_cmd_args, multi_cmd_args


def repo_install():
    # Due to how yum works we need to split repo installs into several
    # components.
    #
    # 1. Installation of fresh packages is easy, we add all of them on one
    #    command line.
    # 2. Upgrade of existing packages where no version has been specified is
    #    also easy, we add that to the same command line.
    # 3. Up/downgrade of existing packages where version is specified is
    #    tricky, for several reasons:
    #      a) There is no one yum command that will do both, "install" or
    #         "upgrade" will only upgrade, and "downgrade" will only downgrade.
    #      b) There is no way rpm or yum will tell you which version is higher
    #         than the other, and we know from experience with the old package
    #         promise implementation that we don't want to try to do such a
    #         comparison ourselves.
    #      c) yum has no dry-run mode, so we cannot tell in advance which
    #         operation will succeed.
    #      d) yum will not even tell you whether operation succeeded when you
    #         run it for real
    #
    # So here's what we need to do. We start by querying each package to find
    # out whether that exact version is installed. If it fulfills 1. or 2. we
    # add it to that single command line.
    #
    # If we end up at 3. we need to split the work and do each package
    # separately. We do:
    #
    # 1. Try to upgrade using "yum upgrade".
    # 2. Query the package again, see if it is the right version now.
    # 3. If not, try to downgrade using "yum downgrade".
    # 4. Query the package again, see if it is the right version now.
    # 5. Final safeguard, try installing using "yum install". This may happen
    #    in case we have one architecture already, but we are installing a
    #    second one. In this case only install will work.
    # 6. (No need to check again, CFEngine will do the final check)
    #
    # This is considerably more expensive than what we do for apt, but it's the
    # only way to cover all bases. In apt it will be one apt call for any number
    # of packages, with yum it will in the worst case be:
    #   1 + 5 * number_of_packages
    # although a more common case will probably be:
    #   1 + 2 * number_of_packages
    # since it's unlikely that people will do a whole lot of downgrades
    # simultaneously.

    ret = 0
    single_cmd_args, multi_cmd_args = package_arguments_builder(True)

    if single_cmd_args:
        cmd_line = [yum_cmd] + yum_options + ["install"]
        cmd_line.extend(single_cmd_args)

        ret = subprocess_call(cmd_line, stdout=NULLFILE)

    if multi_cmd_args:
        for block in multi_cmd_args:
            # Try to upgrade.
            cmd_line = [yum_cmd] + yum_options + ["upgrade"] + block
            subprocess_call(cmd_line, stdout=NULLFILE)

            # See if it succeeded.
            success = True
            for item in block:
                cmd_line = [rpm_cmd] + rpm_quiet_option + ["-q", item]
                if subprocess_call(cmd_line, stdout=NULLFILE) != 0:
                    success = False
                    break

            if success:
                continue

            # Try to downgrade.
            cmd_line = [yum_cmd] + yum_options + ["downgrade"] + block
            subprocess_call(cmd_line, stdout=NULLFILE)

            # See if it succeeded.
            success = True
            for item in block:
                cmd_line = [rpm_cmd] + rpm_quiet_option + ["-q", item]
                if subprocess_call(cmd_line, stdout=NULLFILE) != 0:
                    success = False
                    break

            if success:
                continue

            # Try to plain install.
            cmd_line = [yum_cmd] + yum_options + ["install"] + block
            subprocess_call(cmd_line, stdout=NULLFILE)

            # No final check. CFEngine will figure out that it's missing
            # if it failed.

    # ret == 0 doesn't mean we succeeded with everything, but it's expensive to
    # check, so let CFEngine do that.
    return ret


def remove():
    cmd_line = [yum_cmd] + yum_options + ["remove"]

    # package_arguments_builder will always return empty second element in case
    # of removals, so just drop it.         |
    #                                       V
    args = package_arguments_builder(False)[0]

    if args:
        return subprocess_call(cmd_line + args, stdout=NULLFILE)
    return 0


def file_install():
    cmd_line = [rpm_cmd] + rpm_quiet_option + ["--force", "-U"]
    found = False
    for line in sys.stdin:
        if line.startswith("File="):
            found = True
            cmd_line.append(line.split("=", 1)[1].rstrip())

    if not found:
        return 0

    return subprocess_call(cmd_line, stdout=NULLFILE)


def main():
    if len(sys.argv) < 2:
        sys.stderr.write("Need to provide argument\n")
        return 2

    if sys.argv[1] == "internal-test-stderr":
        # This will cause an exception if stderr is closed.
        try:
            os.fstat(2)
        except OSError:
            return 1
        return 0

    elif sys.argv[1] == "supports-api-version":
        sys.stdout.write("1\n")
        return 0

    elif sys.argv[1] == "get-package-data":
        return get_package_data()

    elif sys.argv[1] == "list-installed":
        return list_installed()

    elif sys.argv[1] == "list-updates":
        return list_updates(True)

    elif sys.argv[1] == "list-updates-local":
        return list_updates(False)

    elif sys.argv[1] == "repo-install":
        return repo_install()

    elif sys.argv[1] == "remove":
        return remove()

    elif sys.argv[1] == "file-install":
        return file_install()

    else:
        sys.stderr.write("Invalid operation\n")
        return 2


sys.exit(main())
